diff --git a/src/Configuration.KeyPerFile/Directory.Build.props b/src/Configuration.KeyPerFile/Directory.Build.props new file mode 100644 index 000000000000..63d0c8b102d7 --- /dev/null +++ b/src/Configuration.KeyPerFile/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + true + configuration + $(NoWarn);PKG0001 + + diff --git a/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.csproj b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.csproj new file mode 100644 index 000000000000..8dbe6d3ba11c --- /dev/null +++ b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + + + diff --git a/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netcoreapp.cs b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netcoreapp.cs new file mode 100644 index 000000000000..d6e800587ad9 --- /dev/null +++ b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netcoreapp.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Configuration +{ + public static partial class KeyPerFileConfigurationBuilderExtensions + { + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, System.Action configureSource) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional, bool reloadOnChange) { throw null; } + } +} +namespace Microsoft.Extensions.Configuration.KeyPerFile +{ + public partial class KeyPerFileConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider, System.IDisposable + { + public KeyPerFileConfigurationProvider(Microsoft.Extensions.Configuration.KeyPerFile.KeyPerFileConfigurationSource source) { } + public void Dispose() { } + public override void Load() { } + public override string ToString() { throw null; } + } + public partial class KeyPerFileConfigurationSource : Microsoft.Extensions.Configuration.IConfigurationSource + { + public KeyPerFileConfigurationSource() { } + public Microsoft.Extensions.FileProviders.IFileProvider FileProvider { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public System.Func IgnoreCondition { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string IgnorePrefix { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public bool Optional { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public int ReloadDelay { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public bool ReloadOnChange { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public Microsoft.Extensions.Configuration.IConfigurationProvider Build(Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { throw null; } + } +} diff --git a/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netstandard2.0.cs b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netstandard2.0.cs new file mode 100644 index 000000000000..d6e800587ad9 --- /dev/null +++ b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netstandard2.0.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Configuration +{ + public static partial class KeyPerFileConfigurationBuilderExtensions + { + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, System.Action configureSource) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional, bool reloadOnChange) { throw null; } + } +} +namespace Microsoft.Extensions.Configuration.KeyPerFile +{ + public partial class KeyPerFileConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider, System.IDisposable + { + public KeyPerFileConfigurationProvider(Microsoft.Extensions.Configuration.KeyPerFile.KeyPerFileConfigurationSource source) { } + public void Dispose() { } + public override void Load() { } + public override string ToString() { throw null; } + } + public partial class KeyPerFileConfigurationSource : Microsoft.Extensions.Configuration.IConfigurationSource + { + public KeyPerFileConfigurationSource() { } + public Microsoft.Extensions.FileProviders.IFileProvider FileProvider { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public System.Func IgnoreCondition { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string IgnorePrefix { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public bool Optional { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public int ReloadDelay { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public bool ReloadOnChange { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public Microsoft.Extensions.Configuration.IConfigurationProvider Build(Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { throw null; } + } +} diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs new file mode 100644 index 000000000000..e4c8dd58eea5 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using Microsoft.Extensions.Configuration.KeyPerFile; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.Extensions.Configuration +{ + /// + /// Extension methods for registering with . + /// + public static class KeyPerFileConfigurationBuilderExtensions + { + /// + /// Adds configuration using files from a directory. File names are used as the key, + /// file contents are used as the value. + /// + /// The to add to. + /// The path to the directory. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, string directoryPath) + => builder.AddKeyPerFile(directoryPath, optional: false, reloadOnChange: false); + + /// + /// Adds configuration using files from a directory. File names are used as the key, + /// file contents are used as the value. + /// + /// The to add to. + /// The path to the directory. + /// Whether the directory is optional. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, string directoryPath, bool optional) + => builder.AddKeyPerFile(directoryPath, optional, reloadOnChange: false); + + /// + /// Adds configuration using files from a directory. File names are used as the key, + /// file contents are used as the value. + /// + /// The to add to. + /// The path to the directory. + /// Whether the directory is optional. + /// Whether the configuration should be reloaded if the files are changed, added or removed. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, string directoryPath, bool optional, bool reloadOnChange) + => builder.AddKeyPerFile(source => + { + // Only try to set the file provider if its not optional or the directory exists + if (!optional || Directory.Exists(directoryPath)) + { + source.FileProvider = new PhysicalFileProvider(directoryPath); + } + source.Optional = optional; + source.ReloadOnChange = reloadOnChange; + }); + + /// + /// Adds configuration using files from a directory. File names are used as the key, + /// file contents are used as the value. + /// + /// The to add to. + /// Configures the source. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, Action configureSource) + => builder.Add(configureSource); + } +} diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs new file mode 100644 index 000000000000..f586896fc275 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.Configuration.KeyPerFile +{ + /// + /// A that uses a directory's files as configuration key/values. + /// + public class KeyPerFileConfigurationProvider : ConfigurationProvider, IDisposable + { + private readonly IDisposable _changeTokenRegistration; + + KeyPerFileConfigurationSource Source { get; set; } + + /// + /// Initializes a new instance. + /// + /// The settings. + public KeyPerFileConfigurationProvider(KeyPerFileConfigurationSource source) + { + Source = source ?? throw new ArgumentNullException(nameof(source)); + + if (Source.ReloadOnChange && Source.FileProvider != null) + { + _changeTokenRegistration = ChangeToken.OnChange( + () => Source.FileProvider.Watch("*"), + () => + { + Thread.Sleep(Source.ReloadDelay); + Load(reload: true); + }); + } + + } + + private static string NormalizeKey(string key) + => key.Replace("__", ConfigurationPath.KeyDelimiter); + + private static string TrimNewLine(string value) + => value.EndsWith(Environment.NewLine) + ? value.Substring(0, value.Length - Environment.NewLine.Length) + : value; + + /// + /// Loads the configuration values. + /// + public override void Load() + { + Load(reload: false); + } + + private void Load(bool reload) + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (Source.FileProvider == null) + { + if (Source.Optional || reload) // Always optional on reload + { + Data = data; + return; + } + + throw new DirectoryNotFoundException("A non-null file provider for the directory is required when this source is not optional."); + } + + var directory = Source.FileProvider.GetDirectoryContents("/"); + if (!directory.Exists) + { + if (Source.Optional || reload) // Always optional on reload + { + Data = data; + return; + } + throw new DirectoryNotFoundException("The root directory for the FileProvider doesn't exist and is not optional."); + } + else + { + foreach (var file in directory) + { + if (file.IsDirectory) + { + continue; + } + + using var stream = file.CreateReadStream(); + using var streamReader = new StreamReader(stream); + + if (Source.IgnoreCondition == null || !Source.IgnoreCondition(file.Name)) + { + data.Add(NormalizeKey(file.Name), TrimNewLine(streamReader.ReadToEnd())); + } + + } + } + + Data = data; + } + + private string GetDirectoryName() + => Source.FileProvider?.GetFileInfo("/")?.PhysicalPath ?? ""; + + /// + /// Generates a string representing this provider name and relevant details. + /// + /// The configuration name. + public override string ToString() + => $"{GetType().Name} for files in '{GetDirectoryName()}' ({(Source.Optional ? "Optional" : "Required")})"; + + /// + public void Dispose() + { + _changeTokenRegistration?.Dispose(); + } + } +} diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs new file mode 100644 index 000000000000..2b64d5a8dda7 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.Extensions.Configuration.KeyPerFile +{ + /// + /// An used to configure . + /// + public class KeyPerFileConfigurationSource : IConfigurationSource + { + /// + /// Constructor; + /// + public KeyPerFileConfigurationSource() + => IgnoreCondition = s => IgnorePrefix != null && s.StartsWith(IgnorePrefix); + + /// + /// The FileProvider whos root "/" directory files will be used as configuration data. + /// + public IFileProvider FileProvider { get; set; } + + /// + /// Files that start with this prefix will be excluded. + /// Defaults to "ignore.". + /// + public string IgnorePrefix { get; set; } = "ignore."; + + /// + /// Used to determine if a file should be ignored using its name. + /// Defaults to using the IgnorePrefix. + /// + public Func IgnoreCondition { get; set; } + + /// + /// If false, will throw if the directory doesn't exist. + /// + public bool Optional { get; set; } + + /// + /// Determines whether the source will be loaded if the underlying file changes. + /// + public bool ReloadOnChange { get; set; } + + /// + /// Number of milliseconds that reload will wait before calling Load. This helps + /// avoid triggering reload before a file is completely written. Default is 250. + /// + public int ReloadDelay { get; set; } = 250; + + /// + /// Builds the for this source. + /// + /// The . + /// A + public IConfigurationProvider Build(IConfigurationBuilder builder) + => new KeyPerFileConfigurationProvider(this); + } +} diff --git a/src/Configuration.KeyPerFile/src/Microsoft.Extensions.Configuration.KeyPerFile.csproj b/src/Configuration.KeyPerFile/src/Microsoft.Extensions.Configuration.KeyPerFile.csproj new file mode 100644 index 000000000000..7f9c5e7eb193 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/Microsoft.Extensions.Configuration.KeyPerFile.csproj @@ -0,0 +1,16 @@ + + + + Configuration provider that uses files in a directory for Microsoft.Extensions.Configuration. + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(DefaultNetCoreTargetFramework) + true + true + + + + + + + + diff --git a/src/Configuration.KeyPerFile/src/README.md b/src/Configuration.KeyPerFile/src/README.md new file mode 100644 index 000000000000..29952e9139dc --- /dev/null +++ b/src/Configuration.KeyPerFile/src/README.md @@ -0,0 +1,2 @@ + +This is a configuration provider that uses a directory's files as data. A file's name is the key and the contents are the value. diff --git a/src/Configuration.KeyPerFile/test/ConfigurationProviderCommandLineTest.cs b/src/Configuration.KeyPerFile/test/ConfigurationProviderCommandLineTest.cs new file mode 100644 index 000000000000..066aecf33756 --- /dev/null +++ b/src/Configuration.KeyPerFile/test/ConfigurationProviderCommandLineTest.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration.Test; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.Extensions.Configuration.KeyPerFile.Test +{ + public class ConfigurationProviderCommandLineTest : ConfigurationProviderTestBase + { + protected override (IConfigurationProvider Provider, Action Initializer) LoadThroughProvider( + TestSection testConfig) + { + var testFiles = new List(); + SectionToTestFiles(testFiles, "", testConfig); + + var provider = new KeyPerFileConfigurationProvider( + new KeyPerFileConfigurationSource + { + Optional = true, + FileProvider = new TestFileProvider(testFiles.ToArray()) + }); + + return (provider, () => { }); + } + + private void SectionToTestFiles(List testFiles, string sectionName, TestSection section) + { + foreach (var tuple in section.Values.SelectMany(e => e.Value.Expand(e.Key))) + { + testFiles.Add(new TestFile(sectionName + tuple.Key, tuple.Value)); + } + + foreach (var tuple in section.Sections) + { + SectionToTestFiles(testFiles, sectionName + tuple.Key + "__", tuple.Section); + } + } + } +} diff --git a/src/Configuration.KeyPerFile/test/ConfigurationProviderTestBase.cs b/src/Configuration.KeyPerFile/test/ConfigurationProviderTestBase.cs new file mode 100644 index 000000000000..4609ee2560ff --- /dev/null +++ b/src/Configuration.KeyPerFile/test/ConfigurationProviderTestBase.cs @@ -0,0 +1,761 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Configuration.Memory; +using Xunit; + +namespace Microsoft.Extensions.Configuration.Test +{ + public abstract class ConfigurationProviderTestBase + { + [Fact] + public virtual void Load_from_single_provider() + { + var configRoot = BuildConfigRoot(LoadThroughProvider(TestSection.TestConfig)); + + AssertConfig(configRoot); + } + + [Fact] + public virtual void Has_debug_view() + { + var configRoot = BuildConfigRoot(LoadThroughProvider(TestSection.TestConfig)); + var providerTag = configRoot.Providers.Single().ToString(); + + var expected = + $@"Key1=Value1 ({providerTag}) +Section1: + Key2=Value12 ({providerTag}) + Section2: + Key3=Value123 ({providerTag}) + Key3a: + 0=ArrayValue0 ({providerTag}) + 1=ArrayValue1 ({providerTag}) + 2=ArrayValue2 ({providerTag}) +Section3: + Section4: + Key4=Value344 ({providerTag}) +"; + + AssertDebugView(configRoot, expected); + } + + [Fact] + public virtual void Null_values_are_included_in_the_config() + { + AssertConfig(BuildConfigRoot(LoadThroughProvider(TestSection.NullsTestConfig)), expectNulls: true, nullValue: ""); + } + + [Fact] + public virtual void Combine_after_other_provider() + { + AssertConfig( + BuildConfigRoot( + LoadUsingMemoryProvider(TestSection.MissingSection2ValuesConfig), + LoadThroughProvider(TestSection.MissingSection4Config))); + + AssertConfig( + BuildConfigRoot( + LoadUsingMemoryProvider(TestSection.MissingSection4Config), + LoadThroughProvider(TestSection.MissingSection2ValuesConfig))); + } + + [Fact] + public virtual void Combine_before_other_provider() + { + AssertConfig( + BuildConfigRoot( + LoadThroughProvider(TestSection.MissingSection2ValuesConfig), + LoadUsingMemoryProvider(TestSection.MissingSection4Config))); + + AssertConfig( + BuildConfigRoot( + LoadThroughProvider(TestSection.MissingSection4Config), + LoadUsingMemoryProvider(TestSection.MissingSection2ValuesConfig))); + } + + [Fact] + public virtual void Second_provider_overrides_values_from_first() + { + AssertConfig( + BuildConfigRoot( + LoadUsingMemoryProvider(TestSection.NoValuesTestConfig), + LoadThroughProvider(TestSection.TestConfig))); + } + + [Fact] + public virtual void Combining_from_multiple_providers_is_case_insensitive() + { + AssertConfig( + BuildConfigRoot( + LoadUsingMemoryProvider(TestSection.DifferentCasedTestConfig), + LoadThroughProvider(TestSection.TestConfig))); + } + + [Fact] + public virtual void Load_from_single_provider_with_duplicates_throws() + { + AssertFormatOrArgumentException( + () => BuildConfigRoot(LoadThroughProvider(TestSection.DuplicatesTestConfig))); + } + + [Fact] + public virtual void Load_from_single_provider_with_differing_case_duplicates_throws() + { + AssertFormatOrArgumentException( + () => BuildConfigRoot(LoadThroughProvider(TestSection.DuplicatesDifferentCaseTestConfig))); + } + + private void AssertFormatOrArgumentException(Action test) + { + Exception caught = null; + try + { + test(); + } + catch (Exception e) + { + caught = e; + } + + Assert.True(caught is ArgumentException + || caught is FormatException); + } + + [Fact] + public virtual void Bind_to_object() + { + var configuration = BuildConfigRoot(LoadThroughProvider(TestSection.TestConfig)); + + var options = configuration.Get(); + + Assert.Equal("Value1", options.Key1); + Assert.Equal("Value12", options.Section1.Key2); + Assert.Equal("Value123", options.Section1.Section2.Key3); + Assert.Equal("Value344", options.Section3.Section4.Key4); + Assert.Equal(new[] { "ArrayValue0", "ArrayValue1", "ArrayValue2" }, options.Section1.Section2.Key3a); + } + + public class AsOptions + { + public string Key1 { get; set; } + + public Section1AsOptions Section1 { get; set; } + public Section3AsOptions Section3 { get; set; } + } + + public class Section1AsOptions + { + public string Key2 { get; set; } + + public Section2AsOptions Section2 { get; set; } + } + + public class Section2AsOptions + { + public string Key3 { get; set; } + public string[] Key3a { get; set; } + } + + public class Section3AsOptions + { + public Section4AsOptions Section4 { get; set; } + } + + public class Section4AsOptions + { + public string Key4 { get; set; } + } + + protected virtual void AssertDebugView( + IConfigurationRoot config, + string expected) + { + string RemoveLineEnds(string source) => source.Replace("\n", "").Replace("\r", ""); + + var actual = config.GetDebugView(); + + Assert.Equal( + RemoveLineEnds(expected), + RemoveLineEnds(actual)); + } + + protected virtual void AssertConfig( + IConfigurationRoot config, + bool expectNulls = false, + string nullValue = null) + { + var value1 = expectNulls ? nullValue : "Value1"; + var value12 = expectNulls ? nullValue : "Value12"; + var value123 = expectNulls ? nullValue : "Value123"; + var arrayvalue0 = expectNulls ? nullValue : "ArrayValue0"; + var arrayvalue1 = expectNulls ? nullValue : "ArrayValue1"; + var arrayvalue2 = expectNulls ? nullValue : "ArrayValue2"; + var value344 = expectNulls ? nullValue : "Value344"; + + Assert.Equal(value1, config["Key1"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value12, config["Section1:Key2"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value123, config["Section1:Section2:Key3"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue0, config["Section1:Section2:Key3a:0"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue1, config["Section1:Section2:Key3a:1"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue2, config["Section1:Section2:Key3a:2"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value344, config["Section3:Section4:Key4"], StringComparer.InvariantCultureIgnoreCase); + + var section1 = config.GetSection("Section1"); + Assert.Equal(value12, section1["Key2"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value123, section1["Section2:Key3"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue0, section1["Section2:Key3a:0"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue1, section1["Section2:Key3a:1"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue2, section1["Section2:Key3a:2"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1", section1.Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(section1.Value); + + var section2 = config.GetSection("Section1:Section2"); + Assert.Equal(value123, section2["Key3"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue0, section2["Key3a:0"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue1, section2["Key3a:1"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue2, section2["Key3a:2"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2", section2.Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(section2.Value); + + section2 = section1.GetSection("Section2"); + Assert.Equal(value123, section2["Key3"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue0, section2["Key3a:0"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue1, section2["Key3a:1"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue2, section2["Key3a:2"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2", section2.Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(section2.Value); + + var section3a = section2.GetSection("Key3a"); + Assert.Equal(arrayvalue0, section3a["0"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue1, section3a["1"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue2, section3a["2"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2:Key3a", section3a.Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(section3a.Value); + + var section3 = config.GetSection("Section3"); + Assert.Equal("Section3", section3.Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(section3.Value); + + var section4 = config.GetSection("Section3:Section4"); + Assert.Equal(value344, section4["Key4"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section3:Section4", section4.Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(section4.Value); + + section4 = config.GetSection("Section3").GetSection("Section4"); + Assert.Equal(value344, section4["Key4"], StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section3:Section4", section4.Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(section4.Value); + + var sections = config.GetChildren().ToList(); + + Assert.Equal(3, sections.Count); + + Assert.Equal("Key1", sections[0].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Key1", sections[0].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value1, sections[0].Value, StringComparer.InvariantCultureIgnoreCase); + + Assert.Equal("Section1", sections[1].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1", sections[1].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(sections[1].Value); + + Assert.Equal("Section3", sections[2].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section3", sections[2].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(sections[2].Value); + + sections = section1.GetChildren().ToList(); + + Assert.Equal(2, sections.Count); + + Assert.Equal("Key2", sections[0].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Key2", sections[0].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value12, sections[0].Value, StringComparer.InvariantCultureIgnoreCase); + + Assert.Equal("Section2", sections[1].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2", sections[1].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(sections[1].Value); + + sections = section2.GetChildren().ToList(); + + Assert.Equal(2, sections.Count); + + Assert.Equal("Key3", sections[0].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2:Key3", sections[0].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value123, sections[0].Value, StringComparer.InvariantCultureIgnoreCase); + + Assert.Equal("Key3a", sections[1].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2:Key3a", sections[1].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(sections[1].Value); + + sections = section3a.GetChildren().ToList(); + + Assert.Equal(3, sections.Count); + + Assert.Equal("0", sections[0].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2:Key3a:0", sections[0].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue0, sections[0].Value, StringComparer.InvariantCultureIgnoreCase); + + Assert.Equal("1", sections[1].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2:Key3a:1", sections[1].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue1, sections[1].Value, StringComparer.InvariantCultureIgnoreCase); + + Assert.Equal("2", sections[2].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section1:Section2:Key3a:2", sections[2].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(arrayvalue2, sections[2].Value, StringComparer.InvariantCultureIgnoreCase); + + sections = section3.GetChildren().ToList(); + + Assert.Single(sections); + + Assert.Equal("Section4", sections[0].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section3:Section4", sections[0].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Null(sections[0].Value); + + sections = section4.GetChildren().ToList(); + + Assert.Single(sections); + + Assert.Equal("Key4", sections[0].Key, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal("Section3:Section4:Key4", sections[0].Path, StringComparer.InvariantCultureIgnoreCase); + Assert.Equal(value344, sections[0].Value, StringComparer.InvariantCultureIgnoreCase); + } + + protected abstract (IConfigurationProvider Provider, Action Initializer) LoadThroughProvider(TestSection testConfig); + + protected virtual IConfigurationRoot BuildConfigRoot( + params (IConfigurationProvider Provider, Action Initializer)[] providers) + { + var root = new ConfigurationRoot(providers.Select(e => e.Provider).ToList()); + + foreach (var initializer in providers.Select(e => e.Initializer)) + { + initializer(); + } + + return root; + } + + protected static (IConfigurationProvider Provider, Action Initializer) LoadUsingMemoryProvider(TestSection testConfig) + { + var values = new List>(); + SectionToValues(testConfig, "", values); + + return (new MemoryConfigurationProvider( + new MemoryConfigurationSource + { + InitialData = values + }), + () => { }); + } + + protected static void SectionToValues( + TestSection section, + string sectionName, + IList> values) + { + foreach (var tuple in section.Values.SelectMany(e => e.Value.Expand(e.Key))) + { + values.Add(new KeyValuePair(sectionName + tuple.Key, tuple.Value)); + } + + foreach (var tuple in section.Sections) + { + SectionToValues( + tuple.Section, + sectionName + tuple.Key + ":", + values); + } + } + + protected class TestKeyValue + { + public object Value { get; } + + public TestKeyValue(string value) + { + Value = value; + } + + public TestKeyValue(string[] values) + { + Value = values; + } + + public static implicit operator TestKeyValue(string value) => new TestKeyValue(value); + public static implicit operator TestKeyValue(string[] values) => new TestKeyValue(values); + + public string[] AsArray => Value as string[]; + + public string AsString => Value as string; + + public IEnumerable<(string Key, string Value)> Expand(string key) + { + if (AsArray == null) + { + yield return (key, AsString); + } + else + { + for (var i = 0; i < AsArray.Length; i++) + { + yield return ($"{key}:{i}", AsArray[i]); + } + } + } + } + + protected class TestSection + { + public IEnumerable<(string Key, TestKeyValue Value)> Values { get; set; } + = Enumerable.Empty<(string, TestKeyValue)>(); + + public IEnumerable<(string Key, TestSection Section)> Sections { get; set; } + = Enumerable.Empty<(string, TestSection)>(); + + public static TestSection TestConfig { get; } + = new TestSection + { + Values = new[] { ("Key1", (TestKeyValue)"Value1") }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] {("Key2", (TestKeyValue)"Value12")}, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", (TestKeyValue)"Value123"), + ("Key3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2"}) + }, + }) + } + }), + ("Section3", new TestSection + { + Sections = new[] + { + ("Section4", new TestSection + { + Values = new[] {("Key4", (TestKeyValue)"Value344")} + }) + } + }) + } + }; + + public static TestSection NoValuesTestConfig { get; } + = new TestSection + { + Values = new[] { ("Key1", (TestKeyValue)"------") }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] {("Key2", (TestKeyValue)"-------")}, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", (TestKeyValue)"-----"), + ("Key3a", (TestKeyValue)new[] {"-----------", "-----------", "-----------"}) + }, + }) + } + }), + ("Section3", new TestSection + { + Sections = new[] + { + ("Section4", new TestSection + { + Values = new[] {("Key4", (TestKeyValue)"--------")} + }) + } + }) + } + }; + + public static TestSection MissingSection2ValuesConfig { get; } + = new TestSection + { + Values = new[] { ("Key1", (TestKeyValue)"Value1") }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] {("Key2", (TestKeyValue)"Value12")}, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3a", (TestKeyValue)new[] {"ArrayValue0"}) + }, + }) + } + }), + ("Section3", new TestSection + { + Sections = new[] + { + ("Section4", new TestSection + { + Values = new[] {("Key4", (TestKeyValue)"Value344")} + }) + } + }) + } + }; + + + public static TestSection MissingSection4Config { get; } + = new TestSection + { + Values = new[] { ("Key1", (TestKeyValue)"Value1") }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] {("Key2", (TestKeyValue)"Value12")}, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", (TestKeyValue)"Value123"), + ("Key3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2"}) + }, + }) + } + }), + ("Section3", new TestSection()) + } + }; + + public static TestSection DifferentCasedTestConfig { get; } + = new TestSection + { + Values = new[] { ("KeY1", (TestKeyValue)"Value1") }, + Sections = new[] + { + ("SectioN1", new TestSection + { + Values = new[] {("KeY2", (TestKeyValue)"Value12")}, + Sections = new[] + { + ("SectioN2", new TestSection + { + Values = new[] + { + ("KeY3", (TestKeyValue)"Value123"), + ("KeY3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2"}) + }, + }) + } + }), + ("SectioN3", new TestSection + { + Sections = new[] + { + ("SectioN4", new TestSection + { + Values = new[] {("KeY4", (TestKeyValue)"Value344")} + }) + } + }) + } + }; + + public static TestSection DuplicatesTestConfig { get; } + = new TestSection + { + Values = new[] + { + ("Key1", (TestKeyValue)"Value1"), + ("Key1", (TestKeyValue)"Value1") + }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] {("Key2", (TestKeyValue)"Value12")}, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", (TestKeyValue)"Value123"), + ("Key3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2"}) + }, + }), + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", (TestKeyValue)"Value123"), + ("Key3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2"}) + }, + }) + + } + }), + ("Section3", new TestSection + { + Sections = new[] + { + ("Section4", new TestSection + { + Values = new[] {("Key4", (TestKeyValue)"Value344")} + }) + } + }) + } + }; + + public static TestSection DuplicatesDifferentCaseTestConfig { get; } + = new TestSection + { + Values = new[] + { + ("Key1", (TestKeyValue)"Value1"), + ("KeY1", (TestKeyValue)"Value1") + }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] {("Key2", (TestKeyValue)"Value12")}, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", (TestKeyValue)"Value123"), + ("Key3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2"}) + }, + }), + ("SectioN2", new TestSection + { + Values = new[] + { + ("KeY3", (TestKeyValue)"Value123"), + ("KeY3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2"}) + }, + }) + + } + }), + ("Section3", new TestSection + { + Sections = new[] + { + ("Section4", new TestSection + { + Values = new[] {("Key4", (TestKeyValue)"Value344")} + }) + } + }) + } + }; + + public static TestSection NullsTestConfig { get; } + = new TestSection + { + Values = new[] { ("Key1", new TestKeyValue((string)null)) }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] {("Key2", new TestKeyValue((string)null))}, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", new TestKeyValue((string)null)), + ("Key3a", (TestKeyValue)new string[] {null, null, null}) + }, + }) + } + }), + ("Section3", new TestSection + { + Sections = new[] + { + ("Section4", new TestSection + { + Values = new[] {("Key4", new TestKeyValue((string)null))} + }) + } + }) + } + }; + + public static TestSection ExtraValuesTestConfig { get; } + = new TestSection + { + Values = new[] + { + ("Key1", (TestKeyValue)"Value1"), + ("Key1r", (TestKeyValue)"Value1r") + }, + Sections = new[] + { + ("Section1", new TestSection + { + Values = new[] + { + ("Key2", (TestKeyValue)"Value12"), + ("Key2r", (TestKeyValue)"Value12r") + }, + Sections = new[] + { + ("Section2", new TestSection + { + Values = new[] + { + ("Key3", (TestKeyValue)"Value123"), + ("Key3a", (TestKeyValue)new[] {"ArrayValue0", "ArrayValue1", "ArrayValue2", "ArrayValue2r"}), + ("Key3ar", (TestKeyValue)new[] {"ArrayValue0r"}) + }, + }) + } + }), + ("Section3", new TestSection + { + Sections = new[] + { + ("Section4", new TestSection + { + Values = new[] {("Key4", (TestKeyValue)"Value344")} + }) + } + }), + ("Section5r", new TestSection + { + Sections = new[] + { + ("Section6r", new TestSection + { + Values = new[] {("Key5r", (TestKeyValue)"Value565r")} + }) + } + }) + } + }; + } + } +} diff --git a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs new file mode 100644 index 000000000000..794f7abf23fc --- /dev/null +++ b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs @@ -0,0 +1,416 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.Extensions.Configuration.KeyPerFile.Test +{ + public class KeyPerFileTests + { + [Fact] + public void DoesNotThrowWhenOptionalAndNoSecrets() + { + new ConfigurationBuilder().AddKeyPerFile(o => o.Optional = true).Build(); + } + + [Fact] + public void DoesNotThrowWhenOptionalAndDirectoryDoesntExist() + { + new ConfigurationBuilder().AddKeyPerFile("nonexistent", true).Build(); + } + + [Fact] + public void ThrowsWhenNotOptionalAndDirectoryDoesntExist() + { + var e = Assert.Throws(() => new ConfigurationBuilder().AddKeyPerFile("nonexistent", false).Build()); + Assert.Contains("The path must be absolute.", e.Message); + } + + [Fact] + public void CanLoadMultipleSecrets() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanLoadMultipleSecretsWithDirectory() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2"), + new TestFile("directory")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanLoadNestedKeys() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret0__Secret1__Secret2__Key", "SecretValue2"), + new TestFile("Secret0__Secret1__Key", "SecretValue1"), + new TestFile("Secret0__Key", "SecretValue0")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Equal("SecretValue0", config["Secret0:Key"]); + Assert.Equal("SecretValue1", config["Secret0:Secret1:Key"]); + Assert.Equal("SecretValue2", config["Secret0:Secret1:Secret2:Key"]); + } + + [Fact] + public void CanIgnoreFilesWithDefault() + { + var testFileProvider = new TestFileProvider( + new TestFile("ignore.Secret0", "SecretValue0"), + new TestFile("ignore.Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Null(config["ignore.Secret0"]); + Assert.Null(config["ignore.Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanTurnOffDefaultIgnorePrefixWithCondition() + { + var testFileProvider = new TestFileProvider( + new TestFile("ignore.Secret0", "SecretValue0"), + new TestFile("ignore.Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnoreCondition = null; + }) + .Build(); + + Assert.Equal("SecretValue0", config["ignore.Secret0"]); + Assert.Equal("SecretValue1", config["ignore.Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanIgnoreAllWithCondition() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret0", "SecretValue0"), + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnoreCondition = s => true; + }) + .Build(); + + Assert.Empty(config.AsEnumerable()); + } + + [Fact] + public void CanIgnoreFilesWithCustomIgnore() + { + var testFileProvider = new TestFileProvider( + new TestFile("meSecret0", "SecretValue0"), + new TestFile("meSecret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnorePrefix = "me"; + }) + .Build(); + + Assert.Null(config["meSecret0"]); + Assert.Null(config["meSecret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanUnIgnoreDefaultFiles() + { + var testFileProvider = new TestFileProvider( + new TestFile("ignore.Secret0", "SecretValue0"), + new TestFile("ignore.Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnorePrefix = null; + }) + .Build(); + + Assert.Equal("SecretValue0", config["ignore.Secret0"]); + Assert.Equal("SecretValue1", config["ignore.Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void BindingDoesNotThrowIfReloadedDuringBinding() + { + var testFileProvider = new TestFileProvider( + new TestFile("Number", "-2"), + new TestFile("Text", "Foo")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + MyOptions options = null; + + using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(250))) + { + void ReloadLoop() + { + while (!cts.IsCancellationRequested) + { + config.Reload(); + } + } + + _ = Task.Run(ReloadLoop); + + while (!cts.IsCancellationRequested) + { + options = config.Get(); + } + } + + Assert.Equal(-2, options.Number); + Assert.Equal("Foo", options.Text); + } + + [Fact] + public void ReloadConfigWhenReloadOnChangeIsTrue() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.ReloadOnChange = true; + }).Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + + testFileProvider.ChangeFiles( + new TestFile("Secret1", "NewSecretValue1"), + new TestFile("Secret3", "NewSecretValue3")); + + Assert.Equal("NewSecretValue1", config["Secret1"]); + Assert.Null(config["NewSecret2"]); + Assert.Equal("NewSecretValue3", config["Secret3"]); + } + + [Fact] + public void SameConfigWhenReloadOnChangeIsFalse() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.ReloadOnChange = false; + }).Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + + testFileProvider.ChangeFiles( + new TestFile("Secret1", "NewSecretValue1"), + new TestFile("Secret3", "NewSecretValue3")); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void NoFilesReloadWhenAddedFiles() + { + var testFileProvider = new TestFileProvider(); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.ReloadOnChange = true; + }).Build(); + + Assert.Empty(config.AsEnumerable()); + + testFileProvider.ChangeFiles( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + private sealed class MyOptions + { + public int Number { get; set; } + public string Text { get; set; } + } + } + + class TestFileProvider : IFileProvider + { + IDirectoryContents _contents; + MockChangeToken _changeToken; + + public TestFileProvider(params IFileInfo[] files) + { + _contents = new TestDirectoryContents(files); + _changeToken = new MockChangeToken(); + } + + public IDirectoryContents GetDirectoryContents(string subpath) => _contents; + + public IFileInfo GetFileInfo(string subpath) => new TestFile("TestDirectory"); + + public IChangeToken Watch(string filter) => _changeToken; + + internal void ChangeFiles(params IFileInfo[] files) + { + _contents = new TestDirectoryContents(files); + _changeToken.RaiseCallback(); + } + } + + class MockChangeToken : IChangeToken + { + private Action _callback; + + public bool ActiveChangeCallbacks => true; + + public bool HasChanged => true; + + public IDisposable RegisterChangeCallback(Action callback, object state) + { + var disposable = new MockDisposable(); + _callback = () => callback(state); + return disposable; + } + + internal void RaiseCallback() + { + _callback?.Invoke(); + } + } + + class MockDisposable : IDisposable + { + public bool Disposed { get; set; } + + public void Dispose() + { + Disposed = true; + } + } + + class TestDirectoryContents : IDirectoryContents + { + List _list; + + public TestDirectoryContents(params IFileInfo[] files) + { + _list = new List(files); + } + + public bool Exists => true; + + public IEnumerator GetEnumerator() => _list.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + //TODO: Probably need a directory and file type. + class TestFile : IFileInfo + { + private readonly string _name; + private readonly string _contents; + + public bool Exists => true; + + public bool IsDirectory + { + get; + } + + public DateTimeOffset LastModified => throw new NotImplementedException(); + + public long Length => throw new NotImplementedException(); + + public string Name => _name; + + public string PhysicalPath => "Root/" + Name; + + public TestFile(string name) + { + _name = name; + IsDirectory = true; + } + + public TestFile(string name, string contents) + { + _name = name; + _contents = contents; + } + + public Stream CreateReadStream() + { + if (IsDirectory) + { + throw new InvalidOperationException("Cannot create stream from directory"); + } + + return _contents == null + ? new MemoryStream() + : new MemoryStream(Encoding.UTF8.GetBytes(_contents)); + } + } +} diff --git a/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj b/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj new file mode 100644 index 000000000000..7abceed7028d --- /dev/null +++ b/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj @@ -0,0 +1,22 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + + + + + + + + + + + + + + + + diff --git a/src/FileProviders/Directory.Build.props b/src/FileProviders/Directory.Build.props new file mode 100644 index 000000000000..709c47ddbd7a --- /dev/null +++ b/src/FileProviders/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + true + files;filesystem + + diff --git a/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.csproj b/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.csproj new file mode 100644 index 000000000000..89cba5ba5723 --- /dev/null +++ b/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + diff --git a/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.netcoreapp.cs b/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.netcoreapp.cs new file mode 100644 index 000000000000..033174efc01e --- /dev/null +++ b/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.netcoreapp.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.FileProviders +{ + public partial class EmbeddedFileProvider : Microsoft.Extensions.FileProviders.IFileProvider + { + public EmbeddedFileProvider(System.Reflection.Assembly assembly) { } + public EmbeddedFileProvider(System.Reflection.Assembly assembly, string baseNamespace) { } + public Microsoft.Extensions.FileProviders.IDirectoryContents GetDirectoryContents(string subpath) { throw null; } + public Microsoft.Extensions.FileProviders.IFileInfo GetFileInfo(string subpath) { throw null; } + public Microsoft.Extensions.Primitives.IChangeToken Watch(string pattern) { throw null; } + } + public partial class ManifestEmbeddedFileProvider : Microsoft.Extensions.FileProviders.IFileProvider + { + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly) { } + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly, string root) { } + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly, string root, System.DateTimeOffset lastModified) { } + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly, string root, string manifestName, System.DateTimeOffset lastModified) { } + public System.Reflection.Assembly Assembly { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.FileProviders.IDirectoryContents GetDirectoryContents(string subpath) { throw null; } + public Microsoft.Extensions.FileProviders.IFileInfo GetFileInfo(string subpath) { throw null; } + public Microsoft.Extensions.Primitives.IChangeToken Watch(string filter) { throw null; } + } +} +namespace Microsoft.Extensions.FileProviders.Embedded +{ + public partial class EmbeddedResourceFileInfo : Microsoft.Extensions.FileProviders.IFileInfo + { + public EmbeddedResourceFileInfo(System.Reflection.Assembly assembly, string resourcePath, string name, System.DateTimeOffset lastModified) { } + public bool Exists { get { throw null; } } + public bool IsDirectory { get { throw null; } } + public System.DateTimeOffset LastModified { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public long Length { get { throw null; } } + public string Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string PhysicalPath { get { throw null; } } + public System.IO.Stream CreateReadStream() { throw null; } + } +} diff --git a/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.netstandard2.0.cs b/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.netstandard2.0.cs new file mode 100644 index 000000000000..033174efc01e --- /dev/null +++ b/src/FileProviders/Embedded/ref/Microsoft.Extensions.FileProviders.Embedded.netstandard2.0.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.FileProviders +{ + public partial class EmbeddedFileProvider : Microsoft.Extensions.FileProviders.IFileProvider + { + public EmbeddedFileProvider(System.Reflection.Assembly assembly) { } + public EmbeddedFileProvider(System.Reflection.Assembly assembly, string baseNamespace) { } + public Microsoft.Extensions.FileProviders.IDirectoryContents GetDirectoryContents(string subpath) { throw null; } + public Microsoft.Extensions.FileProviders.IFileInfo GetFileInfo(string subpath) { throw null; } + public Microsoft.Extensions.Primitives.IChangeToken Watch(string pattern) { throw null; } + } + public partial class ManifestEmbeddedFileProvider : Microsoft.Extensions.FileProviders.IFileProvider + { + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly) { } + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly, string root) { } + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly, string root, System.DateTimeOffset lastModified) { } + public ManifestEmbeddedFileProvider(System.Reflection.Assembly assembly, string root, string manifestName, System.DateTimeOffset lastModified) { } + public System.Reflection.Assembly Assembly { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.FileProviders.IDirectoryContents GetDirectoryContents(string subpath) { throw null; } + public Microsoft.Extensions.FileProviders.IFileInfo GetFileInfo(string subpath) { throw null; } + public Microsoft.Extensions.Primitives.IChangeToken Watch(string filter) { throw null; } + } +} +namespace Microsoft.Extensions.FileProviders.Embedded +{ + public partial class EmbeddedResourceFileInfo : Microsoft.Extensions.FileProviders.IFileInfo + { + public EmbeddedResourceFileInfo(System.Reflection.Assembly assembly, string resourcePath, string name, System.DateTimeOffset lastModified) { } + public bool Exists { get { throw null; } } + public bool IsDirectory { get { throw null; } } + public System.DateTimeOffset LastModified { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public long Length { get { throw null; } } + public string Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string PhysicalPath { get { throw null; } } + public System.IO.Stream CreateReadStream() { throw null; } + } +} diff --git a/src/FileProviders/Embedded/src/EmbeddedFileProvider.cs b/src/FileProviders/Embedded/src/EmbeddedFileProvider.cs new file mode 100644 index 000000000000..75f3f49e4937 --- /dev/null +++ b/src/FileProviders/Embedded/src/EmbeddedFileProvider.cs @@ -0,0 +1,181 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using Microsoft.Extensions.FileProviders.Embedded; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.FileProviders +{ + /// + /// Looks up files using embedded resources in the specified assembly. + /// This file provider is case sensitive. + /// + public class EmbeddedFileProvider : IFileProvider + { + private static readonly char[] _invalidFileNameChars = Path.GetInvalidFileNameChars() + .Where(c => c != '/' && c != '\\').ToArray(); + + private readonly Assembly _assembly; + private readonly string _baseNamespace; + private readonly DateTimeOffset _lastModified; + + /// + /// Initializes a new instance of the class using the specified + /// assembly with the base namespace defaulting to the assembly name. + /// + /// The assembly that contains the embedded resources. + public EmbeddedFileProvider(Assembly assembly) + : this(assembly, assembly?.GetName()?.Name) + { + } + + /// + /// Initializes a new instance of the class using the specified + /// assembly and base namespace. + /// + /// The assembly that contains the embedded resources. + /// The base namespace that contains the embedded resources. + public EmbeddedFileProvider(Assembly assembly, string baseNamespace) + { + if (assembly == null) + { + throw new ArgumentNullException("assembly"); + } + + _baseNamespace = string.IsNullOrEmpty(baseNamespace) ? string.Empty : baseNamespace + "."; + _assembly = assembly; + + _lastModified = DateTimeOffset.UtcNow; + + if (!string.IsNullOrEmpty(_assembly.Location)) + { + try + { + _lastModified = File.GetLastWriteTimeUtc(_assembly.Location); + } + catch (PathTooLongException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + /// + /// Locates a file at the given path. + /// + /// The path that identifies the file. + /// + /// The file information. Caller must check Exists property. A if the file could + /// not be found. + /// + public IFileInfo GetFileInfo(string subpath) + { + if (string.IsNullOrEmpty(subpath)) + { + return new NotFoundFileInfo(subpath); + } + + var builder = new StringBuilder(_baseNamespace.Length + subpath.Length); + builder.Append(_baseNamespace); + + // Relative paths starting with a leading slash okay + if (subpath.StartsWith("/", StringComparison.Ordinal)) + { + builder.Append(subpath, 1, subpath.Length - 1); + } + else + { + builder.Append(subpath); + } + + for (var i = _baseNamespace.Length; i < builder.Length; i++) + { + if (builder[i] == '/' || builder[i] == '\\') + { + builder[i] = '.'; + } + } + + var resourcePath = builder.ToString(); + if (HasInvalidPathChars(resourcePath)) + { + return new NotFoundFileInfo(resourcePath); + } + + var name = Path.GetFileName(subpath); + if (_assembly.GetManifestResourceInfo(resourcePath) == null) + { + return new NotFoundFileInfo(name); + } + + return new EmbeddedResourceFileInfo(_assembly, resourcePath, name, _lastModified); + } + + /// + /// Enumerate a directory at the given path, if any. + /// This file provider uses a flat directory structure. Everything under the base namespace is considered to be one + /// directory. + /// + /// The path that identifies the directory + /// + /// Contents of the directory. Caller must check Exists property. A if no + /// resources were found that match + /// + public IDirectoryContents GetDirectoryContents(string subpath) + { + // The file name is assumed to be the remainder of the resource name. + if (subpath == null) + { + return NotFoundDirectoryContents.Singleton; + } + + // EmbeddedFileProvider only supports a flat file structure at the base namespace. + if (subpath.Length != 0 && !string.Equals(subpath, "/", StringComparison.Ordinal)) + { + return NotFoundDirectoryContents.Singleton; + } + + var entries = new List(); + + // TODO: The list of resources in an assembly isn't going to change. Consider caching. + var resources = _assembly.GetManifestResourceNames(); + for (var i = 0; i < resources.Length; i++) + { + var resourceName = resources[i]; + if (resourceName.StartsWith(_baseNamespace, StringComparison.Ordinal)) + { + entries.Add(new EmbeddedResourceFileInfo( + _assembly, + resourceName, + resourceName.Substring(_baseNamespace.Length), + _lastModified)); + } + } + + return new EnumerableDirectoryContents(entries); + } + + /// + /// Embedded files do not change. + /// + /// This parameter is ignored + /// A + public IChangeToken Watch(string pattern) + { + return NullChangeToken.Singleton; + } + + private static bool HasInvalidPathChars(string path) + { + return path.IndexOfAny(_invalidFileNameChars) != -1; + } + } +} diff --git a/src/FileProviders/Embedded/src/EmbeddedResourceFileInfo.cs b/src/FileProviders/Embedded/src/EmbeddedResourceFileInfo.cs new file mode 100644 index 000000000000..5dca527342b5 --- /dev/null +++ b/src/FileProviders/Embedded/src/EmbeddedResourceFileInfo.cs @@ -0,0 +1,94 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; + +namespace Microsoft.Extensions.FileProviders.Embedded +{ + /// + /// Represents a file embedded in an assembly. + /// + public class EmbeddedResourceFileInfo : IFileInfo + { + private readonly Assembly _assembly; + private readonly string _resourcePath; + + private long? _length; + + /// + /// Initializes a new instance of for an assembly using as the base + /// + /// The assembly that contains the embedded resource + /// The path to the embedded resource + /// An arbitrary name for this instance + /// The to use for + public EmbeddedResourceFileInfo( + Assembly assembly, + string resourcePath, + string name, + DateTimeOffset lastModified) + { + _assembly = assembly; + _resourcePath = resourcePath; + Name = name; + LastModified = lastModified; + } + + /// + /// Always true. + /// + public bool Exists => true; + + /// + /// The length, in bytes, of the embedded resource + /// + public long Length + { + get + { + if (!_length.HasValue) + { + using (var stream = _assembly.GetManifestResourceStream(_resourcePath)) + { + _length = stream.Length; + } + } + return _length.Value; + } + } + + /// + /// Always null. + /// + public string PhysicalPath => null; + + /// + /// The name of embedded file + /// + public string Name { get; } + + /// + /// The time, in UTC, when the was created + /// + public DateTimeOffset LastModified { get; } + + /// + /// Always false. + /// + public bool IsDirectory => false; + + /// + public Stream CreateReadStream() + { + var stream = _assembly.GetManifestResourceStream(_resourcePath); + if (!_length.HasValue) + { + _length = stream.Length; + } + + return stream; + } + } +} diff --git a/src/FileProviders/Embedded/src/EnumerableDirectoryContents.cs b/src/FileProviders/Embedded/src/EnumerableDirectoryContents.cs new file mode 100644 index 000000000000..012723eba631 --- /dev/null +++ b/src/FileProviders/Embedded/src/EnumerableDirectoryContents.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Microsoft.Extensions.FileProviders.Embedded +{ + internal class EnumerableDirectoryContents : IDirectoryContents + { + private readonly IEnumerable _entries; + + public EnumerableDirectoryContents(IEnumerable entries) + { + if (entries == null) + { + throw new ArgumentNullException(nameof(entries)); + } + + _entries = entries; + } + + public bool Exists + { + get { return true; } + } + + public IEnumerator GetEnumerator() + { + return _entries.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _entries.GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/src/FileProviders/Embedded/src/Manifest/EmbeddedFilesManifest.cs b/src/FileProviders/Embedded/src/Manifest/EmbeddedFilesManifest.cs new file mode 100644 index 000000000000..f017b9b28961 --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/EmbeddedFilesManifest.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class EmbeddedFilesManifest + { + private static readonly char[] _invalidFileNameChars = Path.GetInvalidFileNameChars() + .Where(c => c != Path.DirectorySeparatorChar && c != Path.AltDirectorySeparatorChar).ToArray(); + + private static readonly char[] _separators = new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }; + + private readonly ManifestDirectory _rootDirectory; + + internal EmbeddedFilesManifest(ManifestDirectory rootDirectory) + { + if (rootDirectory == null) + { + throw new ArgumentNullException(nameof(rootDirectory)); + } + + _rootDirectory = rootDirectory; + } + + internal ManifestEntry ResolveEntry(string path) + { + if (string.IsNullOrEmpty(path) || HasInvalidPathChars(path)) + { + return null; + } + + // trimmed is a string without leading nor trailing path separators + // so if we find an empty string while iterating over the segments + // we know for sure the path is invalid and we treat it as the above + // case by returning null. + // Examples of invalid paths are: //wwwroot /\wwwroot //wwwroot//jquery.js + var trimmed = RemoveLeadingAndTrailingDirectorySeparators(path); + // Paths consisting only of a single path separator like / or \ are ok. + if (trimmed.Length == 0) + { + return _rootDirectory; + } + + var tokenizer = new StringTokenizer(trimmed, _separators); + ManifestEntry currentEntry = _rootDirectory; + foreach (var segment in tokenizer) + { + if (segment.Equals("")) + { + return null; + } + + currentEntry = currentEntry.Traverse(segment); + } + + return currentEntry; + } + + private static StringSegment RemoveLeadingAndTrailingDirectorySeparators(string path) + { + Debug.Assert(path.Length > 0); + var start = Array.IndexOf(_separators, path[0]) == -1 ? 0 : 1; + if (start == path.Length) + { + return StringSegment.Empty; + } + + var end = Array.IndexOf(_separators, path[path.Length - 1]) == -1 ? path.Length : path.Length - 1; + var trimmed = new StringSegment(path, start, end - start); + return trimmed; + } + + internal EmbeddedFilesManifest Scope(string path) + { + if (ResolveEntry(path) is ManifestDirectory directory && directory != ManifestEntry.UnknownPath) + { + return new EmbeddedFilesManifest(directory.ToRootDirectory()); + } + + throw new InvalidOperationException($"Invalid path: '{path}'"); + } + + private static bool HasInvalidPathChars(string path) => path.IndexOfAny(_invalidFileNameChars) != -1; + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestDirectory.cs b/src/FileProviders/Embedded/src/Manifest/ManifestDirectory.cs new file mode 100644 index 000000000000..b75653a0fb0f --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestDirectory.cs @@ -0,0 +1,127 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class ManifestDirectory : ManifestEntry + { + protected ManifestDirectory(string name, ManifestEntry[] children) + : base(name) + { + if (children == null) + { + throw new ArgumentNullException(nameof(children)); + } + + Children = children; + } + + public IReadOnlyList Children { get; protected set; } + + public override ManifestEntry Traverse(StringSegment segment) + { + if (segment.Equals(".", StringComparison.Ordinal)) + { + return this; + } + + if (segment.Equals("..", StringComparison.Ordinal)) + { + return Parent; + } + + foreach (var child in Children) + { + if (segment.Equals(child.Name, StringComparison.OrdinalIgnoreCase)) + { + return child; + } + } + + return UnknownPath; + } + + public virtual ManifestDirectory ToRootDirectory() => CreateRootDirectory(CopyChildren()); + + public static ManifestDirectory CreateDirectory(string name, ManifestEntry[] children) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException($"'{nameof(name)}' must not be null, empty or whitespace.", nameof(name)); + } + + if (children == null) + { + throw new ArgumentNullException(nameof(children)); + } + + var result = new ManifestDirectory(name, children); + ValidateChildrenAndSetParent(children, result); + + return result; + } + + public static ManifestRootDirectory CreateRootDirectory(ManifestEntry[] children) + { + if (children == null) + { + throw new ArgumentNullException(nameof(children)); + } + + var result = new ManifestRootDirectory(children); + ValidateChildrenAndSetParent(children, result); + + return result; + } + + internal static void ValidateChildrenAndSetParent(ManifestEntry[] children, ManifestDirectory parent) + { + foreach (var child in children) + { + if (child == UnknownPath) + { + throw new InvalidOperationException($"Invalid entry type '{nameof(ManifestSinkDirectory)}'"); + } + + if (child is ManifestRootDirectory) + { + throw new InvalidOperationException($"Can't add a root folder as a child"); + } + + child.SetParent(parent); + } + } + + private ManifestEntry[] CopyChildren() + { + var list = new List(); + for (int i = 0; i < Children.Count; i++) + { + var child = Children[i]; + switch (child) + { + case ManifestSinkDirectory s: + case ManifestRootDirectory r: + throw new InvalidOperationException("Unexpected manifest node."); + case ManifestDirectory d: + var grandChildren = d.CopyChildren(); + var newDirectory = CreateDirectory(d.Name, grandChildren); + list.Add(newDirectory); + break; + case ManifestFile f: + var file = new ManifestFile(f.Name, f.ResourcePath); + list.Add(file); + break; + default: + throw new InvalidOperationException("Unexpected manifest node."); + } + } + + return list.ToArray(); + } + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestDirectoryContents.cs b/src/FileProviders/Embedded/src/Manifest/ManifestDirectoryContents.cs new file mode 100644 index 000000000000..38903dd1967d --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestDirectoryContents.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class ManifestDirectoryContents : IDirectoryContents + { + private readonly DateTimeOffset _lastModified; + private IFileInfo[] _entries; + + public ManifestDirectoryContents(Assembly assembly, ManifestDirectory directory, DateTimeOffset lastModified) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (directory == null) + { + throw new ArgumentNullException(nameof(directory)); + } + + Assembly = assembly; + Directory = directory; + _lastModified = lastModified; + } + + public bool Exists => true; + + public Assembly Assembly { get; } + + public ManifestDirectory Directory { get; } + + public IEnumerator GetEnumerator() + { + return EnsureEntries().GetEnumerator(); + + IReadOnlyList EnsureEntries() => _entries = _entries ?? ResolveEntries().ToArray(); + + IEnumerable ResolveEntries() + { + if (Directory == ManifestEntry.UnknownPath) + { + yield break; + } + + foreach (var entry in Directory.Children) + { + switch (entry) + { + case ManifestFile f: + yield return new ManifestFileInfo(Assembly, f, _lastModified); + break; + case ManifestDirectory d: + yield return new ManifestDirectoryInfo(d, _lastModified); + break; + default: + throw new InvalidOperationException("Unknown entry type"); + } + } + } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestDirectoryInfo.cs b/src/FileProviders/Embedded/src/Manifest/ManifestDirectoryInfo.cs new file mode 100644 index 000000000000..bfe850445d12 --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestDirectoryInfo.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class ManifestDirectoryInfo : IFileInfo + { + public ManifestDirectoryInfo(ManifestDirectory directory, DateTimeOffset lastModified) + { + if (directory == null) + { + throw new ArgumentNullException(nameof(directory)); + } + + Directory = directory; + LastModified = lastModified; + } + + public bool Exists => true; + + public long Length => -1; + + public string PhysicalPath => null; + + public string Name => Directory.Name; + + public DateTimeOffset LastModified { get; } + + public bool IsDirectory => true; + + public ManifestDirectory Directory { get; } + + public Stream CreateReadStream() => + throw new InvalidOperationException("Cannot create a stream for a directory."); + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestEntry.cs b/src/FileProviders/Embedded/src/Manifest/ManifestEntry.cs new file mode 100644 index 000000000000..5c8ead2741a8 --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestEntry.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal abstract class ManifestEntry + { + public ManifestEntry(string name) + { + Name = name; + } + + public ManifestEntry Parent { get; private set; } + + public string Name { get; } + + public static ManifestEntry UnknownPath { get; } = ManifestSinkDirectory.Instance; + + protected internal virtual void SetParent(ManifestDirectory directory) + { + if (Parent != null) + { + throw new InvalidOperationException("Directory already has a parent."); + } + + Parent = directory; + } + + public abstract ManifestEntry Traverse(StringSegment segment); + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestFile.cs b/src/FileProviders/Embedded/src/Manifest/ManifestFile.cs new file mode 100644 index 000000000000..6dd89d34917a --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestFile.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class ManifestFile : ManifestEntry + { + public ManifestFile(string name, string resourcePath) + : base(name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException($"'{nameof(name)}' must not be null, empty or whitespace.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(resourcePath)) + { + throw new ArgumentException($"'{nameof(resourcePath)}' must not be null, empty or whitespace.", nameof(resourcePath)); + } + + ResourcePath = resourcePath; + } + + public string ResourcePath { get; } + + public override ManifestEntry Traverse(StringSegment segment) => UnknownPath; + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestFileInfo.cs b/src/FileProviders/Embedded/src/Manifest/ManifestFileInfo.cs new file mode 100644 index 000000000000..2329c16f85e5 --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestFileInfo.cs @@ -0,0 +1,71 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class ManifestFileInfo : IFileInfo + { + private long? _length; + + public ManifestFileInfo(Assembly assembly, ManifestFile file, DateTimeOffset lastModified) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (file == null) + { + throw new ArgumentNullException(nameof(file)); + } + + Assembly = assembly; + ManifestFile = file; + LastModified = lastModified; + } + + public Assembly Assembly { get; } + + public ManifestFile ManifestFile { get; } + + public bool Exists => true; + + public long Length => EnsureLength(); + + public string PhysicalPath => null; + + public string Name => ManifestFile.Name; + + public DateTimeOffset LastModified { get; } + + public bool IsDirectory => false; + + private long EnsureLength() + { + if (_length == null) + { + using (var stream = Assembly.GetManifestResourceStream(ManifestFile.ResourcePath)) + { + _length = stream.Length; + } + } + + return _length.Value; + } + + public Stream CreateReadStream() + { + var stream = Assembly.GetManifestResourceStream(ManifestFile.ResourcePath); + if (!_length.HasValue) + { + _length = stream.Length; + } + + return stream; + } + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestParser.cs b/src/FileProviders/Embedded/src/Manifest/ManifestParser.cs new file mode 100644 index 000000000000..a478b747cabb --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestParser.cs @@ -0,0 +1,159 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Xml; +using System.Xml.Linq; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal static class ManifestParser + { + private static readonly string DefaultManifestName = "Microsoft.Extensions.FileProviders.Embedded.Manifest.xml"; + + public static EmbeddedFilesManifest Parse(Assembly assembly) + { + return Parse(assembly, DefaultManifestName); + } + + public static EmbeddedFilesManifest Parse(Assembly assembly, string name) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + var stream = assembly.GetManifestResourceStream(name); + if (stream == null) + { + throw new InvalidOperationException($"Could not load the embedded file manifest " + + $"'{name}' for assembly '{assembly.GetName().Name}'."); + } + + var document = XDocument.Load(stream); + + var manifest = EnsureElement(document, "Manifest"); + var manifestVersion = EnsureElement(manifest, "ManifestVersion"); + var version = EnsureText(manifestVersion); + if (!string.Equals("1.0", version, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"The embedded file manifest '{name}' for " + + $"assembly '{assembly.GetName().Name}' specifies an unsupported file format" + + $" version: '{version}'."); + } + var fileSystem = EnsureElement(manifest, "FileSystem"); + + var entries = fileSystem.Elements(); + var entriesList = new List(); + foreach (var element in entries) + { + var entry = BuildEntry(element); + entriesList.Add(entry); + } + + ValidateEntries(entriesList); + + var rootDirectory = ManifestDirectory.CreateRootDirectory(entriesList.ToArray()); + + return new EmbeddedFilesManifest(rootDirectory); + + } + + private static void ValidateEntries(List entriesList) + { + for (int i = 0; i < entriesList.Count - 1; i++) + { + for (int j = i + 1; j < entriesList.Count; j++) + { + if (string.Equals(entriesList[i].Name, entriesList[j].Name, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Found two entries with the same name but different casing:" + + $" '{entriesList[i].Name}' and '{entriesList[j]}'"); + } + } + } + } + + private static ManifestEntry BuildEntry(XElement element) + { + RuntimeHelpers.EnsureSufficientExecutionStack(); + if (element.NodeType != XmlNodeType.Element) + { + throw new InvalidOperationException($"Invalid manifest format. Expected a 'File' or a 'Directory' node:" + + $" '{element.ToString()}'"); + } + + if (string.Equals(element.Name.LocalName, "File", StringComparison.Ordinal)) + { + var entryName = EnsureName(element); + var path = EnsureElement(element, "ResourcePath"); + var pathValue = EnsureText(path); + return new ManifestFile(entryName, pathValue); + } + + if (string.Equals(element.Name.LocalName, "Directory", StringComparison.Ordinal)) + { + var directoryName = EnsureName(element); + var children = new List(); + foreach (var child in element.Elements()) + { + children.Add(BuildEntry(child)); + } + + ValidateEntries(children); + + return ManifestDirectory.CreateDirectory(directoryName, children.ToArray()); + } + + throw new InvalidOperationException($"Invalid manifest format.Expected a 'File' or a 'Directory' node. " + + $"Got '{element.Name.LocalName}' instead."); + } + + private static XElement EnsureElement(XContainer container, string elementName) + { + var element = container.Element(elementName); + if (element == null) + { + throw new InvalidOperationException($"Invalid manifest format. Missing '{elementName}' element name"); + } + + return element; + } + + private static string EnsureName(XElement element) + { + var value = element.Attribute("Name")?.Value; + if (value == null) + { + throw new InvalidOperationException($"Invalid manifest format. '{element.Name}' must contain a 'Name' attribute."); + } + + return value; + } + + private static string EnsureText(XElement element) + { + if (element.Elements().Count() == 0 && + !element.IsEmpty && + element.Nodes().Count() == 1 && + element.FirstNode.NodeType == XmlNodeType.Text) + { + return element.Value; + } + + throw new InvalidOperationException( + $"Invalid manifest format. '{element.Name.LocalName}' must contain " + + $"a text value. '{element.Value}'"); + } + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestRootDirectory.cs b/src/FileProviders/Embedded/src/Manifest/ManifestRootDirectory.cs new file mode 100644 index 000000000000..1e5999e90623 --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestRootDirectory.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class ManifestRootDirectory : ManifestDirectory + { + public ManifestRootDirectory(ManifestEntry[] children) + : base(name: null, children: children) + { + SetParent(ManifestSinkDirectory.Instance); + } + + public override ManifestDirectory ToRootDirectory() => this; + } +} diff --git a/src/FileProviders/Embedded/src/Manifest/ManifestSinkDirectory.cs b/src/FileProviders/Embedded/src/Manifest/ManifestSinkDirectory.cs new file mode 100644 index 000000000000..f14908534f55 --- /dev/null +++ b/src/FileProviders/Embedded/src/Manifest/ManifestSinkDirectory.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + internal class ManifestSinkDirectory : ManifestDirectory + { + private ManifestSinkDirectory() + : base(name: null, children: Array.Empty()) + { + SetParent(this); + Children = new[] { this }; + } + + public static ManifestDirectory Instance { get; } = new ManifestSinkDirectory(); + + public override ManifestEntry Traverse(StringSegment segment) => this; + } +} diff --git a/src/FileProviders/Embedded/src/ManifestEmbeddedFileProvider.cs b/src/FileProviders/Embedded/src/ManifestEmbeddedFileProvider.cs new file mode 100644 index 000000000000..f639a2a812c1 --- /dev/null +++ b/src/FileProviders/Embedded/src/ManifestEmbeddedFileProvider.cs @@ -0,0 +1,153 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; +using Microsoft.Extensions.FileProviders.Embedded.Manifest; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.Extensions.FileProviders +{ + /// + /// An embedded file provider that uses a manifest compiled in the assembly to + /// reconstruct the original paths of the embedded files when they were embedded + /// into the assembly. + /// + public class ManifestEmbeddedFileProvider : IFileProvider + { + private readonly DateTimeOffset _lastModified; + + /// + /// Initializes a new instance of . + /// + /// The assembly containing the embedded files. + public ManifestEmbeddedFileProvider(Assembly assembly) + : this(assembly, ManifestParser.Parse(assembly), ResolveLastModified(assembly)) { } + + /// + /// Initializes a new instance of . + /// + /// The assembly containing the embedded files. + /// The relative path from the root of the manifest to use as root for the provider. + public ManifestEmbeddedFileProvider(Assembly assembly, string root) + : this(assembly, root, ResolveLastModified(assembly)) + { + } + + /// + /// Initializes a new instance of . + /// + /// The assembly containing the embedded files. + /// The relative path from the root of the manifest to use as root for the provider. + /// The LastModified date to use on the instances + /// returned by this . + public ManifestEmbeddedFileProvider(Assembly assembly, string root, DateTimeOffset lastModified) + : this(assembly, ManifestParser.Parse(assembly).Scope(root), lastModified) + { + } + + /// + /// Initializes a new instance of . + /// + /// The assembly containing the embedded files. + /// The relative path from the root of the manifest to use as root for the provider. + /// The name of the embedded resource containing the manifest. + /// The LastModified date to use on the instances + /// returned by this . + public ManifestEmbeddedFileProvider(Assembly assembly, string root, string manifestName, DateTimeOffset lastModified) + : this(assembly, ManifestParser.Parse(assembly, manifestName).Scope(root), lastModified) + { + } + + internal ManifestEmbeddedFileProvider(Assembly assembly, EmbeddedFilesManifest manifest, DateTimeOffset lastModified) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + if (manifest == null) + { + throw new ArgumentNullException(nameof(manifest)); + } + + Assembly = assembly; + Manifest = manifest; + _lastModified = lastModified; + } + + /// + /// Gets the for this provider. + /// + public Assembly Assembly { get; } + + internal EmbeddedFilesManifest Manifest { get; } + + /// + public IDirectoryContents GetDirectoryContents(string subpath) + { + var entry = Manifest.ResolveEntry(subpath); + if (entry == null || entry == ManifestEntry.UnknownPath) + { + return NotFoundDirectoryContents.Singleton; + } + + if (!(entry is ManifestDirectory directory)) + { + return NotFoundDirectoryContents.Singleton; + } + + return new ManifestDirectoryContents(Assembly, directory, _lastModified); + } + + /// + public IFileInfo GetFileInfo(string subpath) + { + var entry = Manifest.ResolveEntry(subpath); + switch (entry) + { + case null: + return new NotFoundFileInfo(subpath); + case ManifestFile f: + return new ManifestFileInfo(Assembly, f, _lastModified); + case ManifestDirectory d when d != ManifestEntry.UnknownPath: + return new NotFoundFileInfo(d.Name); + } + + return new NotFoundFileInfo(subpath); + } + + /// + public IChangeToken Watch(string filter) + { + if (filter == null) + { + throw new ArgumentNullException(nameof(filter)); + } + + return NullChangeToken.Singleton; + } + + private static DateTimeOffset ResolveLastModified(Assembly assembly) + { + var result = DateTimeOffset.UtcNow; + + if (!string.IsNullOrEmpty(assembly.Location)) + { + try + { + result = File.GetLastWriteTimeUtc(assembly.Location); + } + catch (PathTooLongException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + return result; + } + } +} diff --git a/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.csproj b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.csproj new file mode 100644 index 000000000000..3d5fe3cf54fa --- /dev/null +++ b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.csproj @@ -0,0 +1,34 @@ + + + + Microsoft.Extensions.FileProviders + File provider for files in embedded resources for Microsoft.Extensions.FileProviders. + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(MSBuildProjectName).multitarget.nuspec + $(DefaultNetCoreTargetFramework) + $(MSBuildProjectName).netcoreapp.nuspec + true + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.multitarget.nuspec b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.multitarget.nuspec new file mode 100644 index 000000000000..6acde4ec1d73 --- /dev/null +++ b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.multitarget.nuspec @@ -0,0 +1,25 @@ + + + + $CommonMetadataElements$ + + + + + + + + + + + + $CommonFileElements$ + + + + + + + + + diff --git a/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.netcoreapp.nuspec b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.netcoreapp.nuspec new file mode 100644 index 000000000000..217efb6cea98 --- /dev/null +++ b/src/FileProviders/Embedded/src/Microsoft.Extensions.FileProviders.Embedded.netcoreapp.nuspec @@ -0,0 +1,22 @@ + + + + $CommonMetadataElements$ + + + + + + + + + $CommonFileElements$ + + + + + + + + + diff --git a/src/FileProviders/Embedded/src/build/netstandard2.0/Microsoft.Extensions.FileProviders.Embedded.props b/src/FileProviders/Embedded/src/build/netstandard2.0/Microsoft.Extensions.FileProviders.Embedded.props new file mode 100644 index 000000000000..aabbabc92fe1 --- /dev/null +++ b/src/FileProviders/Embedded/src/build/netstandard2.0/Microsoft.Extensions.FileProviders.Embedded.props @@ -0,0 +1,15 @@ + + + false + Microsoft.Extensions.FileProviders.Embedded.Manifest.xml + + + + <_FileProviderTaskAssembly>$(MSBuildThisFileDirectory)..\..\tasks\netstandard2.0\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.dll + + + + + diff --git a/src/FileProviders/Embedded/src/build/netstandard2.0/Microsoft.Extensions.FileProviders.Embedded.targets b/src/FileProviders/Embedded/src/build/netstandard2.0/Microsoft.Extensions.FileProviders.Embedded.targets new file mode 100644 index 000000000000..83505d7fead7 --- /dev/null +++ b/src/FileProviders/Embedded/src/build/netstandard2.0/Microsoft.Extensions.FileProviders.Embedded.targets @@ -0,0 +1,69 @@ + + + _CalculateEmbeddedFilesManifestInputs;$(PrepareResourceNamesDependsOn) + + + + + + <_GeneratedManifestFile>$(IntermediateOutputPath)$(EmbeddedFilesManifestFileName) + + + + <_FilesForManifest Include="@(EmbeddedResource)" /> + <_FilesForManifest Remove="@(EmbeddedResource->WithMetadataValue('ExcludeFromManifest','true'))" /> + + + + + + + + + + + + <_GeneratedManifestInfoInputsCacheFile>$(IntermediateOutputPath)$(MSBuildProjectName).EmbeddedFilesManifest.cache + + + + + + + + + + + + + + + + + + <_FilesForManifest Remove="@(_FilesForManifest)" /> + <_FilesForManifest Include="@(EmbeddedResource)" /> + <_FilesForManifest Remove="@(EmbeddedResource->WithMetadataValue('ExcludeFromManifest','true'))" /> + + + + + diff --git a/src/FileProviders/Embedded/src/buildMultiTargeting/Microsoft.Extensions.FileProviders.Embedded.props b/src/FileProviders/Embedded/src/buildMultiTargeting/Microsoft.Extensions.FileProviders.Embedded.props new file mode 100644 index 000000000000..87296f28f36e --- /dev/null +++ b/src/FileProviders/Embedded/src/buildMultiTargeting/Microsoft.Extensions.FileProviders.Embedded.props @@ -0,0 +1,3 @@ + + + diff --git a/src/FileProviders/Embedded/src/buildMultiTargeting/Microsoft.Extensions.FileProviders.Embedded.targets b/src/FileProviders/Embedded/src/buildMultiTargeting/Microsoft.Extensions.FileProviders.Embedded.targets new file mode 100644 index 000000000000..9191097036d6 --- /dev/null +++ b/src/FileProviders/Embedded/src/buildMultiTargeting/Microsoft.Extensions.FileProviders.Embedded.targets @@ -0,0 +1,3 @@ + + + diff --git a/src/FileProviders/Embedded/test/EmbeddedFileProviderTests.cs b/src/FileProviders/Embedded/test/EmbeddedFileProviderTests.cs new file mode 100644 index 000000000000..cb9598a1b4f4 --- /dev/null +++ b/src/FileProviders/Embedded/test/EmbeddedFileProviderTests.cs @@ -0,0 +1,231 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.Extensions.FileProviders.Embedded.Tests +{ + public class EmbeddedFileProviderTests + { + private static readonly string Namespace = typeof(EmbeddedFileProviderTests).Namespace; + + [Fact] + public void ConstructorWithNullAssemblyThrowsArgumentException() + { + Assert.Throws(() => new EmbeddedFileProvider(null)); + } + + [Fact] + public void GetFileInfo_ReturnsNotFoundFileInfo_IfFileDoesNotExist() + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly); + + // Act + var fileInfo = provider.GetFileInfo("DoesNotExist.Txt"); + + // Assert + Assert.NotNull(fileInfo); + Assert.False(fileInfo.Exists); + } + + [Theory] + [InlineData("File.txt")] + [InlineData("/File.txt")] + public void GetFileInfo_ReturnsFilesAtRoot(string filePath) + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly); + var expectedFileLength = 8; + + // Act + var fileInfo = provider.GetFileInfo(filePath); + + // Assert + Assert.NotNull(fileInfo); + Assert.True(fileInfo.Exists); + Assert.NotEqual(default(DateTimeOffset), fileInfo.LastModified); + Assert.Equal(expectedFileLength, fileInfo.Length); + Assert.False(fileInfo.IsDirectory); + Assert.Null(fileInfo.PhysicalPath); + Assert.Equal("File.txt", fileInfo.Name); + } + + [Fact] + public void GetFileInfo_ReturnsNotFoundFileInfo_IfFileDoesNotExistUnderSpecifiedNamespace() + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly, Namespace + ".SubNamespace"); + + // Act + var fileInfo = provider.GetFileInfo("File.txt"); + + // Assert + Assert.NotNull(fileInfo); + Assert.False(fileInfo.Exists); + } + + [Fact] + public void GetFileInfo_ReturnsNotFoundIfPathStartsWithBackSlash() + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly); + + // Act + var fileInfo = provider.GetFileInfo("\\File.txt"); + + // Assert + Assert.NotNull(fileInfo); + Assert.False(fileInfo.Exists); + } + + public static TheoryData GetFileInfo_LocatesFilesUnderSpecifiedNamespaceData + { + get + { + var theoryData = new TheoryData + { + "ResourcesInSubdirectory/File3.txt" + }; + + if (TestPlatformHelper.IsWindows) + { + theoryData.Add("ResourcesInSubdirectory\\File3.txt"); + } + + return theoryData; + } + } + + [Theory] + [MemberData(nameof(GetFileInfo_LocatesFilesUnderSpecifiedNamespaceData))] + public void GetFileInfo_LocatesFilesUnderSpecifiedNamespace(string path) + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly, Namespace + ".Resources"); + + // Act + var fileInfo = provider.GetFileInfo(path); + + // Assert + Assert.NotNull(fileInfo); + Assert.True(fileInfo.Exists); + Assert.NotEqual(default(DateTimeOffset), fileInfo.LastModified); + Assert.True(fileInfo.Length > 0); + Assert.False(fileInfo.IsDirectory); + Assert.Null(fileInfo.PhysicalPath); + Assert.Equal("File3.txt", fileInfo.Name); + } + + public static TheoryData GetFileInfo_LocatesFilesUnderSubDirectoriesData + { + get + { + var theoryData = new TheoryData + { + "Resources/File.txt" + }; + + if (TestPlatformHelper.IsWindows) + { + theoryData.Add("Resources\\File.txt"); + } + + return theoryData; + } + } + + [Theory] + [MemberData(nameof(GetFileInfo_LocatesFilesUnderSubDirectoriesData))] + public void GetFileInfo_LocatesFilesUnderSubDirectories(string path) + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly); + + // Act + var fileInfo = provider.GetFileInfo(path); + + // Assert + Assert.NotNull(fileInfo); + Assert.True(fileInfo.Exists); + Assert.NotEqual(default(DateTimeOffset), fileInfo.LastModified); + Assert.True(fileInfo.Length > 0); + Assert.False(fileInfo.IsDirectory); + Assert.Null(fileInfo.PhysicalPath); + Assert.Equal("File.txt", fileInfo.Name); + } + + [Theory] + [InlineData("")] + [InlineData("/")] + public void GetDirectoryContents_ReturnsAllFilesInFileSystem(string path) + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly, Namespace + ".Resources"); + + // Act + var files = provider.GetDirectoryContents(path); + + // Assert + Assert.Collection(files.OrderBy(f => f.Name, StringComparer.Ordinal), + file => Assert.Equal("File.txt", file.Name), + file => Assert.Equal("ResourcesInSubdirectory.File3.txt", file.Name)); + + Assert.False(provider.GetDirectoryContents("file").Exists); + Assert.False(provider.GetDirectoryContents("file/").Exists); + Assert.False(provider.GetDirectoryContents("file.txt").Exists); + Assert.False(provider.GetDirectoryContents("file/txt").Exists); + } + + [Fact] + public void GetDirectoryContents_ReturnsEmptySequence_IfResourcesDoNotExistUnderNamespace() + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly, "Unknown.Namespace"); + + // Act + var files = provider.GetDirectoryContents(string.Empty); + + // Assert + Assert.NotNull(files); + Assert.True(files.Exists); + Assert.Empty(files); + } + + [Theory] + [InlineData("Resources")] + [InlineData("/Resources")] + public void GetDirectoryContents_ReturnsNotFoundDirectoryContents_IfHierarchicalPathIsSpecified(string path) + { + // Arrange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly); + + // Act + var files = provider.GetDirectoryContents(path); + + // Assert + Assert.NotNull(files); + Assert.False(files.Exists); + Assert.Empty(files); + } + + [Fact] + public void Watch_ReturnsNoOpTrigger() + { + // Arange + var provider = new EmbeddedFileProvider(GetType().GetTypeInfo().Assembly); + + // Act + var token = provider.Watch("Resources/File.txt"); + + // Assert + Assert.NotNull(token); + Assert.False(token.ActiveChangeCallbacks); + Assert.False(token.HasChanged); + } + } +} \ No newline at end of file diff --git a/src/FileProviders/Embedded/test/File.txt b/src/FileProviders/Embedded/test/File.txt new file mode 100644 index 000000000000..357323fbfa83 --- /dev/null +++ b/src/FileProviders/Embedded/test/File.txt @@ -0,0 +1 @@ +Hello \ No newline at end of file diff --git a/src/FileProviders/Embedded/test/FileInfoComparer.cs b/src/FileProviders/Embedded/test/FileInfoComparer.cs new file mode 100644 index 000000000000..1b4b69b4c1fb --- /dev/null +++ b/src/FileProviders/Embedded/test/FileInfoComparer.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.FileProviders +{ + internal class FileInfoComparer : IEqualityComparer + { + public static FileInfoComparer Instance { get; set; } = new FileInfoComparer(); + + public bool Equals(IFileInfo x, IFileInfo y) + { + if (x == null && y == null) + { + return true; + } + + if ((x == null && y != null) || (x != null && y == null)) + { + return false; + } + + return x.Exists == y.Exists && + x.IsDirectory == y.IsDirectory && + x.Length == y.Length && + string.Equals(x.Name, y.Name, StringComparison.Ordinal) && + string.Equals(x.PhysicalPath, y.PhysicalPath, StringComparison.Ordinal); + } + + public int GetHashCode(IFileInfo obj) => 0; + } +} diff --git a/src/FileProviders/Embedded/test/Manifest/EmbeddedFilesManifestTests.cs b/src/FileProviders/Embedded/test/Manifest/EmbeddedFilesManifestTests.cs new file mode 100644 index 000000000000..107491d34a7d --- /dev/null +++ b/src/FileProviders/Embedded/test/Manifest/EmbeddedFilesManifestTests.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + public class EmbeddedFilesManifestTests + { + [Theory] + [InlineData("/wwwroot//jquery.validate.js")] + [InlineData("//wwwroot/jquery.validate.js")] + public void ResolveEntry_IgnoresInvalidPaths(string path) + { + // Arrange + var manifest = new EmbeddedFilesManifest( + ManifestDirectory.CreateRootDirectory( + new[] + { + ManifestDirectory.CreateDirectory("wwwroot", + new[] + { + new ManifestFile("jquery.validate.js","wwwroot.jquery.validate.js") + }) + })); + // Act + var entry = manifest.ResolveEntry(path); + + // Assert + Assert.Null(entry); + } + + [Theory] + [InlineData("/")] + [InlineData("./")] + [InlineData("/wwwroot/jquery.validate.js")] + [InlineData("/wwwroot/")] + public void ResolveEntry_AllowsSingleDirectorySeparator(string path) + { + // Arrange + var manifest = new EmbeddedFilesManifest( + ManifestDirectory.CreateRootDirectory( + new[] + { + ManifestDirectory.CreateDirectory("wwwroot", + new[] + { + new ManifestFile("jquery.validate.js","wwwroot.jquery.validate.js") + }) + })); + // Act + var entry = manifest.ResolveEntry(path); + + // Assert + Assert.NotNull(entry); + } + } +} diff --git a/src/FileProviders/Embedded/test/Manifest/ManifestEntryTests.cs b/src/FileProviders/Embedded/test/Manifest/ManifestEntryTests.cs new file mode 100644 index 000000000000..dc1a7e1cdda2 --- /dev/null +++ b/src/FileProviders/Embedded/test/Manifest/ManifestEntryTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + public class ManifestEntryTests + { + [Fact] + public void TraversingAFile_ReturnsUnknownPath() + { + // Arrange + var file = new ManifestFile("a", "a.b.c"); + + // Act + var result = file.Traverse("."); + + // Assert + Assert.Equal(ManifestEntry.UnknownPath, result); + } + + [Fact] + public void TraversingANonExistingFile_ReturnsUnknownPath() + { + // Arrange + var directory = ManifestDirectory.CreateDirectory("a", Array.Empty()); + + // Act + var result = directory.Traverse("missing.txt"); + + // Assert + Assert.Equal(ManifestEntry.UnknownPath, result); + } + + [Fact] + public void TraversingWithDot_ReturnsSelf() + { + // Arrange + var directory = ManifestDirectory.CreateDirectory("a", Array.Empty()); + + // Act + var result = directory.Traverse("."); + + // Assert + Assert.Same(directory, result); + } + + [Fact] + public void TraversingWithDotDot_ReturnsParent() + { + // Arrange + var childDirectory = ManifestDirectory.CreateDirectory("b", Array.Empty()); + var directory = ManifestDirectory.CreateDirectory("a", new[] { childDirectory }); + + // Act + var result = childDirectory.Traverse(".."); + + // Assert + Assert.Equal(directory, result); + } + + [Fact] + public void TraversingRootDirectoryWithDotDot_ReturnsSinkDirectory() + { + // Arrange + var directory = ManifestDirectory.CreateRootDirectory(Array.Empty()); + + // Act + var result = directory.Traverse(".."); + + // Assert + Assert.Equal(ManifestEntry.UnknownPath, result); + } + + [Fact] + public void ScopingAFolderAndTryingToGetAScopedFile_ReturnsSinkDirectory() + { + // Arrange + var directory = ManifestDirectory.CreateRootDirectory(new[] { + ManifestDirectory.CreateDirectory("a", + new[] { new ManifestFile("test1.txt", "text.txt") }), + ManifestDirectory.CreateDirectory("b", + new[] { new ManifestFile("test2.txt", "test2.txt") }) }); + + var newRoot = ((ManifestDirectory)directory.Traverse("a")).ToRootDirectory(); + + // Act + var result = newRoot.Traverse("../b/test.txt"); + + // Assert + Assert.Same(ManifestEntry.UnknownPath, result); + } + + [Theory] + [InlineData("..")] + [InlineData(".")] + [InlineData("file.txt")] + [InlineData("folder")] + public void TraversingUnknownPath_ReturnsSinkDirectory(string path) + { + // Arrange + var directory = ManifestEntry.UnknownPath; + + // Act + var result = directory.Traverse(path); + + // Assert + Assert.Equal(ManifestEntry.UnknownPath, result); + } + } +} diff --git a/src/FileProviders/Embedded/test/Manifest/ManifestParserTests.cs b/src/FileProviders/Embedded/test/Manifest/ManifestParserTests.cs new file mode 100644 index 000000000000..e4edc8bde0e4 --- /dev/null +++ b/src/FileProviders/Embedded/test/Manifest/ManifestParserTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + public class ManifestParserTests + { + [Fact] + public void Parse_UsesDefaultManifestNameForManifest() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.File("sample.txt"))); + + // Act + var manifest = ManifestParser.Parse(assembly); + + // Assert + Assert.NotNull(manifest); + } + + [Fact] + public void Parse_FindsManifestWithCustomName() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.File("sample.txt")), + manifestName: "Manifest.xml"); + + // Act + var manifest = ManifestParser.Parse(assembly, "Manifest.xml"); + + // Assert + Assert.NotNull(manifest); + } + + [Fact] + public void Parse_ThrowsForEntriesWithDifferentCasing() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.File("sample.txt"), + TestEntry.File("SAMPLE.TXT"))); + + // Act & Assert + Assert.Throws(() => ManifestParser.Parse(assembly)); + } + + [Theory] + [MemberData(nameof(MalformedManifests))] + public void Parse_ThrowsForInvalidManifests(string invalidManifest) + { + // Arrange + var assembly = new TestAssembly(invalidManifest); + + // Act & Assert + Assert.Throws(() => ManifestParser.Parse(assembly)); + } + + public static TheoryData MalformedManifests => + new TheoryData + { + "", + "", + "", + "2.0", + "2.0", + @"1.0 +path", + + @"1.0 +", + + @"1.0 +sample.txt", + + @"1.0 +", + + @"1.0 +" + }; + + [Theory] + [MemberData(nameof(ManifestsWithAdditionalData))] + public void Parse_IgnoresAdditionalDataOnFileAndDirectoryNodes(string manifest) + { + // Arrange + var assembly = new TestAssembly(manifest); + + // Act + var result = ManifestParser.Parse(assembly); + + // Assert + Assert.NotNull(result); + } + + public static TheoryData ManifestsWithAdditionalData => + new TheoryData + { + @"1.0 +", + + @"1.0 + +path1234 +" + }; + } +} diff --git a/src/FileProviders/Embedded/test/Manifest/TestEntry.cs b/src/FileProviders/Embedded/test/Manifest/TestEntry.cs new file mode 100644 index 000000000000..aaaf881469f9 --- /dev/null +++ b/src/FileProviders/Embedded/test/Manifest/TestEntry.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest +{ + class TestEntry + { + public bool IsFile => ResourcePath != null; + public string Name { get; set; } + public TestEntry[] Children { get; set; } + public string ResourcePath { get; set; } + + public static TestEntry Directory(string name, params TestEntry[] entries) => + new TestEntry() { Name = name, Children = entries }; + + public static TestEntry File(string name, string path = null) => + new TestEntry() { Name = name, ResourcePath = path ?? name }; + + public XElement ToXElement() => IsFile ? + new XElement("File", new XAttribute("Name", Name), new XElement("ResourcePath", ResourcePath)) : + new XElement("Directory", new XAttribute("Name", Name), Children.Select(c => c.ToXElement())); + + public IEnumerable GetFiles() + { + if (IsFile) + { + return Enumerable.Empty(); + } + + var files = Children.Where(c => c.IsFile).ToArray(); + var otherFiles = Children.Where(c => !c.IsFile).SelectMany(d => d.GetFiles()).ToArray(); + + return files.Concat(otherFiles).ToArray(); + } + + } +} diff --git a/src/FileProviders/Embedded/test/ManifestEmbeddedFileProviderTests.cs b/src/FileProviders/Embedded/test/ManifestEmbeddedFileProviderTests.cs new file mode 100644 index 000000000000..a973c9df003f --- /dev/null +++ b/src/FileProviders/Embedded/test/ManifestEmbeddedFileProviderTests.cs @@ -0,0 +1,428 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using Microsoft.Extensions.FileProviders.Embedded.Manifest; +using Xunit; + +namespace Microsoft.Extensions.FileProviders +{ + public class ManifestEmbeddedFileProviderTests + { + [Fact] + public void GetFileInfo_CanResolveSimpleFiles() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css"))); + + // Act + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Assert + var jqueryValidate = provider.GetFileInfo("jquery.validate.js"); + Assert.True(jqueryValidate.Exists); + Assert.False(jqueryValidate.IsDirectory); + Assert.Equal("jquery.validate.js", jqueryValidate.Name); + Assert.Null(jqueryValidate.PhysicalPath); + Assert.Equal(0, jqueryValidate.Length); + + var jqueryMin = provider.GetFileInfo("jquery.min.js"); + Assert.True(jqueryMin.Exists); + Assert.False(jqueryMin.IsDirectory); + Assert.Equal("jquery.min.js", jqueryMin.Name); + Assert.Null(jqueryMin.PhysicalPath); + Assert.Equal(0, jqueryMin.Length); + + var siteCss = provider.GetFileInfo("site.css"); + Assert.True(siteCss.Exists); + Assert.False(siteCss.IsDirectory); + Assert.Equal("site.css", siteCss.Name); + Assert.Null(siteCss.PhysicalPath); + Assert.Equal(0, siteCss.Length); + } + + [Fact] + public void GetFileInfo_CanResolveFilesInsideAFolder() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + // Act + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Assert + var jqueryValidate = provider.GetFileInfo(Path.Combine("wwwroot", "jquery.validate.js")); + Assert.True(jqueryValidate.Exists); + Assert.False(jqueryValidate.IsDirectory); + Assert.Equal("jquery.validate.js", jqueryValidate.Name); + Assert.Null(jqueryValidate.PhysicalPath); + Assert.Equal(0, jqueryValidate.Length); + + var jqueryMin = provider.GetFileInfo(Path.Combine("wwwroot", "jquery.min.js")); + Assert.True(jqueryMin.Exists); + Assert.False(jqueryMin.IsDirectory); + Assert.Equal("jquery.min.js", jqueryMin.Name); + Assert.Null(jqueryMin.PhysicalPath); + Assert.Equal(0, jqueryMin.Length); + + var siteCss = provider.GetFileInfo(Path.Combine("wwwroot", "site.css")); + Assert.True(siteCss.Exists); + Assert.False(siteCss.IsDirectory); + Assert.Equal("site.css", siteCss.Name); + Assert.Null(siteCss.PhysicalPath); + Assert.Equal(0, siteCss.Length); + } + + [Fact] + public void GetFileInfo_ResolveNonExistingFile_ReturnsNotFoundFileInfo() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Act + var file = provider.GetFileInfo("some/non/existing/file.txt"); + + // Assert + Assert.IsType(file); + } + + [Fact] + public void GetFileInfo_ResolveNonExistingDirectory_ReturnsNotFoundFileInfo() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Act + var file = provider.GetFileInfo("some"); + + // Assert + Assert.IsType(file); + } + + [Fact] + public void GetFileInfo_ResolveExistingDirectory_ReturnsNotFoundFileInfo() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Act + var file = provider.GetFileInfo("wwwroot"); + + // Assert + Assert.IsType(file); + } + + [Theory] + [InlineData("WWWROOT", "JQUERY.VALIDATE.JS")] + [InlineData("WwWRoOT", "JQuERY.VALiDATE.js")] + public void GetFileInfo_ResolvesFiles_WithDifferentCasing(string folder, string file) + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + // Act + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Assert + var jqueryValidate = provider.GetFileInfo(Path.Combine(folder, file)); + Assert.True(jqueryValidate.Exists); + Assert.False(jqueryValidate.IsDirectory); + Assert.Equal("jquery.validate.js", jqueryValidate.Name); + Assert.Null(jqueryValidate.PhysicalPath); + Assert.Equal(0, jqueryValidate.Length); + } + + [Fact] + public void GetFileInfo_AllowsLeadingDots_OnThePath() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + // Act + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Assert + var jqueryValidate = provider.GetFileInfo(Path.Combine(".", "wwwroot", "jquery.validate.js")); + Assert.True(jqueryValidate.Exists); + Assert.False(jqueryValidate.IsDirectory); + Assert.Equal("jquery.validate.js", jqueryValidate.Name); + Assert.Null(jqueryValidate.PhysicalPath); + Assert.Equal(0, jqueryValidate.Length); + } + + [Fact] + public void GetFileInfo_EscapingFromTheRootFolder_ReturnsNotFound() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + // Act + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Assert + var jqueryValidate = provider.GetFileInfo(Path.Combine("..", "wwwroot", "jquery.validate.js")); + Assert.IsType(jqueryValidate); + } + + [Theory] + [InlineData("wwwroot/jquery?validate.js")] + [InlineData("wwwroot/jquery*validate.js")] + [InlineData("wwwroot/jquery:validate.js")] + [InlineData("wwwroot/jqueryvalidate.js")] + [InlineData("wwwroot/jquery\0validate.js")] + public void GetFileInfo_ReturnsNotFoundfileInfo_ForPathsWithInvalidCharacters(string path) + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + // Act + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Assert + var file = provider.GetFileInfo(path); + Assert.IsType(file); + Assert.Equal(path, file.Name); + } + + [Fact] + public void GetDirectoryContents_CanEnumerateExistingFolders() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + var expectedContents = new[] + { + CreateTestFileInfo("jquery.validate.js"), + CreateTestFileInfo("jquery.min.js"), + CreateTestFileInfo("site.css") + }; + + // Act + var contents = provider.GetDirectoryContents("wwwroot").ToArray(); + + // Assert + Assert.Equal(expectedContents, contents, FileInfoComparer.Instance); + } + + [Fact] + public void GetDirectoryContents_EnumeratesOnlyAGivenLevel() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + var expectedContents = new[] + { + CreateTestFileInfo("wwwroot", isDirectory: true) + }; + + // Act + var contents = provider.GetDirectoryContents(".").ToArray(); + + // Assert + Assert.Equal(expectedContents, contents, FileInfoComparer.Instance); + } + + [Fact] + public void GetDirectoryContents_EnumeratesFilesAndDirectoriesOnAGivenPath() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot"), + TestEntry.File("site.css"))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + var expectedContents = new[] + { + CreateTestFileInfo("wwwroot", isDirectory: true), + CreateTestFileInfo("site.css") + }; + + // Act + var contents = provider.GetDirectoryContents(".").ToArray(); + + // Assert + Assert.Equal(expectedContents, contents, FileInfoComparer.Instance); + } + + [Fact] + public void GetDirectoryContents_ReturnsNoEntries_ForNonExistingDirectories() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot"), + TestEntry.File("site.css"))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Act + var contents = provider.GetDirectoryContents("non-existing"); + + // Assert + Assert.IsType(contents); + } + + [Fact] + public void GetDirectoryContents_ReturnsNoEntries_ForFilePaths() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot"), + TestEntry.File("site.css"))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Act + var contents = provider.GetDirectoryContents("site.css"); + + // Assert + Assert.IsType(contents); + } + + [Theory] + [InlineData("wwwro*t")] + [InlineData("wwwro?t")] + [InlineData("wwwro:t")] + [InlineData("wwwrot")] + [InlineData("wwwro\0t")] + public void GetDirectoryContents_ReturnsNotFoundDirectoryContents_ForPathsWithInvalidCharacters(string path) + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js"), + TestEntry.File("jquery.min.js"), + TestEntry.File("site.css")))); + + // Act + var provider = new ManifestEmbeddedFileProvider(assembly); + + // Assert + var directory = provider.GetDirectoryContents(path); + Assert.IsType(directory); + } + + [Fact] + public void Contructor_CanScopeManifestToAFolder() + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js")), + TestEntry.File("site.css"))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + var scopedProvider = new ManifestEmbeddedFileProvider(assembly, provider.Manifest.Scope("wwwroot"), DateTimeOffset.UtcNow); + + // Act + var jqueryValidate = scopedProvider.GetFileInfo("jquery.validate.js"); + + // Assert + Assert.True(jqueryValidate.Exists); + Assert.False(jqueryValidate.IsDirectory); + Assert.Equal("jquery.validate.js", jqueryValidate.Name); + Assert.Null(jqueryValidate.PhysicalPath); + Assert.Equal(0, jqueryValidate.Length); + } + + [Theory] + [InlineData("wwwroot/jquery.validate.js")] + [InlineData("../wwwroot/jquery.validate.js")] + [InlineData("site.css")] + [InlineData("../site.css")] + public void ScopedFileProvider_DoesNotReturnFilesOutOfScope(string path) + { + // Arrange + var assembly = new TestAssembly( + TestEntry.Directory("unused", + TestEntry.Directory("wwwroot", + TestEntry.File("jquery.validate.js")), + TestEntry.File("site.css"))); + + var provider = new ManifestEmbeddedFileProvider(assembly); + var scopedProvider = new ManifestEmbeddedFileProvider(assembly, provider.Manifest.Scope("wwwroot"), DateTimeOffset.UtcNow); + + // Act + var jqueryValidate = scopedProvider.GetFileInfo(path); + + // Assert + Assert.IsType(jqueryValidate); + } + + private IFileInfo CreateTestFileInfo(string name, bool isDirectory = false) => + new TestFileInfo(name, isDirectory); + } +} diff --git a/src/FileProviders/Embedded/test/Microsoft.Extensions.FileProviders.Embedded.Tests.csproj b/src/FileProviders/Embedded/test/Microsoft.Extensions.FileProviders.Embedded.Tests.csproj new file mode 100644 index 000000000000..a199e4383757 --- /dev/null +++ b/src/FileProviders/Embedded/test/Microsoft.Extensions.FileProviders.Embedded.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + + + + + + + + + + + diff --git a/src/FileProviders/Embedded/test/Resources/File.txt b/src/FileProviders/Embedded/test/Resources/File.txt new file mode 100644 index 000000000000..d498f3700625 --- /dev/null +++ b/src/FileProviders/Embedded/test/Resources/File.txt @@ -0,0 +1 @@ +Resources-Hello \ No newline at end of file diff --git a/src/FileProviders/Embedded/test/Resources/ResourcesInSubdirectory/File3.txt b/src/FileProviders/Embedded/test/Resources/ResourcesInSubdirectory/File3.txt new file mode 100644 index 000000000000..8651decea694 --- /dev/null +++ b/src/FileProviders/Embedded/test/Resources/ResourcesInSubdirectory/File3.txt @@ -0,0 +1 @@ +Hello3 diff --git a/src/FileProviders/Embedded/test/TestAssembly.cs b/src/FileProviders/Embedded/test/TestAssembly.cs new file mode 100644 index 000000000000..4917bd60ed0a --- /dev/null +++ b/src/FileProviders/Embedded/test/TestAssembly.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Extensions.FileProviders.Embedded.Manifest; + +namespace Microsoft.Extensions.FileProviders +{ + internal class TestAssembly : Assembly + { + public TestAssembly(string manifest, string manifestName = "Microsoft.Extensions.FileProviders.Embedded.Manifest.xml") + { + ManifestStream = new MemoryStream(); + using (var writer = new StreamWriter(ManifestStream, Encoding.UTF8, 1024, leaveOpen: true)) + { + writer.Write(manifest); + } + + ManifestStream.Seek(0, SeekOrigin.Begin); + ManifestName = manifestName; + } + + public TestAssembly(TestEntry entry, string manifestName = "Microsoft.Extensions.FileProviders.Embedded.Manifest.xml") + { + ManifestName = manifestName; + + var manifest = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement("Manifest", + new XElement("ManifestVersion", "1.0"), + new XElement("FileSystem", entry.Children.Select(c => c.ToXElement())))); + + ManifestStream = new MemoryStream(); + using (var writer = XmlWriter.Create(ManifestStream, new XmlWriterSettings { CloseOutput = false })) + { + manifest.WriteTo(writer); + } + + ManifestStream.Seek(0, SeekOrigin.Begin); + Files = entry.GetFiles().Select(f => f.ResourcePath).ToArray(); + } + + public string ManifestName { get; } + public MemoryStream ManifestStream { get; private set; } + public string[] Files { get; private set; } + + public override Stream GetManifestResourceStream(string name) + { + if (string.Equals(ManifestName, name)) + { + return ManifestStream; + } + + return Files.Contains(name) ? Stream.Null : null; + } + + public override string Location => null; + + public override AssemblyName GetName() + { + return new AssemblyName("TestAssembly"); + } + } +} diff --git a/src/FileProviders/Embedded/test/TestFileInfo.cs b/src/FileProviders/Embedded/test/TestFileInfo.cs new file mode 100644 index 000000000000..d410a3b5e782 --- /dev/null +++ b/src/FileProviders/Embedded/test/TestFileInfo.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.Extensions.FileProviders +{ + internal class TestFileInfo : IFileInfo + { + private readonly string _name; + private readonly bool _isDirectory; + + public TestFileInfo(string name, bool isDirectory) + { + _name = name; + _isDirectory = isDirectory; + } + + public bool Exists => true; + + public long Length => _isDirectory ? -1 : 0; + + public string PhysicalPath => null; + + public string Name => _name; + + public DateTimeOffset LastModified => throw new NotImplementedException(); + + public bool IsDirectory => _isDirectory; + + public Stream CreateReadStream() => Stream.Null; + } +} diff --git a/src/FileProviders/Embedded/test/sub/File2.txt b/src/FileProviders/Embedded/test/sub/File2.txt new file mode 100644 index 000000000000..e8ecfad88419 --- /dev/null +++ b/src/FileProviders/Embedded/test/sub/File2.txt @@ -0,0 +1 @@ +Hello2 diff --git a/src/FileProviders/Embedded/test/sub/dir/File3.txt b/src/FileProviders/Embedded/test/sub/dir/File3.txt new file mode 100644 index 000000000000..49cc8ef0e116 Binary files /dev/null and b/src/FileProviders/Embedded/test/sub/dir/File3.txt differ diff --git a/src/FileProviders/Manifest.MSBuildTask/src/EmbeddedItem.cs b/src/FileProviders/Manifest.MSBuildTask/src/EmbeddedItem.cs new file mode 100644 index 000000000000..c2dbd58ed222 --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/src/EmbeddedItem.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest.Task +{ + public class EmbeddedItem : IEquatable + { + public string ManifestFilePath { get; set; } + + public string AssemblyResourceName { get; set; } + + public bool Equals(EmbeddedItem other) => + string.Equals(ManifestFilePath, other?.ManifestFilePath, StringComparison.Ordinal) && + string.Equals(AssemblyResourceName, other?.AssemblyResourceName, StringComparison.Ordinal); + + public override bool Equals(object obj) => Equals(obj as EmbeddedItem); + public override int GetHashCode() => ManifestFilePath.GetHashCode() ^ AssemblyResourceName.GetHashCode(); + } +} diff --git a/src/FileProviders/Manifest.MSBuildTask/src/Entry.cs b/src/FileProviders/Manifest.MSBuildTask/src/Entry.cs new file mode 100644 index 000000000000..1a7f18bef81e --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/src/Entry.cs @@ -0,0 +1,121 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [DebuggerDisplay("{Name,nq}")] + public class Entry : IEquatable + { + public bool IsFile { get; private set; } + + public string Name { get; private set; } + + public string AssemblyResourceName { get; private set; } + + public ISet Children { get; } = new SortedSet(NameComparer.Instance); + + public static Entry Directory(string name) => + new Entry { Name = name }; + + public static Entry File(string name, string assemblyResourceName) => + new Entry { Name = name, AssemblyResourceName = assemblyResourceName, IsFile = true }; + + internal void AddChild(Entry child) + { + if (IsFile) + { + throw new InvalidOperationException("Tried to add children to a file."); + } + + if (Children.Contains(child)) + { + throw new InvalidOperationException($"An item with the name '{child.Name}' already exists."); + } + + Children.Add(child); + } + + internal Entry GetDirectory(string currentSegment) + { + if (IsFile) + { + throw new InvalidOperationException("Tried to get a directory from a file."); + } + + foreach (var child in Children) + { + if (child.HasName(currentSegment)) + { + if (child.IsFile) + { + throw new InvalidOperationException("Tried to find a directory but found a file instead"); + } + else + { + return child; + } + } + } + + return null; + } + + public bool Equals(Entry other) + { + if (other == null || !other.HasName(Name) || other.IsFile != IsFile) + { + return false; + } + + if (IsFile) + { + return string.Equals(other.AssemblyResourceName, AssemblyResourceName, StringComparison.Ordinal); + } + else + { + return SameChildren(Children, other.Children); + } + } + + private bool HasName(string currentSegment) + { + return string.Equals(Name, currentSegment, StringComparison.Ordinal); + } + + private bool SameChildren(ISet left, ISet right) + { + if (left.Count != right.Count) + { + return false; + } + + var le = left.GetEnumerator(); + var re = right.GetEnumerator(); + while (le.MoveNext() && re.MoveNext()) + { + if (!le.Current.Equals(re.Current)) + { + return false; + } + } + + return true; + } + + private class NameComparer : IComparer + { + public static NameComparer Instance { get; } = new NameComparer(); + + public int Compare(Entry x, Entry y) => + string.Compare(x?.Name, y?.Name, StringComparison.Ordinal); + } + } +} diff --git a/src/FileProviders/Manifest.MSBuildTask/src/GenerateEmbeddedResourcesManifest.cs b/src/FileProviders/Manifest.MSBuildTask/src/GenerateEmbeddedResourcesManifest.cs new file mode 100644 index 000000000000..3a62d3d5e341 --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/src/GenerateEmbeddedResourcesManifest.cs @@ -0,0 +1,104 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using Microsoft.Build.Framework; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest.Task +{ + /// + /// Task for generating a manifest file out of the embedded resources in an + /// assembly. + /// + public class GenerateEmbeddedResourcesManifest : Microsoft.Build.Utilities.Task + { + private const string LogicalName = "LogicalName"; + private const string ManifestResourceName = "ManifestResourceName"; + private const string TargetPath = "TargetPath"; + + [Required] + public ITaskItem[] EmbeddedFiles { get; set; } + + [Required] + public string ManifestFile { get; set; } + + /// + public override bool Execute() + { + var processedItems = CreateEmbeddedItems(EmbeddedFiles); + + var manifest = BuildManifest(processedItems); + + var document = manifest.ToXmlDocument(); + + var settings = new XmlWriterSettings() + { + Encoding = Encoding.UTF8, + CloseOutput = true + }; + + using (var xmlWriter = GetXmlWriter(settings)) + { + document.WriteTo(xmlWriter); + } + + return true; + } + + protected virtual XmlWriter GetXmlWriter(XmlWriterSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + var fileStream = new FileStream(ManifestFile, FileMode.Create); + return XmlWriter.Create(fileStream, settings); + } + + public EmbeddedItem[] CreateEmbeddedItems(params ITaskItem[] items) + { + if (items == null) + { + throw new ArgumentNullException(nameof(items)); + } + + return items.Select(er => new EmbeddedItem + { + ManifestFilePath = GetManifestPath(er), + AssemblyResourceName = GetAssemblyResourceName(er) + }).ToArray(); + } + + public Manifest BuildManifest(EmbeddedItem[] processedItems) + { + if (processedItems == null) + { + throw new ArgumentNullException(nameof(processedItems)); + } + + var manifest = new Manifest(); + foreach (var item in processedItems) + { + manifest.AddElement(item.ManifestFilePath, item.AssemblyResourceName); + } + + return manifest; + } + + private string GetManifestPath(ITaskItem taskItem) => string.Equals(taskItem.GetMetadata(LogicalName), taskItem.GetMetadata(ManifestResourceName)) ? + taskItem.GetMetadata(TargetPath) : + NormalizePath(taskItem.GetMetadata(LogicalName)); + + private string GetAssemblyResourceName(ITaskItem taskItem) => string.Equals(taskItem.GetMetadata(LogicalName), taskItem.GetMetadata(ManifestResourceName)) ? + taskItem.GetMetadata(ManifestResourceName) : + taskItem.GetMetadata(LogicalName); + + private string NormalizePath(string path) => Path.DirectorySeparatorChar == '\\' ? + path.Replace("/", "\\") : path.Replace("\\", "/"); + } +} diff --git a/src/FileProviders/Manifest.MSBuildTask/src/Manifest.cs b/src/FileProviders/Manifest.MSBuildTask/src/Manifest.cs new file mode 100644 index 000000000000..86e99477ff47 --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/src/Manifest.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.Internal; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest.Task +{ + public class Manifest + { + public Entry Root { get; set; } = Entry.Directory(""); + + public void AddElement(string originalPath, string assemblyResourceName) + { + if (originalPath == null) + { + throw new System.ArgumentNullException(nameof(originalPath)); + } + + if (assemblyResourceName == null) + { + throw new System.ArgumentNullException(nameof(assemblyResourceName)); + } + + var paths = originalPath.Split(Path.DirectorySeparatorChar); + var current = Root; + for (int i = 0; i < paths.Length - 1; i++) + { + var currentSegment = paths[i]; + var next = current.GetDirectory(currentSegment); + if (next == null) + { + next = Entry.Directory(currentSegment); + current.AddChild(next); + } + current = next; + } + + current.AddChild(Entry.File(paths[paths.Length - 1], assemblyResourceName)); + } + + public XDocument ToXmlDocument() + { + var document = new XDocument(new XDeclaration("1.0", "utf-8", "yes")); + var root = new XElement(ElementNames.Root, + new XElement(ElementNames.ManifestVersion, "1.0"), + new XElement(ElementNames.FileSystem, + Root.Children.Select(e => BuildNode(e)))); + + document.Add(root); + + return document; + } + + private XElement BuildNode(Entry entry) + { + if (entry.IsFile) + { + return new XElement(ElementNames.File, + new XAttribute(ElementNames.Name, entry.Name), + new XElement(ElementNames.ResourcePath, entry.AssemblyResourceName)); + } + else + { + var directory = new XElement(ElementNames.Directory, new XAttribute(ElementNames.Name, entry.Name)); + directory.Add(entry.Children.Select(c => BuildNode(c))); + return directory; + } + } + + private class ElementNames + { + public static readonly string Directory = "Directory"; + public static readonly string Name = "Name"; + public static readonly string FileSystem = "FileSystem"; + public static readonly string Root = "Manifest"; + public static readonly string File = "File"; + public static readonly string ResourcePath = "ResourcePath"; + public static readonly string ManifestVersion = "ManifestVersion"; + } + } +} diff --git a/src/FileProviders/Manifest.MSBuildTask/src/Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj b/src/FileProviders/Manifest.MSBuildTask/src/Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj new file mode 100644 index 000000000000..cdc4ffdcb0b4 --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/src/Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.csproj @@ -0,0 +1,18 @@ + + + + MSBuild task to generate a manifest that can be used by Microsoft.Extensions.FileProviders.Embedded to preserve + metadata of the files embedded in the assembly at compilation time. + netstandard2.0 + false + true + false + false + + + + + + + + diff --git a/src/FileProviders/Manifest.MSBuildTask/test/GenerateEmbeddedResourcesManifestTest.cs b/src/FileProviders/Manifest.MSBuildTask/test/GenerateEmbeddedResourcesManifestTest.cs new file mode 100644 index 000000000000..c7285913af11 --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/test/GenerateEmbeddedResourcesManifestTest.cs @@ -0,0 +1,388 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.Internal; +using Xunit; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest.Task +{ + public class GenerateEmbeddedResourcesManifestTest + { + [Fact] + public void CreateEmbeddedItems_MapsMetadataFromEmbeddedResources_UsesTheTargetPath() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata(@"lib\js\jquery.validate.js")); + + var expectedItems = new[] + { + CreateEmbeddedItem(@"lib\js\jquery.validate.js","lib.js.jquery.validate.js") + }; + + // Act + var embeddedItems = task.CreateEmbeddedItems(embeddedFiles); + + // Assert + Assert.Equal(expectedItems, embeddedItems); + } + + [Fact] + public void CreateEmbeddedItems_MapsMetadataFromEmbeddedResources_WithLogicalName() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var DirectorySeparator = (Path.DirectorySeparatorChar == '\\' ? '/' : '\\'); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata("site.css", null, "site.css"), + CreateMetadata("lib/jquery.validate.js", null, $"dist{DirectorySeparator}jquery.validate.js")); + + var expectedItems = new[] + { + CreateEmbeddedItem("site.css","site.css"), + CreateEmbeddedItem(Path.Combine("dist","jquery.validate.js"),$"dist{DirectorySeparator}jquery.validate.js") + }; + + // Act + var embeddedItems = task.CreateEmbeddedItems(embeddedFiles); + + // Assert + Assert.Equal(expectedItems, embeddedItems); + } + + [Fact] + public void BuildManifest_CanCreatesManifest_ForTopLevelFiles() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata("jquery.validate.js"), + CreateMetadata("jquery.min.js"), + CreateMetadata("Site.css")); + + var manifestFiles = task.CreateEmbeddedItems(embeddedFiles); + + var expectedManifest = new Manifest() + { + Root = Entry.Directory("").AddRange( + Entry.File("jquery.validate.js", "jquery.validate.js"), + Entry.File("jquery.min.js", "jquery.min.js"), + Entry.File("Site.css", "Site.css")) + }; + + // Act + var manifest = task.BuildManifest(manifestFiles); + + // Assert + Assert.Equal(expectedManifest, manifest, ManifestComparer.Instance); + } + + [Fact] + public void BuildManifest_CanCreatesManifest_ForFilesWithinAFolder() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata(Path.Combine("wwwroot", "js", "jquery.validate.js")), + CreateMetadata(Path.Combine("wwwroot", "js", "jquery.min.js")), + CreateMetadata(Path.Combine("wwwroot", "css", "Site.css")), + CreateMetadata(Path.Combine("Areas", "Identity", "Views", "Account", "Index.cshtml"))); + + var manifestFiles = task.CreateEmbeddedItems(embeddedFiles); + + var expectedManifest = new Manifest() + { + Root = Entry.Directory("").AddRange( + Entry.Directory("wwwroot").AddRange( + Entry.Directory("js").AddRange( + Entry.File("jquery.validate.js", "wwwroot.js.jquery.validate.js"), + Entry.File("jquery.min.js", "wwwroot.js.jquery.min.js")), + Entry.Directory("css").AddRange( + Entry.File("Site.css", "wwwroot.css.Site.css"))), + Entry.Directory("Areas").AddRange( + Entry.Directory("Identity").AddRange( + Entry.Directory("Views").AddRange( + Entry.Directory("Account").AddRange( + Entry.File("Index.cshtml", "Areas.Identity.Views.Account.Index.cshtml")))))) + }; + + // Act + var manifest = task.BuildManifest(manifestFiles); + + // Assert + Assert.Equal(expectedManifest, manifest, ManifestComparer.Instance); + } + + [Fact] + public void BuildManifest_RespectsEntriesWithLogicalName() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata("jquery.validate.js", null, @"wwwroot\lib\js\jquery.validate.js"), + CreateMetadata("jquery.min.js", null, @"wwwroot\lib/js\jquery.min.js"), + CreateMetadata("Site.css", null, "wwwroot/lib/css/site.css")); + var manifestFiles = task.CreateEmbeddedItems(embeddedFiles); + + var expectedManifest = new Manifest() + { + Root = Entry.Directory("").AddRange( + Entry.Directory("wwwroot").AddRange( + Entry.Directory("lib").AddRange( + Entry.Directory("js").AddRange( + Entry.File("jquery.validate.js", @"wwwroot\lib\js\jquery.validate.js"), + Entry.File("jquery.min.js", @"wwwroot\lib/js\jquery.min.js")), + Entry.Directory("css").AddRange( + Entry.File("site.css", "wwwroot/lib/css/site.css"))))) + }; + + // Act + var manifest = task.BuildManifest(manifestFiles); + + // Assert + Assert.Equal(expectedManifest, manifest, ManifestComparer.Instance); + } + + [Fact] + public void BuildManifest_SupportsFilesAndFoldersWithDifferentCasing() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata(Path.Combine("A", "b", "c.txt")), + CreateMetadata(Path.Combine("A", "B", "c.txt")), + CreateMetadata(Path.Combine("A", "B", "C.txt")), + CreateMetadata(Path.Combine("A", "b", "C.txt")), + CreateMetadata(Path.Combine("A", "d")), + CreateMetadata(Path.Combine("A", "D", "e.txt"))); + + var manifestFiles = task.CreateEmbeddedItems(embeddedFiles); + + var expectedManifest = new Manifest() + { + Root = Entry.Directory("").AddRange( + Entry.Directory("A").AddRange( + Entry.Directory("b").AddRange( + Entry.File("c.txt", @"A.b.c.txt"), + Entry.File("C.txt", @"A.b.C.txt")), + Entry.Directory("B").AddRange( + Entry.File("c.txt", @"A.B.c.txt"), + Entry.File("C.txt", @"A.B.C.txt")), + Entry.Directory("D").AddRange( + Entry.File("e.txt", "A.D.e.txt")), + Entry.File("d", "A.d"))) + }; + + // Act + var manifest = task.BuildManifest(manifestFiles); + + // Assert + Assert.Equal(expectedManifest, manifest, ManifestComparer.Instance); + } + + [Fact] + public void BuildManifest_ThrowsInvalidOperationException_WhenTryingToAddAFileWithTheSameNameAsAFolder() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata(Path.Combine("A", "b", "c.txt")), + CreateMetadata(Path.Combine("A", "b"))); + + var manifestFiles = task.CreateEmbeddedItems(embeddedFiles); + + // Act & Assert + Assert.Throws(() => task.BuildManifest(manifestFiles)); + } + + [Fact] + public void BuildManifest_ThrowsInvalidOperationException_WhenTryingToAddAFolderWithTheSameNameAsAFile() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata(Path.Combine("A", "b")), + CreateMetadata(Path.Combine("A", "b", "c.txt"))); + + var manifestFiles = task.CreateEmbeddedItems(embeddedFiles); + + // Act & Assert + Assert.Throws(() => task.BuildManifest(manifestFiles)); + } + + [Fact] + public void ToXmlDocument_GeneratesTheCorrectXmlDocument() + { + // Arrange + var manifest = new Manifest() + { + Root = Entry.Directory("").AddRange( + Entry.Directory("A").AddRange( + Entry.Directory("b").AddRange( + Entry.File("c.txt", @"A.b.c.txt"), + Entry.File("C.txt", @"A.b.C.txt")), + Entry.Directory("B").AddRange( + Entry.File("c.txt", @"A.B.c.txt"), + Entry.File("C.txt", @"A.B.C.txt")), + Entry.Directory("D").AddRange( + Entry.File("e.txt", "A.D.e.txt")), + Entry.File("d", "A.d"))) + }; + + var expectedDocument = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement("Manifest", + new XElement("ManifestVersion", "1.0"), + new XElement("FileSystem", + new XElement("Directory", new XAttribute("Name", "A"), + new XElement("Directory", new XAttribute("Name", "B"), + new XElement("File", new XAttribute("Name", "C.txt"), new XElement("ResourcePath", "A.B.C.txt")), + new XElement("File", new XAttribute("Name", "c.txt"), new XElement("ResourcePath", "A.B.c.txt"))), + new XElement("Directory", new XAttribute("Name", "D"), + new XElement("File", new XAttribute("Name", "e.txt"), new XElement("ResourcePath", "A.D.e.txt"))), + new XElement("Directory", new XAttribute("Name", "b"), + new XElement("File", new XAttribute("Name", "C.txt"), new XElement("ResourcePath", "A.b.C.txt")), + new XElement("File", new XAttribute("Name", "c.txt"), new XElement("ResourcePath", "A.b.c.txt"))), + new XElement("File", new XAttribute("Name", "d"), new XElement("ResourcePath", "A.d")))))); + + // Act + var document = manifest.ToXmlDocument(); + + // Assert + Assert.Equal(expectedDocument.ToString(), document.ToString()); + } + + [Fact] + public void Execute_WritesManifest_ToOutputFile() + { + // Arrange + var task = new TestGenerateEmbeddedResourcesManifest(); + var embeddedFiles = CreateEmbeddedResource( + CreateMetadata(Path.Combine("A", "b", "c.txt")), + CreateMetadata(Path.Combine("A", "B", "c.txt")), + CreateMetadata(Path.Combine("A", "B", "C.txt")), + CreateMetadata(Path.Combine("A", "b", "C.txt")), + CreateMetadata(Path.Combine("A", "d")), + CreateMetadata(Path.Combine("A", "D", "e.txt"))); + + task.EmbeddedFiles = embeddedFiles; + task.ManifestFile = Path.Combine("obj", "debug", "netstandard2.0"); + + var expectedDocument = new XDocument( + new XDeclaration("1.0", "utf-8", "yes"), + new XElement("Manifest", + new XElement("ManifestVersion", "1.0"), + new XElement("FileSystem", + new XElement("Directory", new XAttribute("Name", "A"), + new XElement("Directory", new XAttribute("Name", "B"), + new XElement("File", new XAttribute("Name", "C.txt"), new XElement("ResourcePath", "A.B.C.txt")), + new XElement("File", new XAttribute("Name", "c.txt"), new XElement("ResourcePath", "A.B.c.txt"))), + new XElement("Directory", new XAttribute("Name", "D"), + new XElement("File", new XAttribute("Name", "e.txt"), new XElement("ResourcePath", "A.D.e.txt"))), + new XElement("Directory", new XAttribute("Name", "b"), + new XElement("File", new XAttribute("Name", "C.txt"), new XElement("ResourcePath", "A.b.C.txt")), + new XElement("File", new XAttribute("Name", "c.txt"), new XElement("ResourcePath", "A.b.c.txt"))), + new XElement("File", new XAttribute("Name", "d"), new XElement("ResourcePath", "A.d")))))); + + var expectedOutput = new MemoryStream(); + var writer = XmlWriter.Create(expectedOutput, new XmlWriterSettings { Encoding = Encoding.UTF8 }); + expectedDocument.WriteTo(writer); + writer.Flush(); + expectedOutput.Seek(0, SeekOrigin.Begin); + + // Act + task.Execute(); + + // Assert + task.Output.Seek(0, SeekOrigin.Begin); + using (var expectedReader = new StreamReader(expectedOutput)) + { + using (var reader = new StreamReader(task.Output)) + { + Assert.Equal(expectedReader.ReadToEnd(), reader.ReadToEnd()); + } + } + } + + private EmbeddedItem CreateEmbeddedItem(string manifestPath, string assemblyName) => + new EmbeddedItem + { + ManifestFilePath = manifestPath, + AssemblyResourceName = assemblyName + }; + + + public class TestGenerateEmbeddedResourcesManifest + : GenerateEmbeddedResourcesManifest + { + public TestGenerateEmbeddedResourcesManifest() + : this(new MemoryStream()) + { + } + + public TestGenerateEmbeddedResourcesManifest(Stream output) + { + Output = output; + } + + public Stream Output { get; } + + protected override XmlWriter GetXmlWriter(XmlWriterSettings settings) + { + settings.CloseOutput = false; + return XmlWriter.Create(Output, settings); + } + } + + private ITaskItem[] CreateEmbeddedResource(params IDictionary[] files) => + files.Select(f => CreateTaskItem(f)).ToArray(); + + private ITaskItem CreateTaskItem(IDictionary metadata) + { + var result = new TaskItem(); + foreach (var kvp in metadata) + { + result.SetMetadata(kvp.Key, kvp.Value); + } + + return result; + } + + private static IDictionary + CreateMetadata( + string targetPath, + string manifestResourceName = null, + string logicalName = null) => + new Dictionary + { + ["TargetPath"] = targetPath, + ["ManifestResourceName"] = manifestResourceName ?? targetPath.Replace("/", ".").Replace("\\", "."), + ["LogicalName"] = logicalName ?? targetPath.Replace("/", ".").Replace("\\", "."), + }; + + private class ManifestComparer : IEqualityComparer + { + public static IEqualityComparer Instance { get; } = new ManifestComparer(); + + public bool Equals(Manifest x, Manifest y) + { + return x.Root.Equals(y.Root); + } + + public int GetHashCode(Manifest obj) + { + return obj.Root.GetHashCode(); + } + } + } +} diff --git a/src/FileProviders/Manifest.MSBuildTask/test/Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.Tests.csproj b/src/FileProviders/Manifest.MSBuildTask/test/Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.Tests.csproj new file mode 100644 index 000000000000..06afd574e83a --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/test/Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.Tests.csproj @@ -0,0 +1,16 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + + diff --git a/src/FileProviders/Manifest.MSBuildTask/test/SetExtensions.cs b/src/FileProviders/Manifest.MSBuildTask/test/SetExtensions.cs new file mode 100644 index 000000000000..6b2c83a875fc --- /dev/null +++ b/src/FileProviders/Manifest.MSBuildTask/test/SetExtensions.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.Internal; + +namespace Microsoft.Extensions.FileProviders.Embedded.Manifest.Task +{ + internal static class SetExtensions + { + public static Entry AddRange(this Entry source, params Entry[] elements) + { + foreach (var element in elements) + { + source.Children.Add(element); + } + + return source; + } + } +} diff --git a/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj b/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj new file mode 100644 index 000000000000..6b67a0868621 --- /dev/null +++ b/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + diff --git a/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.netcoreapp.cs b/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.netcoreapp.cs new file mode 100644 index 000000000000..616636274594 --- /dev/null +++ b/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.netcoreapp.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public sealed partial class HealthCheckContext + { + public HealthCheckContext() { } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration Registration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + public sealed partial class HealthCheckRegistration + { + public HealthCheckRegistration(string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) { } + public HealthCheckRegistration(string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, System.TimeSpan? timeout) { } + public HealthCheckRegistration(string name, System.Func factory, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) { } + public HealthCheckRegistration(string name, System.Func factory, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, System.TimeSpan? timeout) { } + public System.Func Factory { get { throw null; } set { } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus FailureStatus { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string Name { get { throw null; } set { } } + public System.Collections.Generic.ISet Tags { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.TimeSpan Timeout { get { throw null; } set { } } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public partial struct HealthCheckResult + { + private object _dummy; + private int _dummyPrimitive; + public HealthCheckResult(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus status, string description = null, System.Exception exception = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + public System.Collections.Generic.IReadOnlyDictionary Data { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Description { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus Status { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public static Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult Degraded(string description = null, System.Exception exception = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + public static Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult Healthy(string description = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + public static Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult Unhealthy(string description = null, System.Exception exception = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + } + public sealed partial class HealthReport + { + public HealthReport(System.Collections.Generic.IReadOnlyDictionary entries, System.TimeSpan totalDuration) { } + public System.Collections.Generic.IReadOnlyDictionary Entries { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus Status { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.TimeSpan TotalDuration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public partial struct HealthReportEntry + { + private object _dummy; + private int _dummyPrimitive; + public HealthReportEntry(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus status, string description, System.TimeSpan duration, System.Exception exception, System.Collections.Generic.IReadOnlyDictionary data) { throw null; } + public HealthReportEntry(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus status, string description, System.TimeSpan duration, System.Exception exception, System.Collections.Generic.IReadOnlyDictionary data, System.Collections.Generic.IEnumerable tags = null) { throw null; } + public System.Collections.Generic.IReadOnlyDictionary Data { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Description { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.TimeSpan Duration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus Status { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Collections.Generic.IEnumerable Tags { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public enum HealthStatus + { + Unhealthy = 0, + Degraded = 1, + Healthy = 2, + } + public partial interface IHealthCheck + { + System.Threading.Tasks.Task CheckHealthAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + } + public partial interface IHealthCheckPublisher + { + System.Threading.Tasks.Task PublishAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report, System.Threading.CancellationToken cancellationToken); + } +} diff --git a/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.netstandard2.0.cs b/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.netstandard2.0.cs new file mode 100644 index 000000000000..616636274594 --- /dev/null +++ b/src/HealthChecks/Abstractions/ref/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.netstandard2.0.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public sealed partial class HealthCheckContext + { + public HealthCheckContext() { } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration Registration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + public sealed partial class HealthCheckRegistration + { + public HealthCheckRegistration(string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) { } + public HealthCheckRegistration(string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, System.TimeSpan? timeout) { } + public HealthCheckRegistration(string name, System.Func factory, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) { } + public HealthCheckRegistration(string name, System.Func factory, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, System.TimeSpan? timeout) { } + public System.Func Factory { get { throw null; } set { } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus FailureStatus { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string Name { get { throw null; } set { } } + public System.Collections.Generic.ISet Tags { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.TimeSpan Timeout { get { throw null; } set { } } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public partial struct HealthCheckResult + { + private object _dummy; + private int _dummyPrimitive; + public HealthCheckResult(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus status, string description = null, System.Exception exception = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + public System.Collections.Generic.IReadOnlyDictionary Data { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Description { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus Status { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public static Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult Degraded(string description = null, System.Exception exception = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + public static Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult Healthy(string description = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + public static Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult Unhealthy(string description = null, System.Exception exception = null, System.Collections.Generic.IReadOnlyDictionary data = null) { throw null; } + } + public sealed partial class HealthReport + { + public HealthReport(System.Collections.Generic.IReadOnlyDictionary entries, System.TimeSpan totalDuration) { } + public System.Collections.Generic.IReadOnlyDictionary Entries { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus Status { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.TimeSpan TotalDuration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public partial struct HealthReportEntry + { + private object _dummy; + private int _dummyPrimitive; + public HealthReportEntry(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus status, string description, System.TimeSpan duration, System.Exception exception, System.Collections.Generic.IReadOnlyDictionary data) { throw null; } + public HealthReportEntry(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus status, string description, System.TimeSpan duration, System.Exception exception, System.Collections.Generic.IReadOnlyDictionary data, System.Collections.Generic.IEnumerable tags = null) { throw null; } + public System.Collections.Generic.IReadOnlyDictionary Data { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Description { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.TimeSpan Duration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus Status { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Collections.Generic.IEnumerable Tags { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public enum HealthStatus + { + Unhealthy = 0, + Degraded = 1, + Healthy = 2, + } + public partial interface IHealthCheck + { + System.Threading.Tasks.Task CheckHealthAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckContext context, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + } + public partial interface IHealthCheckPublisher + { + System.Threading.Tasks.Task PublishAsync(Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report, System.Threading.CancellationToken cancellationToken); + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthCheckContext.cs b/src/HealthChecks/Abstractions/src/HealthCheckContext.cs new file mode 100644 index 000000000000..027451c0d243 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthCheckContext.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public sealed class HealthCheckContext + { + /// + /// Gets or sets the of the currently executing . + /// + public HealthCheckRegistration Registration { get; set; } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthCheckRegistration.cs b/src/HealthChecks/Abstractions/src/HealthCheckRegistration.cs new file mode 100644 index 000000000000..8ee11e3195b7 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthCheckRegistration.cs @@ -0,0 +1,199 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represent the registration information associated with an implementation. + /// + /// + /// + /// The health check registration is provided as a separate object so that application developers can customize + /// how health check implementations are configured. + /// + /// + /// The registration is provided to an implementation during execution through + /// . This allows a health check implementation to access named + /// options or perform other operations based on the registered name. + /// + /// + public sealed class HealthCheckRegistration + { + private Func _factory; + private string _name; + private TimeSpan _timeout; + + /// + /// Creates a new for an existing instance. + /// + /// The health check name. + /// The instance. + /// + /// The that should be reported upon failure of the health check. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used for filtering health checks. + public HealthCheckRegistration(string name, IHealthCheck instance, HealthStatus? failureStatus, IEnumerable tags) + : this(name, instance, failureStatus, tags, default) + { + } + + /// + /// Creates a new for an existing instance. + /// + /// The health check name. + /// The instance. + /// + /// The that should be reported upon failure of the health check. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used for filtering health checks. + /// An optional representing the timeout of the check. + public HealthCheckRegistration(string name, IHealthCheck instance, HealthStatus? failureStatus, IEnumerable tags, TimeSpan? timeout) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + if (timeout <= TimeSpan.Zero && timeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + Name = name; + FailureStatus = failureStatus ?? HealthStatus.Unhealthy; + Tags = new HashSet(tags ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + Factory = (_) => instance; + Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan; + } + + /// + /// Creates a new for an existing instance. + /// + /// The health check name. + /// A delegate used to create the instance. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used for filtering health checks. + public HealthCheckRegistration( + string name, + Func factory, + HealthStatus? failureStatus, + IEnumerable tags) + : this(name, factory, failureStatus, tags, default) + { + } + + /// + /// Creates a new for an existing instance. + /// + /// The health check name. + /// A delegate used to create the instance. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used for filtering health checks. + /// An optional representing the timeout of the check. + public HealthCheckRegistration( + string name, + Func factory, + HealthStatus? failureStatus, + IEnumerable tags, + TimeSpan? timeout) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + if (timeout <= TimeSpan.Zero && timeout != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(timeout)); + } + + Name = name; + FailureStatus = failureStatus ?? HealthStatus.Unhealthy; + Tags = new HashSet(tags ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + Factory = factory; + Timeout = timeout ?? System.Threading.Timeout.InfiniteTimeSpan; + } + + /// + /// Gets or sets a delegate used to create the instance. + /// + public Func Factory + { + get => _factory; + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + _factory = value; + } + } + + /// + /// Gets or sets the that should be reported upon failure of the health check. + /// + public HealthStatus FailureStatus { get; set; } + + /// + /// Gets or sets the timeout used for the test. + /// + public TimeSpan Timeout + { + get => _timeout; + set + { + if (value <= TimeSpan.Zero && value != System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + + _timeout = value; + } + } + + /// + /// Gets or sets the health check name. + /// + public string Name + { + get => _name; + set + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + _name = value; + } + } + + /// + /// Gets a list of tags that can be used for filtering health checks. + /// + public ISet Tags { get; } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthCheckResult.cs b/src/HealthChecks/Abstractions/src/HealthCheckResult.cs new file mode 100644 index 000000000000..7f4522da19c9 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthCheckResult.cs @@ -0,0 +1,88 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the result of a health check. + /// + public struct HealthCheckResult + { + private static readonly IReadOnlyDictionary _emptyReadOnlyDictionary = new Dictionary(); + + /// + /// Creates a new with the specified values for , + /// , , and . + /// + /// A value indicating the status of the component that was checked. + /// A human-readable description of the status of the component that was checked. + /// An representing the exception that was thrown when checking for status (if any). + /// Additional key-value pairs describing the health of the component. + public HealthCheckResult(HealthStatus status, string description = null, Exception exception = null, IReadOnlyDictionary data = null) + { + Status = status; + Description = description; + Exception = exception; + Data = data ?? _emptyReadOnlyDictionary; + } + + /// + /// Gets additional key-value pairs describing the health of the component. + /// + public IReadOnlyDictionary Data { get; } + + /// + /// Gets a human-readable description of the status of the component that was checked. + /// + public string Description { get; } + + /// + /// Gets an representing the exception that was thrown when checking for status (if any). + /// + public Exception Exception { get; } + + /// + /// Gets a value indicating the status of the component that was checked. + /// + public HealthStatus Status { get; } + + /// + /// Creates a representing a healthy component. + /// + /// A human-readable description of the status of the component that was checked. Optional. + /// Additional key-value pairs describing the health of the component. Optional. + /// A representing a healthy component. + public static HealthCheckResult Healthy(string description = null, IReadOnlyDictionary data = null) + { + return new HealthCheckResult(status: HealthStatus.Healthy, description, exception: null, data); + } + + + /// + /// Creates a representing a degraded component. + /// + /// A human-readable description of the status of the component that was checked. Optional. + /// An representing the exception that was thrown when checking for status. Optional. + /// Additional key-value pairs describing the health of the component. Optional. + /// A representing a degraged component. + public static HealthCheckResult Degraded(string description = null, Exception exception = null, IReadOnlyDictionary data = null) + { + return new HealthCheckResult(status: HealthStatus.Degraded, description, exception: exception, data); + } + + /// + /// Creates a representing an unhealthy component. + /// + /// A human-readable description of the status of the component that was checked. Optional. + /// An representing the exception that was thrown when checking for status. Optional. + /// Additional key-value pairs describing the health of the component. Optional. + /// A representing an unhealthy component. + public static HealthCheckResult Unhealthy(string description = null, Exception exception = null, IReadOnlyDictionary data = null) + { + return new HealthCheckResult(status: HealthStatus.Unhealthy, description, exception, data); + } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthReport.cs b/src/HealthChecks/Abstractions/src/HealthReport.cs new file mode 100644 index 000000000000..6a796b0d8230 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthReport.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the result of executing a group of instances. + /// + public sealed class HealthReport + { + /// + /// Create a new from the specified results. + /// + /// A containing the results from each health check. + /// A value indicating the time the health check service took to execute. + public HealthReport(IReadOnlyDictionary entries, TimeSpan totalDuration) + { + Entries = entries; + Status = CalculateAggregateStatus(entries.Values); + TotalDuration = totalDuration; + } + + /// + /// A containing the results from each health check. + /// + /// + /// The keys in this dictionary map the name of each executed health check to a for the + /// result data returned from the corresponding health check. + /// + public IReadOnlyDictionary Entries { get; } + + /// + /// Gets a representing the aggregate status of all the health checks. The value of + /// will be the most severe status reported by a health check. If no checks were executed, the value is always . + /// + public HealthStatus Status { get; } + + /// + /// Gets the time the health check service took to execute. + /// + public TimeSpan TotalDuration { get; } + + private HealthStatus CalculateAggregateStatus(IEnumerable entries) + { + // This is basically a Min() check, but we know the possible range, so we don't need to walk the whole list + var currentValue = HealthStatus.Healthy; + foreach (var entry in entries) + { + if (currentValue > entry.Status) + { + currentValue = entry.Status; + } + + if (currentValue == HealthStatus.Unhealthy) + { + // Game over, man! Game over! + // (We hit the worst possible status, so there's no need to keep iterating) + return currentValue; + } + } + + return currentValue; + } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthReportEntry.cs b/src/HealthChecks/Abstractions/src/HealthReportEntry.cs new file mode 100644 index 000000000000..043c1414a4b1 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthReportEntry.cs @@ -0,0 +1,82 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents an entry in a . Corresponds to the result of a single . + /// + public struct HealthReportEntry + { + private static readonly IReadOnlyDictionary _emptyReadOnlyDictionary = new Dictionary(); + + /// + /// Creates a new with the specified values for , , + /// , and . + /// + /// A value indicating the health status of the component that was checked. + /// A human-readable description of the status of the component that was checked. + /// A value indicating the health execution duration. + /// An representing the exception that was thrown when checking for status (if any). + /// Additional key-value pairs describing the health of the component. + public HealthReportEntry(HealthStatus status, string description, TimeSpan duration, Exception exception, IReadOnlyDictionary data) + : this(status, description, duration, exception, data, null) + { + } + + /// + /// Creates a new with the specified values for , , + /// , and . + /// + /// A value indicating the health status of the component that was checked. + /// A human-readable description of the status of the component that was checked. + /// A value indicating the health execution duration. + /// An representing the exception that was thrown when checking for status (if any). + /// Additional key-value pairs describing the health of the component. + /// Tags associated with the health check that generated the report entry. + public HealthReportEntry(HealthStatus status, string description, TimeSpan duration, Exception exception, IReadOnlyDictionary data, IEnumerable tags = null) + { + Status = status; + Description = description; + Duration = duration; + Exception = exception; + Data = data ?? _emptyReadOnlyDictionary; + Tags = tags ?? Enumerable.Empty(); + } + + + /// + /// Gets additional key-value pairs describing the health of the component. + /// + public IReadOnlyDictionary Data { get; } + + /// + /// Gets a human-readable description of the status of the component that was checked. + /// + public string Description { get; } + + /// + /// Gets the health check execution duration. + /// + public TimeSpan Duration { get; } + + /// + /// Gets an representing the exception that was thrown when checking for status (if any). + /// + public Exception Exception { get; } + + /// + /// Gets the health status of the component that was checked. + /// + public HealthStatus Status { get; } + + /// + /// Gets the tags associated with the health check. + /// + public IEnumerable Tags { get; } + } +} diff --git a/src/HealthChecks/Abstractions/src/HealthStatus.cs b/src/HealthChecks/Abstractions/src/HealthStatus.cs new file mode 100644 index 000000000000..61b76d54fa19 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/HealthStatus.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents the reported status of a health check result. + /// + /// + /// + /// A status of should be considered the default value for a failing health check. Application + /// developers may configure a health check to report a different status as desired. + /// + /// + /// The values of this enum or ordered from least healthy to most healthy. So is + /// greater than but less than . + /// + /// + public enum HealthStatus + { + /// + /// Indicates that the health check determined that the component was unhealthy, or an unhandled + /// exception was thrown while executing the health check. + /// + Unhealthy = 0, + + /// + /// Indicates that the health check determined that the component was in a degraded state. + /// + Degraded = 1, + + /// + /// Indicates that the health check determined that the component was healthy. + /// + Healthy = 2, + } +} diff --git a/src/HealthChecks/Abstractions/src/IHealthCheck.cs b/src/HealthChecks/Abstractions/src/IHealthCheck.cs new file mode 100644 index 000000000000..1b69953b67c6 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/IHealthCheck.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents a health check, which can be used to check the status of a component in the application, such as a backend service, database or some internal + /// state. + /// + public interface IHealthCheck + { + /// + /// Runs the health check, returning the status of the component being checked. + /// + /// A context object associated with the current execution. + /// A that can be used to cancel the health check. + /// A that completes when the health check has finished, yielding the status of the component being checked. + Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/HealthChecks/Abstractions/src/IHealthCheckPublisher.cs b/src/HealthChecks/Abstractions/src/IHealthCheckPublisher.cs new file mode 100644 index 000000000000..f1809c4bb893 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/IHealthCheckPublisher.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents a publisher of information. + /// + /// + /// + /// The default health checks implementation provided an IHostedService implementation that can + /// be used to execute health checks at regular intervals and provide the resulting + /// data to all registered instances. + /// + /// + /// To provide an implementation, register an instance or type as a singleton + /// service in the dependency injection container. + /// + /// + /// instances are provided with a after executing + /// health checks in a background thread. The use of depend on hosting in + /// an application using IWebHost or generic host (IHost). Execution of + /// instance is not related to execution of health checks via a middleware. + /// + /// + public interface IHealthCheckPublisher + { + /// + /// Publishes the provided . + /// + /// The . The result of executing a set of health checks. + /// The . + /// A which will complete when publishing is complete. + Task PublishAsync(HealthReport report, CancellationToken cancellationToken); + } +} diff --git a/src/HealthChecks/Abstractions/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj b/src/HealthChecks/Abstractions/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj new file mode 100644 index 000000000000..aeb85d3e7603 --- /dev/null +++ b/src/HealthChecks/Abstractions/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions.csproj @@ -0,0 +1,19 @@ + + + + Abstractions for defining health checks in .NET applications + +Commonly Used Types +Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck + + Microsoft.Extensions.Diagnostics.HealthChecks + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(DefaultNetCoreTargetFramework) + $(NoWarn);CS1591 + true + diagnostics;healthchecks + true + true + + + diff --git a/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.csproj b/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.csproj new file mode 100644 index 000000000000..83dae521fbbf --- /dev/null +++ b/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + + + + + diff --git a/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.netcoreapp.cs b/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.netcoreapp.cs new file mode 100644 index 000000000000..cee2d1420e1a --- /dev/null +++ b/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.netcoreapp.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class HealthChecksBuilderAddCheckExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus?), System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus?), System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, System.TimeSpan timeout, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + } + public static partial class HealthChecksBuilderDelegateExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + } + public static partial class HealthCheckServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddHealthChecks(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + } + public partial interface IHealthChecksBuilder + { + Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get; } + Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder Add(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration registration); + } +} +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public sealed partial class HealthCheckPublisherOptions + { + public HealthCheckPublisherOptions() { } + public System.TimeSpan Delay { get { throw null; } set { } } + public System.TimeSpan Period { get { throw null; } set { } } + public System.Func Predicate { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public System.TimeSpan Timeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + public abstract partial class HealthCheckService + { + protected HealthCheckService() { } + public abstract System.Threading.Tasks.Task CheckHealthAsync(System.Func predicate, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + public System.Threading.Tasks.Task CheckHealthAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } + public sealed partial class HealthCheckServiceOptions + { + public HealthCheckServiceOptions() { } + public System.Collections.Generic.ICollection Registrations { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } +} diff --git a/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.netstandard2.0.cs b/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.netstandard2.0.cs new file mode 100644 index 000000000000..cee2d1420e1a --- /dev/null +++ b/src/HealthChecks/HealthChecks/ref/Microsoft.Extensions.Diagnostics.HealthChecks.netstandard2.0.cs @@ -0,0 +1,59 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class HealthChecksBuilderAddCheckExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck instance, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus?), System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus = default(Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus?), System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, System.Collections.Generic.IEnumerable tags, System.TimeSpan timeout, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, Microsoft.Extensions.Diagnostics.HealthChecks.HealthStatus? failureStatus, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddTypeActivatedCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, params object[] args) where T : class, Microsoft.Extensions.Diagnostics.HealthChecks.IHealthCheck { throw null; } + } + public static partial class HealthChecksBuilderDelegateExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddAsyncCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func> check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddCheck(this Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder builder, string name, System.Func check, System.Collections.Generic.IEnumerable tags = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + } + public static partial class HealthCheckServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder AddHealthChecks(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + } + public partial interface IHealthChecksBuilder + { + Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get; } + Microsoft.Extensions.DependencyInjection.IHealthChecksBuilder Add(Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration registration); + } +} +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public sealed partial class HealthCheckPublisherOptions + { + public HealthCheckPublisherOptions() { } + public System.TimeSpan Delay { get { throw null; } set { } } + public System.TimeSpan Period { get { throw null; } set { } } + public System.Func Predicate { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public System.TimeSpan Timeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + public abstract partial class HealthCheckService + { + protected HealthCheckService() { } + public abstract System.Threading.Tasks.Task CheckHealthAsync(System.Func predicate, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + public System.Threading.Tasks.Task CheckHealthAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } + public sealed partial class HealthCheckServiceOptions + { + public HealthCheckServiceOptions() { } + public System.Collections.Generic.ICollection Registrations { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs b/src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs new file mode 100644 index 000000000000..253e6a4746f0 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs @@ -0,0 +1,346 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal class DefaultHealthCheckService : HealthCheckService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptions _options; + private readonly ILogger _logger; + + public DefaultHealthCheckService( + IServiceScopeFactory scopeFactory, + IOptions options, + ILogger logger) + { + _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + // We're specifically going out of our way to do this at startup time. We want to make sure you + // get any kind of health-check related error as early as possible. Waiting until someone + // actually tries to **run** health checks would be real baaaaad. + ValidateRegistrations(_options.Value.Registrations); + } + public override async Task CheckHealthAsync( + Func predicate, + CancellationToken cancellationToken = default) + { + var registrations = _options.Value.Registrations; + if (predicate != null) + { + registrations = registrations.Where(predicate).ToArray(); + } + + var totalTime = ValueStopwatch.StartNew(); + Log.HealthCheckProcessingBegin(_logger); + + var tasks = new Task[registrations.Count]; + var index = 0; + using (var scope = _scopeFactory.CreateScope()) + { + foreach (var registration in registrations) + { + tasks[index++] = Task.Run(() => RunCheckAsync(scope, registration, cancellationToken), cancellationToken); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + index = 0; + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var registration in registrations) + { + entries[registration.Name] = tasks[index++].Result; + } + + var totalElapsedTime = totalTime.GetElapsedTime(); + var report = new HealthReport(entries, totalElapsedTime); + Log.HealthCheckProcessingEnd(_logger, report.Status, totalElapsedTime); + return report; + } + + private async Task RunCheckAsync(IServiceScope scope, HealthCheckRegistration registration, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var healthCheck = registration.Factory(scope.ServiceProvider); + + // If the health check does things like make Database queries using EF or backend HTTP calls, + // it may be valuable to know that logs it generates are part of a health check. So we start a scope. + using (_logger.BeginScope(new HealthCheckLogScope(registration.Name))) + { + var stopwatch = ValueStopwatch.StartNew(); + var context = new HealthCheckContext { Registration = registration }; + + Log.HealthCheckBegin(_logger, registration); + + HealthReportEntry entry; + CancellationTokenSource timeoutCancellationTokenSource = null; + try + { + HealthCheckResult result; + + var checkCancellationToken = cancellationToken; + if (registration.Timeout > TimeSpan.Zero) + { + timeoutCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCancellationTokenSource.CancelAfter(registration.Timeout); + checkCancellationToken = timeoutCancellationTokenSource.Token; + } + + result = await healthCheck.CheckHealthAsync(context, checkCancellationToken).ConfigureAwait(false); + + var duration = stopwatch.GetElapsedTime(); + + entry = new HealthReportEntry( + status: result.Status, + description: result.Description, + duration: duration, + exception: result.Exception, + data: result.Data, + tags: registration.Tags); + + Log.HealthCheckEnd(_logger, registration, entry, duration); + Log.HealthCheckData(_logger, registration, entry); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + var duration = stopwatch.GetElapsedTime(); + entry = new HealthReportEntry( + status: HealthStatus.Unhealthy, + description: "A timeout occurred while running check.", + duration: duration, + exception: ex, + data: null); + + Log.HealthCheckError(_logger, registration, ex, duration); + } + + // Allow cancellation to propagate if it's not a timeout. + catch (Exception ex) when (ex as OperationCanceledException == null) + { + var duration = stopwatch.GetElapsedTime(); + entry = new HealthReportEntry( + status: HealthStatus.Unhealthy, + description: ex.Message, + duration: duration, + exception: ex, + data: null); + + Log.HealthCheckError(_logger, registration, ex, duration); + } + + finally + { + timeoutCancellationTokenSource?.Dispose(); + } + + return entry; + } + } + + private static void ValidateRegistrations(IEnumerable registrations) + { + // Scan the list for duplicate names to provide a better error if there are duplicates. + var duplicateNames = registrations + .GroupBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + + if (duplicateNames.Count > 0) + { + throw new ArgumentException($"Duplicate health checks were registered with the name(s): {string.Join(", ", duplicateNames)}", nameof(registrations)); + } + } + + internal static class EventIds + { + public static readonly EventId HealthCheckProcessingBegin = new EventId(100, "HealthCheckProcessingBegin"); + public static readonly EventId HealthCheckProcessingEnd = new EventId(101, "HealthCheckProcessingEnd"); + + public static readonly EventId HealthCheckBegin = new EventId(102, "HealthCheckBegin"); + public static readonly EventId HealthCheckEnd = new EventId(103, "HealthCheckEnd"); + public static readonly EventId HealthCheckError = new EventId(104, "HealthCheckError"); + public static readonly EventId HealthCheckData = new EventId(105, "HealthCheckData"); + } + + private static class Log + { + private static readonly Action _healthCheckProcessingBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckProcessingBegin, + "Running health checks"); + + private static readonly Action _healthCheckProcessingEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckProcessingEnd, + "Health check processing completed after {ElapsedMilliseconds}ms with combined status {HealthStatus}"); + + private static readonly Action _healthCheckBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckBegin, + "Running health check {HealthCheckName}"); + + // These are separate so they can have different log levels + private static readonly string HealthCheckEndText = "Health check {HealthCheckName} completed after {ElapsedMilliseconds}ms with status {HealthStatus} and '{HealthCheckDescription}'"; + + private static readonly Action _healthCheckEndHealthy = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckEndDegraded = LoggerMessage.Define( + LogLevel.Warning, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckEndUnhealthy = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckEndFailed = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckEnd, + HealthCheckEndText); + + private static readonly Action _healthCheckError = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckError, + "Health check {HealthCheckName} threw an unhandled exception after {ElapsedMilliseconds}ms"); + + public static void HealthCheckProcessingBegin(ILogger logger) + { + _healthCheckProcessingBegin(logger, null); + } + + public static void HealthCheckProcessingEnd(ILogger logger, HealthStatus status, TimeSpan duration) + { + _healthCheckProcessingEnd(logger, duration.TotalMilliseconds, status, null); + } + + public static void HealthCheckBegin(ILogger logger, HealthCheckRegistration registration) + { + _healthCheckBegin(logger, registration.Name, null); + } + + public static void HealthCheckEnd(ILogger logger, HealthCheckRegistration registration, HealthReportEntry entry, TimeSpan duration) + { + switch (entry.Status) + { + case HealthStatus.Healthy: + _healthCheckEndHealthy(logger, registration.Name, duration.TotalMilliseconds, entry.Status, entry.Description, null); + break; + + case HealthStatus.Degraded: + _healthCheckEndDegraded(logger, registration.Name, duration.TotalMilliseconds, entry.Status, entry.Description, null); + break; + + case HealthStatus.Unhealthy: + _healthCheckEndUnhealthy(logger, registration.Name, duration.TotalMilliseconds, entry.Status, entry.Description, null); + break; + } + } + + public static void HealthCheckError(ILogger logger, HealthCheckRegistration registration, Exception exception, TimeSpan duration) + { + _healthCheckError(logger, registration.Name, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckData(ILogger logger, HealthCheckRegistration registration, HealthReportEntry entry) + { + if (entry.Data.Count > 0 && logger.IsEnabled(LogLevel.Debug)) + { + logger.Log( + LogLevel.Debug, + EventIds.HealthCheckData, + new HealthCheckDataLogValue(registration.Name, entry.Data), + null, + (state, ex) => state.ToString()); + } + } + } + + internal class HealthCheckDataLogValue : IReadOnlyList> + { + private readonly string _name; + private readonly List> _values; + + private string _formatted; + + public HealthCheckDataLogValue(string name, IReadOnlyDictionary values) + { + _name = name; + _values = values.ToList(); + + // We add the name as a kvp so that you can filter by health check name in the logs. + // This is the same parameter name used in the other logs. + _values.Add(new KeyValuePair("HealthCheckName", name)); + } + + public KeyValuePair this[int index] + { + get + { + if (index < 0 || index >= Count) + { + throw new IndexOutOfRangeException(nameof(index)); + } + + return _values[index]; + } + } + + public int Count => _values.Count; + + public IEnumerator> GetEnumerator() + { + return _values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _values.GetEnumerator(); + } + + public override string ToString() + { + if (_formatted == null) + { + var builder = new StringBuilder(); + builder.AppendLine($"Health check data for {_name}:"); + + var values = _values; + for (var i = 0; i < values.Count; i++) + { + var kvp = values[i]; + builder.Append(" "); + builder.Append(kvp.Key); + builder.Append(": "); + + builder.AppendLine(kvp.Value?.ToString()); + } + + _formatted = builder.ToString(); + } + + return _formatted; + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DelegateHealthCheck.cs b/src/HealthChecks/HealthChecks/src/DelegateHealthCheck.cs new file mode 100644 index 000000000000..94069fd7d154 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DelegateHealthCheck.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// A simple implementation of which uses a provided delegate to + /// implement the check. + /// + internal sealed class DelegateHealthCheck : IHealthCheck + { + private readonly Func> _check; + + /// + /// Create an instance of from the specified delegate. + /// + /// A delegate which provides the code to execute when the health check is run. + public DelegateHealthCheck(Func> check) + { + _check = check ?? throw new ArgumentNullException(nameof(check)); + } + + /// + /// Runs the health check, returning the status of the component being checked. + /// + /// A context object associated with the current execution. + /// A that can be used to cancel the health check. + /// A that completes when the health check has finished, yielding the status of the component being checked. + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) => _check(cancellationToken); + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthCheckServiceCollectionExtensions.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthCheckServiceCollectionExtensions.cs new file mode 100644 index 000000000000..91ffa59449e3 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthCheckServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides extension methods for registering in an . + /// + public static class HealthCheckServiceCollectionExtensions + { + /// + /// Adds the to the container, using the provided delegate to register + /// health checks. + /// + /// + /// This operation is idempotent - multiple invocations will still only result in a single + /// instance in the . It can be invoked + /// multiple times in order to get access to the in multiple places. + /// + /// The to add the to. + /// An instance of from which health checks can be registered. + public static IHealthChecksBuilder AddHealthChecks(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return new HealthChecksBuilder(services); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilder.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilder.cs new file mode 100644 index 000000000000..231dd5171721 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilder.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + internal class HealthChecksBuilder : IHealthChecksBuilder + { + public HealthChecksBuilder(IServiceCollection services) + { + Services = services; + } + + public IServiceCollection Services { get; } + + public IHealthChecksBuilder Add(HealthCheckRegistration registration) + { + if (registration == null) + { + throw new ArgumentNullException(nameof(registration)); + } + + Services.Configure(options => + { + options.Registrations.Add(registration); + }); + + return this; + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderAddCheckExtensions.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderAddCheckExtensions.cs new file mode 100644 index 000000000000..51b7815438f1 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderAddCheckExtensions.cs @@ -0,0 +1,285 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides basic extension methods for registering instances in an . + /// + public static class HealthChecksBuilderAddCheckExtensions + { + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// An instance. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// The . + // 2.0 BACKCOMPAT OVERLOAD -- DO NOT TOUCH + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + IHealthCheck instance, + HealthStatus? failureStatus, + IEnumerable tags) + { + return AddCheck(builder, name, instance, failureStatus, tags, default); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// An instance. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// An optional representing the timeout of the check. + /// The . + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + IHealthCheck instance, + HealthStatus? failureStatus = null, + IEnumerable tags = null, + TimeSpan? timeout = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (instance == null) + { + throw new ArgumentNullException(nameof(instance)); + } + + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus, tags, timeout)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. If a service of type is registered in the dependency injection container + /// with any lifetime it will be used. Otherwise an instance of type will be constructed with + /// access to services from the dependency injection container. + /// + // 2.0 BACKCOMPAT OVERLOAD -- DO NOT TOUCH + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus, + IEnumerable tags) where T : class, IHealthCheck + { + return AddCheck(builder, name, failureStatus, tags, default); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// An optional representing the timeout of the check. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. If a service of type is registered in the dependency injection container + /// with any lifetime it will be used. Otherwise an instance of type will be constructed with + /// access to services from the dependency injection container. + /// + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus = null, + IEnumerable tags = null, + TimeSpan? timeout = null) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return builder.Add(new HealthCheckRegistration(name, s => ActivatorUtilities.GetServiceOrCreateInstance(s), failureStatus, tags, timeout)); + } + + // NOTE: AddTypeActivatedCheck has overloads rather than default parameters values, because default parameter values don't + // play super well with params. + + /// + /// Adds a new type activated health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// Additional arguments to provide to the constructor. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. Additional arguments can be provided to the constructor via . + /// + public static IHealthChecksBuilder AddTypeActivatedCheck(this IHealthChecksBuilder builder, string name, params object[] args) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return AddTypeActivatedCheck(builder, name, failureStatus: null, tags: null, args); + } + + /// + /// Adds a new type activated health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// Additional arguments to provide to the constructor. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. Additional arguments can be provided to the constructor via . + /// + public static IHealthChecksBuilder AddTypeActivatedCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus, + params object[] args) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return AddTypeActivatedCheck(builder, name, failureStatus, tags: null, args); + } + + /// + /// Adds a new type activated health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// Additional arguments to provide to the constructor. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. Additional arguments can be provided to the constructor via . + /// + public static IHealthChecksBuilder AddTypeActivatedCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus, + IEnumerable tags, + params object[] args) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return builder.Add(new HealthCheckRegistration(name, s => ActivatorUtilities.CreateInstance(s, args), failureStatus, tags)); + } + + /// + /// Adds a new type activated health check with the specified name and implementation. + /// + /// The health check implementation type. + /// The . + /// The name of the health check. + /// + /// The that should be reported when the health check reports a failure. If the provided value + /// is null, then will be reported. + /// + /// A list of tags that can be used to filter health checks. + /// Additional arguments to provide to the constructor. + /// A representing the timeout of the check. + /// The . + /// + /// This method will use to create the health check + /// instance when needed. Additional arguments can be provided to the constructor via . + /// + public static IHealthChecksBuilder AddTypeActivatedCheck( + this IHealthChecksBuilder builder, + string name, + HealthStatus? failureStatus, + IEnumerable tags, + TimeSpan timeout, + params object[] args) where T : class, IHealthCheck + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return builder.Add(new HealthCheckRegistration(name, s => ActivatorUtilities.CreateInstance(s, args), failureStatus, tags, timeout)); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderDelegateExtensions.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderDelegateExtensions.cs new file mode 100644 index 000000000000..ba27ab555451 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/HealthChecksBuilderDelegateExtensions.cs @@ -0,0 +1,229 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Provides extension methods for registering delegates with the . + /// + public static class HealthChecksBuilderDelegateExtensions + { + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + // 2.0 BACKCOMPAT OVERLOAD -- DO NOT TOUCH + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + Func check, + IEnumerable tags) + { + return AddCheck(builder, name, check, tags, default); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// An optional representing the timeout of the check. + /// The . + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + Func check, + IEnumerable tags = null, + TimeSpan? timeout = default) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => Task.FromResult(check())); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags, timeout)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + // 2.0 BACKCOMPAT OVERLOAD -- DO NOT TOUCH + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + Func check, + IEnumerable tags) + { + return AddCheck(builder, name, check, tags, default); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// An optional representing the timeout of the check. + /// The . + public static IHealthChecksBuilder AddCheck( + this IHealthChecksBuilder builder, + string name, + Func check, + IEnumerable tags = null, + TimeSpan? timeout = default) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => Task.FromResult(check(ct))); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags, timeout)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + // 2.0 BACKCOMPAT OVERLOAD -- DO NOT TOUCH + public static IHealthChecksBuilder AddAsyncCheck( + this IHealthChecksBuilder builder, + string name, + Func> check, + IEnumerable tags) + { + return AddAsyncCheck(builder, name, check, tags, default); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// An optional representing the timeout of the check. + /// The . + public static IHealthChecksBuilder AddAsyncCheck( + this IHealthChecksBuilder builder, + string name, + Func> check, + IEnumerable tags = null, + TimeSpan? timeout = default) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => check()); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags, timeout)); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// The . + // 2.0 BACKCOMPAT OVERLOAD -- DO NOT TOUCH + public static IHealthChecksBuilder AddAsyncCheck( + this IHealthChecksBuilder builder, + string name, + Func> check, + IEnumerable tags) + { + return AddAsyncCheck(builder, name, check, tags, default); + } + + /// + /// Adds a new health check with the specified name and implementation. + /// + /// The . + /// The name of the health check. + /// A list of tags that can be used to filter health checks. + /// A delegate that provides the health check implementation. + /// An optional representing the timeout of the check. + /// The . + public static IHealthChecksBuilder AddAsyncCheck( + this IHealthChecksBuilder builder, + string name, + Func> check, + IEnumerable tags = null, + TimeSpan? timeout = default) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (check == null) + { + throw new ArgumentNullException(nameof(check)); + } + + var instance = new DelegateHealthCheck((ct) => check(ct)); + return builder.Add(new HealthCheckRegistration(name, instance, failureStatus: null, tags, timeout)); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/DependencyInjection/IHealthChecksBuilder.cs b/src/HealthChecks/HealthChecks/src/DependencyInjection/IHealthChecksBuilder.cs new file mode 100644 index 000000000000..eb78293f87c4 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/DependencyInjection/IHealthChecksBuilder.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// A builder used to register health checks. + /// + public interface IHealthChecksBuilder + { + /// + /// Adds a for a health check. + /// + /// The . + IHealthChecksBuilder Add(HealthCheckRegistration registration); + + /// + /// Gets the into which instances should be registered. + /// + IServiceCollection Services { get; } + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckLogScope.cs b/src/HealthChecks/HealthChecks/src/HealthCheckLogScope.cs new file mode 100644 index 000000000000..c7ef3ff5bd2d --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckLogScope.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal class HealthCheckLogScope : IReadOnlyList> + { + public string HealthCheckName { get; } + + int IReadOnlyCollection>.Count { get; } = 1; + + KeyValuePair IReadOnlyList>.this[int index] + { + get + { + if (index == 0) + { + return new KeyValuePair(nameof(HealthCheckName), HealthCheckName); + } + + throw new ArgumentOutOfRangeException(nameof(index)); + } + } + + /// + /// Creates a new instance of with the provided name. + /// + /// The name of the health check being executed. + public HealthCheckLogScope(string healthCheckName) + { + HealthCheckName = healthCheckName; + } + + IEnumerator> IEnumerable>.GetEnumerator() + { + yield return new KeyValuePair(nameof(HealthCheckName), HealthCheckName); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable>)this).GetEnumerator(); + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs new file mode 100644 index 000000000000..d124ffa2e3ea --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherHostedService.cs @@ -0,0 +1,262 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal sealed class HealthCheckPublisherHostedService : IHostedService + { + private readonly HealthCheckService _healthCheckService; + private readonly IOptions _options; + private readonly ILogger _logger; + private readonly IHealthCheckPublisher[] _publishers; + + private CancellationTokenSource _stopping; + private Timer _timer; + + public HealthCheckPublisherHostedService( + HealthCheckService healthCheckService, + IOptions options, + ILogger logger, + IEnumerable publishers) + { + if (healthCheckService == null) + { + throw new ArgumentNullException(nameof(healthCheckService)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (publishers == null) + { + throw new ArgumentNullException(nameof(publishers)); + } + + _healthCheckService = healthCheckService; + _options = options; + _logger = logger; + _publishers = publishers.ToArray(); + + _stopping = new CancellationTokenSource(); + } + + internal bool IsStopping => _stopping.IsCancellationRequested; + + internal bool IsTimerRunning => _timer != null; + + public Task StartAsync(CancellationToken cancellationToken = default) + { + if (_publishers.Length == 0) + { + return Task.CompletedTask; + } + + // IMPORTANT - make sure this is the last thing that happens in this method. The timer can + // fire before other code runs. + _timer = NonCapturingTimer.Create(Timer_Tick, null, dueTime: _options.Value.Delay, period: _options.Value.Period); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + try + { + _stopping.Cancel(); + } + catch + { + // Ignore exceptions thrown as a result of a cancellation. + } + + if (_publishers.Length == 0) + { + return Task.CompletedTask; + } + + _timer?.Dispose(); + _timer = null; + + + return Task.CompletedTask; + } + + // Yes, async void. We need to be async. We need to be void. We handle the exceptions in RunAsync + private async void Timer_Tick(object state) + { + await RunAsync(); + } + + // Internal for testing + internal async Task RunAsync() + { + var duration = ValueStopwatch.StartNew(); + Logger.HealthCheckPublisherProcessingBegin(_logger); + + CancellationTokenSource cancellation = null; + try + { + var timeout = _options.Value.Timeout; + + cancellation = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token); + cancellation.CancelAfter(timeout); + + await RunAsyncCore(cancellation.Token); + + Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime()); + } + catch (OperationCanceledException) when (IsStopping) + { + // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's + // a timeout and we want to log it. + } + catch (Exception ex) + { + // This is an error, publishing failed. + Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime(), ex); + } + finally + { + cancellation.Dispose(); + } + } + + private async Task RunAsyncCore(CancellationToken cancellationToken) + { + // Forcibly yield - we want to unblock the timer thread. + await Task.Yield(); + + // The health checks service does it's own logging, and doesn't throw exceptions. + var report = await _healthCheckService.CheckHealthAsync(_options.Value.Predicate, cancellationToken); + + var publishers = _publishers; + var tasks = new Task[publishers.Length]; + for (var i = 0; i < publishers.Length; i++) + { + tasks[i] = RunPublisherAsync(publishers[i], report, cancellationToken); + } + + await Task.WhenAll(tasks); + } + + private async Task RunPublisherAsync(IHealthCheckPublisher publisher, HealthReport report, CancellationToken cancellationToken) + { + var duration = ValueStopwatch.StartNew(); + + try + { + Logger.HealthCheckPublisherBegin(_logger, publisher); + + await publisher.PublishAsync(report, cancellationToken); + Logger.HealthCheckPublisherEnd(_logger, publisher, duration.GetElapsedTime()); + } + catch (OperationCanceledException) when (IsStopping) + { + // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's + // a timeout and we want to log it. + } + catch (OperationCanceledException ocex) + { + Logger.HealthCheckPublisherTimeout(_logger, publisher, duration.GetElapsedTime()); + throw ocex; + } + catch (Exception ex) + { + Logger.HealthCheckPublisherError(_logger, publisher, duration.GetElapsedTime(), ex); + throw ex; + } + } + + internal static class EventIds + { + public static readonly EventId HealthCheckPublisherProcessingBegin = new EventId(100, "HealthCheckPublisherProcessingBegin"); + public static readonly EventId HealthCheckPublisherProcessingEnd = new EventId(101, "HealthCheckPublisherProcessingEnd"); + public static readonly EventId HealthCheckPublisherProcessingError = new EventId(101, "HealthCheckPublisherProcessingError"); + + public static readonly EventId HealthCheckPublisherBegin = new EventId(102, "HealthCheckPublisherBegin"); + public static readonly EventId HealthCheckPublisherEnd = new EventId(103, "HealthCheckPublisherEnd"); + public static readonly EventId HealthCheckPublisherError = new EventId(104, "HealthCheckPublisherError"); + public static readonly EventId HealthCheckPublisherTimeout = new EventId(104, "HealthCheckPublisherTimeout"); + } + + private static class Logger + { + private static readonly Action _healthCheckPublisherProcessingBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherProcessingBegin, + "Running health check publishers"); + + private static readonly Action _healthCheckPublisherProcessingEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherProcessingEnd, + "Health check publisher processing completed after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherBegin, + "Running health check publisher '{HealthCheckPublisher}'"); + + private static readonly Action _healthCheckPublisherEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherEnd, + "Health check '{HealthCheckPublisher}' completed after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherError = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckPublisherError, + "Health check {HealthCheckPublisher} threw an unhandled exception after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherTimeout = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckPublisherTimeout, + "Health check {HealthCheckPublisher} was canceled after {ElapsedMilliseconds}ms"); + + public static void HealthCheckPublisherProcessingBegin(ILogger logger) + { + _healthCheckPublisherProcessingBegin(logger, null); + } + + public static void HealthCheckPublisherProcessingEnd(ILogger logger, TimeSpan duration, Exception exception = null) + { + _healthCheckPublisherProcessingEnd(logger, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckPublisherBegin(ILogger logger, IHealthCheckPublisher publisher) + { + _healthCheckPublisherBegin(logger, publisher, null); + } + + public static void HealthCheckPublisherEnd(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration) + { + _healthCheckPublisherEnd(logger, publisher, duration.TotalMilliseconds, null); + } + + public static void HealthCheckPublisherError(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration, Exception exception) + { + _healthCheckPublisherError(logger, publisher, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckPublisherTimeout(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration) + { + _healthCheckPublisherTimeout(logger, publisher, duration.TotalMilliseconds, null); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckPublisherOptions.cs b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherOptions.cs new file mode 100644 index 000000000000..6b7c8c3365b5 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckPublisherOptions.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Options for the default service that executes instances. + /// + public sealed class HealthCheckPublisherOptions + { + private TimeSpan _delay; + private TimeSpan _period; + + public HealthCheckPublisherOptions() + { + _delay = TimeSpan.FromSeconds(5); + _period = TimeSpan.FromSeconds(30); + } + + /// + /// Gets or sets the initial delay applied after the application starts before executing + /// instances. The delay is applied once at startup, and does + /// not apply to subsequent iterations. The default value is 5 seconds. + /// + public TimeSpan Delay + { + get => _delay; + set + { + if (value == System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentException($"The {nameof(Delay)} must not be infinite.", nameof(value)); + } + + _delay = value; + } + } + + /// + /// Gets or sets the period of execution. The default value is + /// 30 seconds. + /// + /// + /// The cannot be set to a value lower than 1 second. + /// + public TimeSpan Period + { + get => _period; + set + { + if (value < TimeSpan.FromSeconds(1)) + { + throw new ArgumentException($"The {nameof(Period)} must be greater than or equal to one second.", nameof(value)); + } + + if (value == System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentException($"The {nameof(Period)} must not be infinite.", nameof(value)); + } + + _period = value; + } + } + + /// + /// Gets or sets a predicate that is used to filter the set of health checks executed. + /// + /// + /// If is null, the health check publisher service will run all + /// registered health checks - this is the default behavior. To run a subset of health checks, + /// provide a function that filters the set of checks. The predicate will be evaluated each period. + /// + public Func Predicate { get; set; } + + /// + /// Gets or sets the timeout for executing the health checks an all + /// instances. Use to execute with no timeout. + /// The default value is 30 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckService.cs b/src/HealthChecks/HealthChecks/src/HealthCheckService.cs new file mode 100644 index 000000000000..e4a128148d48 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckService.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// A service which can be used to check the status of instances + /// registered in the application. + /// + /// + /// + /// The default implementation of is registered in the dependency + /// injection container as a singleton service by calling + /// . + /// + /// + /// The returned by + /// + /// provides a convenience API for registering health checks. + /// + /// + /// implementations can be registered through extension methods provided by + /// . + /// + /// + public abstract class HealthCheckService + { + /// + /// Runs all the health checks in the application and returns the aggregated status. + /// + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + public Task CheckHealthAsync(CancellationToken cancellationToken = default) + { + return CheckHealthAsync(predicate: null, cancellationToken); + } + + /// + /// Runs the provided health checks and returns the aggregated status + /// + /// + /// A predicate that can be used to include health checks based on user-defined criteria. + /// + /// A which can be used to cancel the health checks. + /// + /// A which will complete when all the health checks have been run, + /// yielding a containing the results. + /// + public abstract Task CheckHealthAsync( + Func predicate, + CancellationToken cancellationToken = default); + } +} diff --git a/src/HealthChecks/HealthChecks/src/HealthCheckServiceOptions.cs b/src/HealthChecks/HealthChecks/src/HealthCheckServiceOptions.cs new file mode 100644 index 000000000000..b8dfdb9b40e0 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/HealthCheckServiceOptions.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Options for the default implementation of + /// + public sealed class HealthCheckServiceOptions + { + /// + /// Gets the health check registrations. + /// + public ICollection Registrations { get; } = new List(); + } +} diff --git a/src/HealthChecks/HealthChecks/src/Microsoft.Extensions.Diagnostics.HealthChecks.csproj b/src/HealthChecks/HealthChecks/src/Microsoft.Extensions.Diagnostics.HealthChecks.csproj new file mode 100644 index 000000000000..35c789a691f2 --- /dev/null +++ b/src/HealthChecks/HealthChecks/src/Microsoft.Extensions.Diagnostics.HealthChecks.csproj @@ -0,0 +1,33 @@ + + + Components for performing health checks in .NET applications + +Commonly Used Types: +Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckService +Microsoft.Extensions.Diagnostics.HealthChecks.IHealthChecksBuilder + + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(DefaultNetCoreTargetFramework) + $(NoWarn);CS1591 + true + diagnostics;healthchecks + true + true + + + + + + + + + + + + + + + + + + diff --git a/src/HealthChecks/HealthChecks/test/DefaultHealthCheckServiceTest.cs b/src/HealthChecks/HealthChecks/test/DefaultHealthCheckServiceTest.cs new file mode 100644 index 000000000000..50cf7ebeaeae --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/DefaultHealthCheckServiceTest.cs @@ -0,0 +1,534 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class DefaultHealthCheckServiceTest + { + [Fact] + public void Constructor_ThrowsUsefulExceptionForDuplicateNames() + { + // Arrange + // + // Doing this the old fashioned way so we can verify that the exception comes + // from the constructor. + var serviceCollection = new ServiceCollection(); + serviceCollection.AddLogging(); + serviceCollection.AddOptions(); + serviceCollection.AddHealthChecks() + .AddCheck("Foo", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Foo", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Bar", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Baz", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))) + .AddCheck("Baz", new DelegateHealthCheck(_ => Task.FromResult(HealthCheckResult.Healthy()))); + + var services = serviceCollection.BuildServiceProvider(); + + var scopeFactory = services.GetRequiredService(); + var options = services.GetRequiredService>(); + var logger = services.GetRequiredService>(); + + // Act + var exception = Assert.Throws(() => new DefaultHealthCheckService(scopeFactory, options, logger)); + + // Assert + Assert.StartsWith($"Duplicate health checks were registered with the name(s): Foo, Baz", exception.Message); + } + + [Fact] + public async Task CheckAsync_RunsAllChecksAndAggregatesResultsAsync() + { + const string DataKey = "Foo"; + const string DataValue = "Bar"; + const string DegradedMessage = "I'm not feeling so good"; + const string UnhealthyMessage = "Halp!"; + const string HealthyMessage = "Everything is A-OK"; + var exception = new Exception("Things are pretty bad!"); + var healthyCheckTags = new List { "healthy-check-tag" }; + var degradedCheckTags = new List { "degraded-check-tag" }; + var unhealthyCheckTags = new List { "unhealthy-check-tag" }; + + // Arrange + var data = new Dictionary() + { + { DataKey, DataValue } + }; + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data)), healthyCheckTags); + b.AddAsyncCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage)), degradedCheckTags); + b.AddAsyncCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception)), unhealthyCheckTags); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries.OrderBy(kvp => kvp.Key), + actual => + { + Assert.Equal("DegradedCheck", actual.Key); + Assert.Equal(DegradedMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Degraded, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Empty(actual.Value.Data); + Assert.Equal(actual.Value.Tags, degradedCheckTags); + }, + actual => + { + Assert.Equal("HealthyCheck", actual.Key); + Assert.Equal(HealthyMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Collection(actual.Value.Data, item => + { + Assert.Equal(DataKey, item.Key); + Assert.Equal(DataValue, item.Value); + }); + Assert.Equal(actual.Value.Tags, healthyCheckTags); + }, + actual => + { + Assert.Equal("UnhealthyCheck", actual.Key); + Assert.Equal(UnhealthyMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Unhealthy, actual.Value.Status); + Assert.Same(exception, actual.Value.Exception); + Assert.Empty(actual.Value.Data); + Assert.Equal(actual.Value.Tags, unhealthyCheckTags); + }); + } + + [Fact] + public async Task CheckAsync_RunsFilteredChecksAndAggregatesResultsAsync() + { + const string DataKey = "Foo"; + const string DataValue = "Bar"; + const string DegradedMessage = "I'm not feeling so good"; + const string UnhealthyMessage = "Halp!"; + const string HealthyMessage = "Everything is A-OK"; + var exception = new Exception("Things are pretty bad!"); + + // Arrange + var data = new Dictionary + { + { DataKey, DataValue } + }; + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("HealthyCheck", _ => Task.FromResult(HealthCheckResult.Healthy(HealthyMessage, data))); + b.AddAsyncCheck("DegradedCheck", _ => Task.FromResult(HealthCheckResult.Degraded(DegradedMessage))); + b.AddAsyncCheck("UnhealthyCheck", _ => Task.FromResult(HealthCheckResult.Unhealthy(UnhealthyMessage, exception))); + }); + + // Act + var results = await service.CheckHealthAsync(c => c.Name == "HealthyCheck"); + + // Assert + Assert.Collection(results.Entries, + actual => + { + Assert.Equal("HealthyCheck", actual.Key); + Assert.Equal(HealthyMessage, actual.Value.Description); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + Assert.Collection(actual.Value.Data, item => + { + Assert.Equal(DataKey, item.Key); + Assert.Equal(DataValue, item.Value); + }); + }); + } + + [Fact] + public async Task CheckHealthAsync_SetsRegistrationForEachCheck() + { + // Arrange + var thrownException = new InvalidOperationException("Whoops!"); + var faultedException = new InvalidOperationException("Ohnoes!"); + + var service = CreateHealthChecksService(b => + { + b.AddCheck("A"); + b.AddCheck("B"); + b.AddCheck("C"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("A", actual.Key); + Assert.Collection( + actual.Value.Data, + kvp => Assert.Equal(kvp, new KeyValuePair("name", "A"))); + }, + actual => + { + Assert.Equal("B", actual.Key); + Assert.Collection( + actual.Value.Data, + kvp => Assert.Equal(kvp, new KeyValuePair("name", "B"))); + }, + actual => + { + Assert.Equal("C", actual.Key); + Assert.Collection( + actual.Value.Data, + kvp => Assert.Equal(kvp, new KeyValuePair("name", "C"))); + }); + } + + [Fact] + public async Task CheckHealthAsync_Cancellation_CanPropagate() + { + // Arrange + var insideCheck = new TaskCompletionSource(); + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("cancels", async ct => + { + insideCheck.SetResult(null); + + await Task.Delay(10000, ct); + return HealthCheckResult.Unhealthy(); + }); + }); + + var cancel = new CancellationTokenSource(); + var task = service.CheckHealthAsync(cancel.Token); + + // After this returns we know the check has started + await insideCheck.Task; + + cancel.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync(async () => await task); + } + + [Fact] + public async Task CheckHealthAsync_ConvertsExceptionInHealthCheckToUnhealthyResultAsync() + { + // Arrange + var thrownException = new InvalidOperationException("Whoops!"); + var faultedException = new InvalidOperationException("Ohnoes!"); + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("Throws", ct => throw thrownException); + b.AddAsyncCheck("Faults", ct => Task.FromException(faultedException)); + b.AddAsyncCheck("Succeeds", ct => Task.FromResult(HealthCheckResult.Healthy())); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Throws", actual.Key); + Assert.Equal(thrownException.Message, actual.Value.Description); + Assert.Equal(HealthStatus.Unhealthy, actual.Value.Status); + Assert.Same(thrownException, actual.Value.Exception); + }, + actual => + { + Assert.Equal("Faults", actual.Key); + Assert.Equal(faultedException.Message, actual.Value.Description); + Assert.Equal(HealthStatus.Unhealthy, actual.Value.Status); + Assert.Same(faultedException, actual.Value.Exception); + }, + actual => + { + Assert.Equal("Succeeds", actual.Key); + Assert.Null(actual.Value.Description); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + Assert.Null(actual.Value.Exception); + }); + } + + [Fact] + public async Task CheckHealthAsync_SetsUpALoggerScopeForEachCheck() + { + // Arrange + var sink = new TestSink(); + var check = new DelegateHealthCheck(cancellationToken => + { + Assert.Collection(sink.Scopes, + actual => + { + Assert.Equal(actual.LoggerName, typeof(DefaultHealthCheckService).FullName); + Assert.Collection((IEnumerable>)actual.Scope, + item => + { + Assert.Equal("HealthCheckName", item.Key); + Assert.Equal("TestScope", item.Value); + }); + }); + return Task.FromResult(HealthCheckResult.Healthy()); + }); + + var loggerFactory = new TestLoggerFactory(sink, enabled: true); + var service = CreateHealthChecksService(b => + { + // Override the logger factory for testing + b.Services.AddSingleton(loggerFactory); + + b.AddCheck("TestScope", check); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection(results.Entries, actual => + { + Assert.Equal("TestScope", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnTransientService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddTransient(); + + b.AddCheck("Test"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnScopedService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddScoped(); + + b.AddCheck("Test"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_CheckCanDependOnSingletonService() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.Services.AddSingleton(); + + b.AddCheck("Test"); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("Test", actual.Key); + Assert.Equal(HealthStatus.Healthy, actual.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_ChecksAreRunInParallel() + { + // Arrange + var input1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var input2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var output2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("test1", + async () => + { + output1.SetResult(null); + await input1.Task; + return HealthCheckResult.Healthy(); + }); + b.AddAsyncCheck("test2", + async () => + { + output2.SetResult(null); + await input2.Task; + return HealthCheckResult.Healthy(); + }); + }); + + // Act + var checkHealthTask = service.CheckHealthAsync(); + await Task.WhenAll(output1.Task, output2.Task).TimeoutAfter(TimeSpan.FromSeconds(10)); + input1.SetResult(null); + input2.SetResult(null); + await checkHealthTask; + + // Assert + Assert.Collection(checkHealthTask.Result.Entries, + entry => + { + Assert.Equal("test1", entry.Key); + Assert.Equal(HealthStatus.Healthy, entry.Value.Status); + }, + entry => + { + Assert.Equal("test2", entry.Key); + Assert.Equal(HealthStatus.Healthy, entry.Value.Status); + }); + } + + [Fact] + public async Task CheckHealthAsync_TimeoutReturnsUnhealthy() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("timeout", async (ct) => + { + await Task.Delay(2000, ct); + return HealthCheckResult.Healthy(); + }, timeout: TimeSpan.FromMilliseconds(100)); + }); + + // Act + var results = await service.CheckHealthAsync(); + + // Assert + Assert.Collection( + results.Entries, + actual => + { + Assert.Equal("timeout", actual.Key); + Assert.Equal(HealthStatus.Unhealthy, actual.Value.Status); + }); + } + + [Fact] + public void CheckHealthAsync_WorksInSingleThreadedSyncContext() + { + // Arrange + var service = CreateHealthChecksService(b => + { + b.AddAsyncCheck("test", async () => + { + await Task.Delay(1).ConfigureAwait(false); + return HealthCheckResult.Healthy(); + }); + }); + + var hangs = true; + + // Act + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3))) + { + var token = cts.Token; + token.Register(() => throw new OperationCanceledException(token)); + + SingleThreadedSynchronizationContext.Run(() => + { + // Act + service.CheckHealthAsync(token).GetAwaiter().GetResult(); + hangs = false; + }); + } + + // Assert + Assert.False(hangs); + } + + private static DefaultHealthCheckService CreateHealthChecksService(Action configure) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + + var builder = services.AddHealthChecks(); + if (configure != null) + { + configure(builder); + } + + return (DefaultHealthCheckService)services.BuildServiceProvider(validateScopes: true).GetRequiredService(); + } + + private class AnotherService { } + + private class CheckWithServiceDependency : IHealthCheck + { + public CheckWithServiceDependency(AnotherService _) + { + } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + return Task.FromResult(HealthCheckResult.Healthy()); + } + } + + private class NameCapturingCheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var data = new Dictionary() + { + { "name", context.Registration.Name }, + }; + return Task.FromResult(HealthCheckResult.Healthy(data: data)); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/DependencyInjection/HealthChecksBuilderTest.cs b/src/HealthChecks/HealthChecks/test/DependencyInjection/HealthChecksBuilderTest.cs new file mode 100644 index 000000000000..4235f152a289 --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/DependencyInjection/HealthChecksBuilderTest.cs @@ -0,0 +1,257 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + // Integration tests for extension methods on IHealthCheckBuilder + // + // We test the longest overload of each 'family' of Add...Check methods, since they chain to each other. + public class HealthChecksBuilderTest + { + [Fact] + public void AddCheck_Instance() + { + // Arrange + var instance = new DelegateHealthCheck((_) => + { + return Task.FromResult(HealthCheckResult.Healthy()); + }); + + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", failureStatus: HealthStatus.Degraded,tags: new[] { "tag", }, instance: instance); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.Same(instance, registration.Factory(serviceProvider)); + } + + [Fact] + public void AddCheck_T_TypeActivated() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", failureStatus: HealthStatus.Degraded, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddCheck_T_Service() + { + // Arrange + var instance = new TestHealthCheck(); + + var services = CreateServices(); + services.AddSingleton(instance); + services.AddHealthChecks().AddCheck("test", failureStatus: HealthStatus.Degraded, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.Same(instance, registration.Factory(serviceProvider)); + } + + [Fact] + public void AddTypeActivatedCheck() + { + // Arrange + var services = CreateServices(); + services + .AddHealthChecks() + .AddTypeActivatedCheck("test", failureStatus: HealthStatus.Degraded, tags: new[] { "tag", }, args: new object[] { 5, "hi", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Degraded, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + + var check = Assert.IsType(registration.Factory(serviceProvider)); + Assert.Equal(5, check.I); + Assert.Equal("hi", check.S); + } + + [Fact] + public void AddDelegateCheck_NoArg() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", tags: new[] { "tag", }, check: () => + { + return HealthCheckResult.Healthy(); + }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddDelegateCheck_CancellationToken() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddCheck("test", (_) => + { + return HealthCheckResult.Degraded(); + }, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddAsyncDelegateCheck_NoArg() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddAsyncCheck("test", () => + { + return Task.FromResult(HealthCheckResult.Healthy()); + }, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void AddAsyncDelegateCheck_CancellationToken() + { + // Arrange + var services = CreateServices(); + services.AddHealthChecks().AddAsyncCheck("test", (_) => + { + return Task.FromResult(HealthCheckResult.Unhealthy()); + }, tags: new[] { "tag", }); + + var serviceProvider = services.BuildServiceProvider(); + + // Act + var options = serviceProvider.GetRequiredService>().Value; + + // Assert + var registration = Assert.Single(options.Registrations); + Assert.Equal("test", registration.Name); + Assert.Equal(HealthStatus.Unhealthy, registration.FailureStatus); + Assert.Equal(new[] { "tag", }, registration.Tags); + Assert.IsType(registration.Factory(serviceProvider)); + } + + [Fact] + public void ChecksCanBeRegisteredInMultipleCallsToAddHealthChecks() + { + var services = new ServiceCollection(); + services + .AddHealthChecks() + .AddAsyncCheck("Foo", () => Task.FromResult(HealthCheckResult.Healthy())); + services + .AddHealthChecks() + .AddAsyncCheck("Bar", () => Task.FromResult(HealthCheckResult.Healthy())); + + // Act + var options = services.BuildServiceProvider().GetRequiredService>(); + + // Assert + Assert.Collection( + options.Value.Registrations, + actual => Assert.Equal("Foo", actual.Name), + actual => Assert.Equal("Bar", actual.Name)); + } + + private IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOptions(); + return services; + } + + private class TestHealthCheck : IHealthCheck + { + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + } + + private class TestHealthCheckWithArgs : IHealthCheck + { + public TestHealthCheckWithArgs(int i, string s) + { + I = i; + S = s; + } + + public int I { get; set; } + + public string S { get; set; } + + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + throw new System.NotImplementedException(); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/DependencyInjection/ServiceCollectionExtensionsTest.cs b/src/HealthChecks/HealthChecks/test/DependencyInjection/ServiceCollectionExtensionsTest.cs new file mode 100644 index 000000000000..4a191a365f6c --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/DependencyInjection/ServiceCollectionExtensionsTest.cs @@ -0,0 +1,96 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + public class ServiceCollectionExtensionsTest + { + [Fact] + public void AddHealthChecks_RegistersSingletonHealthCheckServiceIdempotently() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddHealthChecks(); + services.AddHealthChecks(); + + // Assert + Assert.Collection(services.OrderBy(s => s.ServiceType.FullName), + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(HealthCheckService), actual.ServiceType); + Assert.Equal(typeof(DefaultHealthCheckService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }, + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(IHostedService), actual.ServiceType); + Assert.Equal(typeof(HealthCheckPublisherHostedService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }); + } + + [Fact] // see: https://github.com/dotnet/extensions/issues/639 + public void AddHealthChecks_RegistersPublisherService_WhenOtherHostedServicesRegistered() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddSingleton(); + services.AddHealthChecks(); + + // Assert + Assert.Collection(services.OrderBy(s => s.ServiceType.FullName).ThenBy(s => s.ImplementationType.FullName), + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(HealthCheckService), actual.ServiceType); + Assert.Equal(typeof(DefaultHealthCheckService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }, + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(IHostedService), actual.ServiceType); + Assert.Equal(typeof(DummyHostedService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }, + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(IHostedService), actual.ServiceType); + Assert.Equal(typeof(HealthCheckPublisherHostedService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); + }); + } + + private class DummyHostedService : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + throw new System.NotImplementedException(); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/HealthCheckPublisherHostedServiceTest.cs b/src/HealthChecks/HealthChecks/test/HealthCheckPublisherHostedServiceTest.cs new file mode 100644 index 000000000000..099944a47330 --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/HealthCheckPublisherHostedServiceTest.cs @@ -0,0 +1,528 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class HealthCheckPublisherHostedServiceTest + { + [Fact] + public async Task StartAsync_WithoutPublishers_DoesNotStartTimer() + { + // Arrange + var publishers = new IHealthCheckPublisher[] + { + }; + + var service = CreateService(publishers); + + try + { + // Act + await service.StartAsync(); + + // Assert + Assert.False(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StartAsync_WithPublishers_StartsTimer() + { + // Arrange + var publishers = new IHealthCheckPublisher[] + { + new TestPublisher(), + }; + + var service = CreateService(publishers); + + try + { + // Act + await service.StartAsync(); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StartAsync_WithPublishers_StartsTimer_RunsPublishers() + { + // Arrange + var unblock0 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock0.Task, }, + new TestPublisher() { Wait = unblock1.Task, }, + new TestPublisher() { Wait = unblock2.Task, }, + }; + + var service = CreateService(publishers, configure: (options) => + { + options.Delay = TimeSpan.FromMilliseconds(0); + }); + + try + { + // Act + await service.StartAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[1].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[2].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock0.SetResult(null); + unblock1.SetResult(null); + unblock2.SetResult(null); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StopAsync_CancelsExecution() + { + // Arrange + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, } + }; + + var service = CreateService(publishers); + + try + { + await service.StartAsync(); + + // Start execution + var running = service.RunAsync(); + + // Wait for the publisher to see the cancellation token + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + Assert.Single(publishers[0].Entries); + + // Act + await service.StopAsync(); // Trigger cancellation + + // Assert + await AssertCancelledAsync(publishers[0].Entries[0].cancellationToken); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_WaitsForCompletion_Single() + { + // Arrange + var sink = new TestSink(); + + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", "two", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Contains(entry.EventId, new[] { DefaultHealthCheckService.EventIds.HealthCheckBegin, DefaultHealthCheckService.EventIds.HealthCheckEnd }); }, + entry => { Assert.Contains(entry.EventId, new[] { DefaultHealthCheckService.EventIds.HealthCheckBegin, DefaultHealthCheckService.EventIds.HealthCheckEnd }); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + // Not testing logs here to avoid differences in logging order + [Fact] + public async Task RunAsync_WaitsForCompletion_Multiple() + { + // Arrange + var unblock0 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock0.Task, }, + new TestPublisher() { Wait = unblock1.Task, }, + new TestPublisher() { Wait = unblock2.Task, }, + }; + + var service = CreateService(publishers); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[1].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[2].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock0.SetResult(null); + unblock1.SetResult(null); + unblock2.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", "two", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_PublishersCanTimeout() + { + // Arrange + var sink = new TestSink(); + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, }, + }; + + var service = CreateService(publishers, sink: sink, configure: (options) => + { + options.Timeout = TimeSpan.FromMilliseconds(50); + }); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + await AssertCancelledAsync(publishers[0].Entries[0].cancellationToken); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Contains(entry.EventId, new[] { DefaultHealthCheckService.EventIds.HealthCheckBegin, DefaultHealthCheckService.EventIds.HealthCheckEnd }); }, + entry => { Assert.Contains(entry.EventId, new[] { DefaultHealthCheckService.EventIds.HealthCheckBegin, DefaultHealthCheckService.EventIds.HealthCheckEnd }); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherTimeout, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + [Fact] + public async Task RunAsync_CanFilterHealthChecks() + { + // Arrange + var publishers = new TestPublisher[] + { + new TestPublisher(), + new TestPublisher(), + }; + + var service = CreateService(publishers, configure: (options) => + { + options.Predicate = (r) => r.Name == "one"; + }); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_HandlesExceptions() + { + // Arrange + var sink = new TestSink(); + var publishers = new TestPublisher[] + { + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Contains(entry.EventId, new[] { DefaultHealthCheckService.EventIds.HealthCheckBegin, DefaultHealthCheckService.EventIds.HealthCheckEnd }); }, + entry => { Assert.Contains(entry.EventId, new[] { DefaultHealthCheckService.EventIds.HealthCheckBegin, DefaultHealthCheckService.EventIds.HealthCheckEnd }); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherError, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + // Not testing logging here to avoid flaky ordering issues + [Fact] + public async Task RunAsync_HandlesExceptions_Multiple() + { + // Arrange + var sink = new TestSink(); + var publishers = new TestPublisher[] + { + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + new TestPublisher(), + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + private HealthCheckPublisherHostedService CreateService( + IHealthCheckPublisher[] publishers, + Action configure = null, + TestSink sink = null) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddOptions(); + serviceCollection.AddLogging(); + serviceCollection.AddHealthChecks() + .AddCheck("one", () => { return HealthCheckResult.Healthy(); }) + .AddCheck("two", () => { return HealthCheckResult.Healthy(); }); + + // Choosing big values for tests to make sure that we're not dependent on the defaults. + // All of the tests that rely on the timer will set their own values for speed. + serviceCollection.Configure(options => + { + options.Delay = TimeSpan.FromMinutes(5); + options.Period = TimeSpan.FromMinutes(5); + options.Timeout = TimeSpan.FromMinutes(5); + }); + + if (publishers != null) + { + for (var i = 0; i < publishers.Length; i++) + { + serviceCollection.AddSingleton(publishers[i]); + } + } + + if (configure != null) + { + serviceCollection.Configure(configure); + } + + if (sink != null) + { + serviceCollection.AddSingleton(new TestLoggerFactory(sink, enabled: true)); + } + + var services = serviceCollection.BuildServiceProvider(); + return services.GetServices().OfType< HealthCheckPublisherHostedService>().Single(); + } + + private static async Task AssertCancelledAsync(CancellationToken cancellationToken) + { + await Assert.ThrowsAsync(() => Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)); + } + + private class TestPublisher : IHealthCheckPublisher + { + private TaskCompletionSource _started; + + public TestPublisher() + { + _started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public List<(HealthReport report, CancellationToken cancellationToken)> Entries { get; } = new List<(HealthReport report, CancellationToken cancellationToken)>(); + + public Exception Exception { get; set; } + + public Task Started => _started.Task; + + public Task Wait { get; set; } + + public async Task PublishAsync(HealthReport report, CancellationToken cancellationToken) + { + Entries.Add((report, cancellationToken)); + + // Signal that we've started + _started.SetResult(null); + + if (Wait != null) + { + await Wait; + } + + if (Exception != null) + { + throw Exception; + } + + cancellationToken.ThrowIfCancellationRequested(); + } + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/HealthReportTest.cs b/src/HealthChecks/HealthChecks/test/HealthReportTest.cs new file mode 100644 index 000000000000..07f8e5a8e372 --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/HealthReportTest.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class HealthReportTest + { + [Theory] + [InlineData(HealthStatus.Healthy)] + [InlineData(HealthStatus.Degraded)] + [InlineData(HealthStatus.Unhealthy)] + public void Status_MatchesWorstStatusInResults(HealthStatus status) + { + var result = new HealthReport(new Dictionary() + { + {"Foo", new HealthReportEntry(HealthStatus.Healthy, null,TimeSpan.MinValue, null, null) }, + {"Bar", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue,null, null) }, + {"Baz", new HealthReportEntry(status, exception: null, description: null,duration:TimeSpan.MinValue, data: null) }, + {"Quick", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue, null, null) }, + {"Quack", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue, null, null) }, + {"Quock", new HealthReportEntry(HealthStatus.Healthy, null, TimeSpan.MinValue, null, null) }, + }, totalDuration: TimeSpan.MinValue); + + Assert.Equal(status, result.Status); + } + + [Theory] + [InlineData(200)] + [InlineData(300)] + [InlineData(400)] + public void TotalDuration_MatchesTotalDurationParameter(int milliseconds) + { + var result = new HealthReport(new Dictionary() + { + {"Foo", new HealthReportEntry(HealthStatus.Healthy, null,TimeSpan.MinValue, null, null) } + }, totalDuration: TimeSpan.FromMilliseconds(milliseconds)); + + Assert.Equal(TimeSpan.FromMilliseconds(milliseconds), result.TotalDuration); + } + } +} diff --git a/src/HealthChecks/HealthChecks/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj b/src/HealthChecks/HealthChecks/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj new file mode 100644 index 000000000000..1591585b826e --- /dev/null +++ b/src/HealthChecks/HealthChecks/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests.csproj @@ -0,0 +1,27 @@ + + + + + + $(DefaultNetCoreTargetFramework);net472 + Microsoft.Extensions.Diagnostics.HealthChecks + + + + + + + + + + + + + + + + + + + + diff --git a/src/JSInterop/JSInterop.slnf b/src/JSInterop/JSInterop.slnf new file mode 100644 index 000000000000..faae0051a523 --- /dev/null +++ b/src/JSInterop/JSInterop.slnf @@ -0,0 +1,9 @@ +{ + "solution": { + "path": "..\\..\\Extensions.sln", + "projects": [ + "src\\JSInterop\\Microsoft.JSInterop\\src\\Microsoft.JSInterop.csproj", + "src\\JSInterop\\Microsoft.JSInterop\\test\\Microsoft.JSInterop.Tests.csproj" + ] + } +} diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/Microsoft.JSInterop.JS.npmproj b/src/JSInterop/Microsoft.JSInterop.JS/src/Microsoft.JSInterop.JS.npmproj new file mode 100644 index 000000000000..589b7e1c5a7b --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/Microsoft.JSInterop.JS.npmproj @@ -0,0 +1,12 @@ + + + + + @microsoft/dotnet-js-interop + true + false + true + + + + diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/package-lock.json b/src/JSInterop/Microsoft.JSInterop.JS/src/package-lock.json new file mode 100644 index 000000000000..d2d9c46b2ae5 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/package-lock.json @@ -0,0 +1,348 @@ +{ + "name": "@microsoft/dotnet-js-interop", + "version": "5.0.0-dev", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + }, + "dependencies": { + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + } + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "commander": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "js-yaml": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.1.tgz", + "integrity": "sha512-um46hB9wNOKlwkHgiuyEVAybXBjwFUV0Z/RaHJblRd9DXltue9FTYvzCr9ErQrK9Adz5MU4gHWVaNUfdmrC8qA==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "resolve": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.10.0.tgz", + "integrity": "sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "semver": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", + "dev": true + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", + "dev": true + }, + "tslint": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.12.1.tgz", + "integrity": "sha512-sfodBHOucFg6egff8d1BvuofoOQ/nOeYNfbp7LDlKBcLNrL3lmS5zoiDGyOMdT7YsEXAwWpTdAHwOGOc8eRZAw==", + "dev": true, + "requires": { + "babel-code-frame": "^6.22.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^3.2.0", + "glob": "^7.1.1", + "js-yaml": "^3.7.0", + "minimatch": "^3.0.4", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.8.0", + "tsutils": "^2.27.2" + } + }, + "tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, + "typescript": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz", + "integrity": "sha512-Gr4p6nFNaoufRIY4NMdpQRNmgxVIGMs4Fcu/ujdYk3nAZqk7supzBE9idmvfZIlH/Cuj//dvi+019qEue9lV0w==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/package.json b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json new file mode 100644 index 000000000000..37273c492f45 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/package.json @@ -0,0 +1,31 @@ +{ + "name": "@microsoft/dotnet-js-interop", + "version": "5.0.0-dev", + "description": "Provides abstractions and features for interop between .NET and JavaScript code.", + "main": "dist/Microsoft.JSInterop.js", + "types": "dist/Microsoft.JSInterop.d.js", + "scripts": { + "clean": "node node_modules/rimraf/bin.js ./dist", + "build": "npm run clean && npm run build:esm", + "build:lint": "node node_modules/tslint/bin/tslint -p ./tsconfig.json", + "build:esm": "node node_modules/typescript/bin/tsc --project ./tsconfig.json" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dotnet/extensions.git" + }, + "author": "Microsoft", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/dotnet/extensions/issues" + }, + "homepage": "https://github.com/dotnet/extensions/tree/master/src/JSInterop#readme", + "files": [ + "dist/**" + ], + "devDependencies": { + "rimraf": "^2.5.4", + "tslint": "^5.9.1", + "typescript": "^2.7.1" + } +} diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts b/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts new file mode 100644 index 000000000000..3af355f6d016 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts @@ -0,0 +1,298 @@ +// This is a single-file self-contained module to avoid the need for a Webpack build + +module DotNet { + (window as any).DotNet = DotNet; // Ensure reachable from anywhere + + export type JsonReviver = ((key: any, value: any) => any); + const jsonRevivers: JsonReviver[] = []; + + const pendingAsyncCalls: { [id: number]: PendingAsyncCall } = {}; + const cachedJSFunctions: { [identifier: string]: Function } = {}; + let nextAsyncCallId = 1; // Start at 1 because zero signals "no response needed" + + let dotNetDispatcher: DotNetCallDispatcher | null = null; + + /** + * Sets the specified .NET call dispatcher as the current instance so that it will be used + * for future invocations. + * + * @param dispatcher An object that can dispatch calls from JavaScript to a .NET runtime. + */ + export function attachDispatcher(dispatcher: DotNetCallDispatcher) { + dotNetDispatcher = dispatcher; + } + + /** + * Adds a JSON reviver callback that will be used when parsing arguments received from .NET. + * @param reviver The reviver to add. + */ + export function attachReviver(reviver: JsonReviver) { + jsonRevivers.push(reviver); + } + + /** + * Invokes the specified .NET public method synchronously. Not all hosting scenarios support + * synchronous invocation, so if possible use invokeMethodAsync instead. + * + * @param assemblyName The short name (without key/version or .dll extension) of the .NET assembly containing the method. + * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier. + * @param args Arguments to pass to the method, each of which must be JSON-serializable. + * @returns The result of the operation. + */ + export function invokeMethod(assemblyName: string, methodIdentifier: string, ...args: any[]): T { + return invokePossibleInstanceMethod(assemblyName, methodIdentifier, null, args); + } + + /** + * Invokes the specified .NET public method asynchronously. + * + * @param assemblyName The short name (without key/version or .dll extension) of the .NET assembly containing the method. + * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier. + * @param args Arguments to pass to the method, each of which must be JSON-serializable. + * @returns A promise representing the result of the operation. + */ + export function invokeMethodAsync(assemblyName: string, methodIdentifier: string, ...args: any[]): Promise { + return invokePossibleInstanceMethodAsync(assemblyName, methodIdentifier, null, args); + } + + function invokePossibleInstanceMethod(assemblyName: string | null, methodIdentifier: string, dotNetObjectId: number | null, args: any[] | null): T { + const dispatcher = getRequiredDispatcher(); + if (dispatcher.invokeDotNetFromJS) { + const argsJson = JSON.stringify(args, argReplacer); + const resultJson = dispatcher.invokeDotNetFromJS(assemblyName, methodIdentifier, dotNetObjectId, argsJson); + return resultJson ? parseJsonWithRevivers(resultJson) : null; + } else { + throw new Error('The current dispatcher does not support synchronous calls from JS to .NET. Use invokeMethodAsync instead.'); + } + } + + function invokePossibleInstanceMethodAsync(assemblyName: string | null, methodIdentifier: string, dotNetObjectId: number | null, args: any[] | null): Promise { + if (assemblyName && dotNetObjectId) { + throw new Error(`For instance method calls, assemblyName should be null. Received '${assemblyName}'.`) ; + } + + const asyncCallId = nextAsyncCallId++; + const resultPromise = new Promise((resolve, reject) => { + pendingAsyncCalls[asyncCallId] = { resolve, reject }; + }); + + try { + const argsJson = JSON.stringify(args, argReplacer); + getRequiredDispatcher().beginInvokeDotNetFromJS(asyncCallId, assemblyName, methodIdentifier, dotNetObjectId, argsJson); + } catch (ex) { + // Synchronous failure + completePendingCall(asyncCallId, false, ex); + } + + return resultPromise; + } + + function getRequiredDispatcher(): DotNetCallDispatcher { + if (dotNetDispatcher !== null) { + return dotNetDispatcher; + } + + throw new Error('No .NET call dispatcher has been set.'); + } + + function completePendingCall(asyncCallId: number, success: boolean, resultOrError: any) { + if (!pendingAsyncCalls.hasOwnProperty(asyncCallId)) { + throw new Error(`There is no pending async call with ID ${asyncCallId}.`); + } + + const asyncCall = pendingAsyncCalls[asyncCallId]; + delete pendingAsyncCalls[asyncCallId]; + if (success) { + asyncCall.resolve(resultOrError); + } else { + asyncCall.reject(resultOrError); + } + } + + interface PendingAsyncCall { + resolve: (value?: T | PromiseLike) => void; + reject: (reason?: any) => void; + } + + /** + * Represents the ability to dispatch calls from JavaScript to a .NET runtime. + */ + export interface DotNetCallDispatcher { + /** + * Optional. If implemented, invoked by the runtime to perform a synchronous call to a .NET method. + * + * @param assemblyName The short name (without key/version or .dll extension) of the .NET assembly holding the method to invoke. The value may be null when invoking instance methods. + * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier. + * @param dotNetObjectId If given, the call will be to an instance method on the specified DotNetObject. Pass null or undefined to call static methods. + * @param argsJson JSON representation of arguments to pass to the method. + * @returns JSON representation of the result of the invocation. + */ + invokeDotNetFromJS?(assemblyName: string | null, methodIdentifier: string, dotNetObjectId: number | null, argsJson: string): string | null; + + /** + * Invoked by the runtime to begin an asynchronous call to a .NET method. + * + * @param callId A value identifying the asynchronous operation. This value should be passed back in a later call from .NET to JS. + * @param assemblyName The short name (without key/version or .dll extension) of the .NET assembly holding the method to invoke. The value may be null when invoking instance methods. + * @param methodIdentifier The identifier of the method to invoke. The method must have a [JSInvokable] attribute specifying this identifier. + * @param dotNetObjectId If given, the call will be to an instance method on the specified DotNetObject. Pass null to call static methods. + * @param argsJson JSON representation of arguments to pass to the method. + */ + beginInvokeDotNetFromJS(callId: number, assemblyName: string | null, methodIdentifier: string, dotNetObjectId: number | null, argsJson: string): void; + + /** + * Invoked by the runtime to complete an asynchronous JavaScript function call started from .NET + * + * @param callId A value identifying the asynchronous operation. + * @param succeded Whether the operation succeeded or not. + * @param resultOrError The serialized result or the serialized error from the async operation. + */ + endInvokeJSFromDotNet(callId: number, succeeded: boolean, resultOrError: any): void; + } + + /** + * Receives incoming calls from .NET and dispatches them to JavaScript. + */ + export const jsCallDispatcher = { + /** + * Finds the JavaScript function matching the specified identifier. + * + * @param identifier Identifies the globally-reachable function to be returned. + * @returns A Function instance. + */ + findJSFunction, // Note that this is used by the JS interop code inside Mono WebAssembly itself + + /** + * Invokes the specified synchronous JavaScript function. + * + * @param identifier Identifies the globally-reachable function to invoke. + * @param argsJson JSON representation of arguments to be passed to the function. + * @returns JSON representation of the invocation result. + */ + invokeJSFromDotNet: (identifier: string, argsJson: string) => { + const result = findJSFunction(identifier).apply(null, parseJsonWithRevivers(argsJson)); + return result === null || result === undefined + ? null + : JSON.stringify(result, argReplacer); + }, + + /** + * Invokes the specified synchronous or asynchronous JavaScript function. + * + * @param asyncHandle A value identifying the asynchronous operation. This value will be passed back in a later call to endInvokeJSFromDotNet. + * @param identifier Identifies the globally-reachable function to invoke. + * @param argsJson JSON representation of arguments to be passed to the function. + */ + beginInvokeJSFromDotNet: (asyncHandle: number, identifier: string, argsJson: string): void => { + // Coerce synchronous functions into async ones, plus treat + // synchronous exceptions the same as async ones + const promise = new Promise(resolve => { + const synchronousResultOrPromise = findJSFunction(identifier).apply(null, parseJsonWithRevivers(argsJson)); + resolve(synchronousResultOrPromise); + }); + + // We only listen for a result if the caller wants to be notified about it + if (asyncHandle) { + // On completion, dispatch result back to .NET + // Not using "await" because it codegens a lot of boilerplate + promise.then( + result => getRequiredDispatcher().endInvokeJSFromDotNet(asyncHandle, true, JSON.stringify([asyncHandle, true, result], argReplacer)), + error => getRequiredDispatcher().endInvokeJSFromDotNet(asyncHandle, false, JSON.stringify([asyncHandle, false, formatError(error)])) + ); + } + }, + + /** + * Receives notification that an async call from JS to .NET has completed. + * @param asyncCallId The identifier supplied in an earlier call to beginInvokeDotNetFromJS. + * @param success A flag to indicate whether the operation completed successfully. + * @param resultOrExceptionMessage Either the operation result or an error message. + */ + endInvokeDotNetFromJS: (asyncCallId: string, success: boolean, resultOrExceptionMessage: any): void => { + const resultOrError = success ? resultOrExceptionMessage : new Error(resultOrExceptionMessage); + completePendingCall(parseInt(asyncCallId), success, resultOrError); + } + } + + function parseJsonWithRevivers(json: string): any { + return json ? JSON.parse(json, (key, initialValue) => { + // Invoke each reviver in order, passing the output from the previous reviver, + // so that each one gets a chance to transform the value + return jsonRevivers.reduce( + (latestValue, reviver) => reviver(key, latestValue), + initialValue + ); + }) : null; + } + + function formatError(error: any): string { + if (error instanceof Error) { + return `${error.message}\n${error.stack}`; + } else { + return error ? error.toString() : 'null'; + } + } + + function findJSFunction(identifier: string): Function { + if (cachedJSFunctions.hasOwnProperty(identifier)) { + return cachedJSFunctions[identifier]; + } + + let result: any = window; + let resultIdentifier = 'window'; + let lastSegmentValue: any; + identifier.split('.').forEach(segment => { + if (segment in result) { + lastSegmentValue = result; + result = result[segment]; + resultIdentifier += '.' + segment; + } else { + throw new Error(`Could not find '${segment}' in '${resultIdentifier}'.`); + } + }); + + if (result instanceof Function) { + result = result.bind(lastSegmentValue); + cachedJSFunctions[identifier] = result; + return result; + } else { + throw new Error(`The value '${resultIdentifier}' is not a function.`); + } + } + + class DotNetObject { + constructor(private _id: number) { + } + + public invokeMethod(methodIdentifier: string, ...args: any[]): T { + return invokePossibleInstanceMethod(null, methodIdentifier, this._id, args); + } + + public invokeMethodAsync(methodIdentifier: string, ...args: any[]): Promise { + return invokePossibleInstanceMethodAsync(null, methodIdentifier, this._id, args); + } + + public dispose() { + const promise = invokePossibleInstanceMethodAsync(null, '__Dispose', this._id, null); + promise.catch(error => console.error(error)); + } + + public serializeAsArg() { + return { __dotNetObject: this._id }; + } + } + + const dotNetObjectRefKey = '__dotNetObject'; + attachReviver(function reviveDotNetObject(key: any, value: any) { + if (value && typeof value === 'object' && value.hasOwnProperty(dotNetObjectRefKey)) { + return new DotNetObject(value.__dotNetObject); + } + + // Unrecognized - let another reviver handle it + return value; + }); + + function argReplacer(key: string, value: any) { + return value instanceof DotNetObject ? value.serializeAsArg() : value; + } +} diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/tsconfig.json b/src/JSInterop/Microsoft.JSInterop.JS/src/tsconfig.json new file mode 100644 index 000000000000..f5a2b0e31a92 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "noEmitOnError": true, + "removeComments": false, + "sourceMap": true, + "target": "es5", + "lib": ["es2015", "dom", "es2015.promise"], + "strict": true, + "declaration": true, + "outDir": "dist" + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "dist/**" + ] +} diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/tslint.json b/src/JSInterop/Microsoft.JSInterop.JS/src/tslint.json new file mode 100644 index 000000000000..5c38bef9900e --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/tslint.json @@ -0,0 +1,14 @@ +{ + "extends": "tslint:recommended", + "rules": { + "max-line-length": { "options": [300] }, + "member-ordering": false, + "interface-name": false, + "unified-signatures": false, + "max-classes-per-file": false, + "no-floating-promises": true, + "no-empty": false, + "no-bitwise": false, + "no-console": false + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.csproj b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.csproj new file mode 100644 index 000000000000..ceb24636ab4d --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + diff --git a/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netcoreapp.cs b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netcoreapp.cs new file mode 100644 index 000000000000..3abe22eea07e --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netcoreapp.cs @@ -0,0 +1,102 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.JSInterop +{ + public static partial class DotNetObjectReference + { + public static Microsoft.JSInterop.DotNetObjectReference Create(TValue value) where TValue : class { throw null; } + } + public sealed partial class DotNetObjectReference : System.IDisposable where TValue : class + { + internal DotNetObjectReference() { } + public TValue Value { get { throw null; } } + public void Dispose() { } + } + public partial interface IJSInProcessRuntime : Microsoft.JSInterop.IJSRuntime + { + T Invoke(string identifier, params object[] args); + } + public partial interface IJSRuntime + { + System.Threading.Tasks.ValueTask InvokeAsync(string identifier, object[] args); + System.Threading.Tasks.ValueTask InvokeAsync(string identifier, System.Threading.CancellationToken cancellationToken, object[] args); + } + public partial class JSException : System.Exception + { + public JSException(string message) { } + public JSException(string message, System.Exception innerException) { } + } + public abstract partial class JSInProcessRuntime : Microsoft.JSInterop.JSRuntime, Microsoft.JSInterop.IJSInProcessRuntime, Microsoft.JSInterop.IJSRuntime + { + protected JSInProcessRuntime() { } + protected abstract string InvokeJS(string identifier, string argsJson); + public TValue Invoke(string identifier, params object[] args) { throw null; } + } + public static partial class JSInProcessRuntimeExtensions + { + public static void InvokeVoid(this Microsoft.JSInterop.IJSInProcessRuntime jsRuntime, string identifier, params object[] args) { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=true)] + public sealed partial class JSInvokableAttribute : System.Attribute + { + public JSInvokableAttribute() { } + public JSInvokableAttribute(string identifier) { } + public string Identifier { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public abstract partial class JSRuntime : Microsoft.JSInterop.IJSRuntime + { + protected JSRuntime() { } + protected System.TimeSpan? DefaultAsyncTimeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected internal System.Text.Json.JsonSerializerOptions JsonSerializerOptions { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + protected abstract void BeginInvokeJS(long taskId, string identifier, string argsJson); + protected internal abstract void EndInvokeDotNet(Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo invocationInfo, in Microsoft.JSInterop.Infrastructure.DotNetInvocationResult invocationResult); + public System.Threading.Tasks.ValueTask InvokeAsync(string identifier, object[] args) { throw null; } + public System.Threading.Tasks.ValueTask InvokeAsync(string identifier, System.Threading.CancellationToken cancellationToken, object[] args) { throw null; } + } + public static partial class JSRuntimeExtensions + { + public static System.Threading.Tasks.ValueTask InvokeAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, params object[] args) { throw null; } + public static System.Threading.Tasks.ValueTask InvokeAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.Threading.CancellationToken cancellationToken, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.TimeSpan timeout, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.Threading.CancellationToken cancellationToken, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.TimeSpan timeout, params object[] args) { throw null; } + } +} +namespace Microsoft.JSInterop.Infrastructure +{ + public static partial class DotNetDispatcher + { + public static void BeginInvokeDotNet(Microsoft.JSInterop.JSRuntime jsRuntime, Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo invocationInfo, string argsJson) { } + public static void EndInvokeJS(Microsoft.JSInterop.JSRuntime jsRuntime, string arguments) { } + public static string Invoke(Microsoft.JSInterop.JSRuntime jsRuntime, in Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo invocationInfo, string argsJson) { throw null; } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public readonly partial struct DotNetInvocationInfo + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public DotNetInvocationInfo(string assemblyName, string methodIdentifier, long dotNetObjectId, string callId) { throw null; } + public string AssemblyName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string CallId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public long DotNetObjectId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string MethodIdentifier { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public readonly partial struct DotNetInvocationResult + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public DotNetInvocationResult(System.Exception exception, string errorKind) { throw null; } + public DotNetInvocationResult(object result) { throw null; } + public string ErrorKind { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public object Result { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public bool Success { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netstandard2.0.cs b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netstandard2.0.cs new file mode 100644 index 000000000000..3abe22eea07e --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/ref/Microsoft.JSInterop.netstandard2.0.cs @@ -0,0 +1,102 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.JSInterop +{ + public static partial class DotNetObjectReference + { + public static Microsoft.JSInterop.DotNetObjectReference Create(TValue value) where TValue : class { throw null; } + } + public sealed partial class DotNetObjectReference : System.IDisposable where TValue : class + { + internal DotNetObjectReference() { } + public TValue Value { get { throw null; } } + public void Dispose() { } + } + public partial interface IJSInProcessRuntime : Microsoft.JSInterop.IJSRuntime + { + T Invoke(string identifier, params object[] args); + } + public partial interface IJSRuntime + { + System.Threading.Tasks.ValueTask InvokeAsync(string identifier, object[] args); + System.Threading.Tasks.ValueTask InvokeAsync(string identifier, System.Threading.CancellationToken cancellationToken, object[] args); + } + public partial class JSException : System.Exception + { + public JSException(string message) { } + public JSException(string message, System.Exception innerException) { } + } + public abstract partial class JSInProcessRuntime : Microsoft.JSInterop.JSRuntime, Microsoft.JSInterop.IJSInProcessRuntime, Microsoft.JSInterop.IJSRuntime + { + protected JSInProcessRuntime() { } + protected abstract string InvokeJS(string identifier, string argsJson); + public TValue Invoke(string identifier, params object[] args) { throw null; } + } + public static partial class JSInProcessRuntimeExtensions + { + public static void InvokeVoid(this Microsoft.JSInterop.IJSInProcessRuntime jsRuntime, string identifier, params object[] args) { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=true)] + public sealed partial class JSInvokableAttribute : System.Attribute + { + public JSInvokableAttribute() { } + public JSInvokableAttribute(string identifier) { } + public string Identifier { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public abstract partial class JSRuntime : Microsoft.JSInterop.IJSRuntime + { + protected JSRuntime() { } + protected System.TimeSpan? DefaultAsyncTimeout { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + protected internal System.Text.Json.JsonSerializerOptions JsonSerializerOptions { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + protected abstract void BeginInvokeJS(long taskId, string identifier, string argsJson); + protected internal abstract void EndInvokeDotNet(Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo invocationInfo, in Microsoft.JSInterop.Infrastructure.DotNetInvocationResult invocationResult); + public System.Threading.Tasks.ValueTask InvokeAsync(string identifier, object[] args) { throw null; } + public System.Threading.Tasks.ValueTask InvokeAsync(string identifier, System.Threading.CancellationToken cancellationToken, object[] args) { throw null; } + } + public static partial class JSRuntimeExtensions + { + public static System.Threading.Tasks.ValueTask InvokeAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, params object[] args) { throw null; } + public static System.Threading.Tasks.ValueTask InvokeAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.Threading.CancellationToken cancellationToken, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.TimeSpan timeout, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.Threading.CancellationToken cancellationToken, params object[] args) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.ValueTask InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime jsRuntime, string identifier, System.TimeSpan timeout, params object[] args) { throw null; } + } +} +namespace Microsoft.JSInterop.Infrastructure +{ + public static partial class DotNetDispatcher + { + public static void BeginInvokeDotNet(Microsoft.JSInterop.JSRuntime jsRuntime, Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo invocationInfo, string argsJson) { } + public static void EndInvokeJS(Microsoft.JSInterop.JSRuntime jsRuntime, string arguments) { } + public static string Invoke(Microsoft.JSInterop.JSRuntime jsRuntime, in Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo invocationInfo, string argsJson) { throw null; } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public readonly partial struct DotNetInvocationInfo + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public DotNetInvocationInfo(string assemblyName, string methodIdentifier, long dotNetObjectId, string callId) { throw null; } + public string AssemblyName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string CallId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public long DotNetObjectId { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string MethodIdentifier { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)] + public readonly partial struct DotNetInvocationResult + { + private readonly object _dummy; + private readonly int _dummyPrimitive; + public DotNetInvocationResult(System.Exception exception, string errorKind) { throw null; } + public DotNetInvocationResult(object result) { throw null; } + public string ErrorKind { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Exception Exception { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public object Result { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public bool Success { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReference.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReference.cs new file mode 100644 index 000000000000..53217936d67b --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReference.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.JSInterop +{ + /// + /// Provides convenience methods to produce a . + /// + public static class DotNetObjectReference + { + /// + /// Creates a new instance of . + /// + /// The reference type to track. + /// An instance of . + public static DotNetObjectReference Create(TValue value) where TValue : class + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + return new DotNetObjectReference(value); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReferenceOfT.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReferenceOfT.cs new file mode 100644 index 000000000000..773c2ed9a357 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetObjectReferenceOfT.cs @@ -0,0 +1,105 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using Microsoft.JSInterop.Infrastructure; + +namespace Microsoft.JSInterop +{ + /// + /// Wraps a JS interop argument, indicating that the value should not be serialized as JSON + /// but instead should be passed as a reference. + /// + /// To avoid leaking memory, the reference must later be disposed by JS code or by .NET code. + /// + /// The type of the value to wrap. + public sealed class DotNetObjectReference : IDotNetObjectReference, IDisposable where TValue : class + { + private readonly TValue _value; + private long _objectId; + private JSRuntime _jsRuntime; + + /// + /// Initializes a new instance of . + /// + /// The value to pass by reference. + internal DotNetObjectReference(TValue value) + { + _value = value; + } + + /// + /// Gets the object instance represented by this wrapper. + /// + public TValue Value + { + get + { + ThrowIfDisposed(); + return _value; + } + } + + internal long ObjectId + { + get + { + ThrowIfDisposed(); + Debug.Assert(_objectId != 0, "Accessing ObjectId without tracking is always incorrect."); + + return _objectId; + } + set + { + ThrowIfDisposed(); + _objectId = value; + } + } + + internal JSRuntime JSRuntime + { + get + { + ThrowIfDisposed(); + return _jsRuntime; + } + set + { + ThrowIfDisposed(); + _jsRuntime = value; + } + + } + + object IDotNetObjectReference.Value => Value; + + internal bool Disposed { get; private set; } + + /// + /// Stops tracking this object reference, allowing it to be garbage collected + /// (if there are no other references to it). Once the instance is disposed, it + /// can no longer be used in interop calls from JavaScript code. + /// + public void Dispose() + { + if (!Disposed) + { + Disposed = true; + + if (_jsRuntime != null) + { + _jsRuntime.ReleaseObjectReference(_objectId); + } + } + } + + internal void ThrowIfDisposed() + { + if (Disposed) + { + throw new ObjectDisposedException(GetType().Name); + } + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/IJSInProcessRuntime.cs b/src/JSInterop/Microsoft.JSInterop/src/IJSInProcessRuntime.cs new file mode 100644 index 000000000000..6da8d7f3a791 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/IJSInProcessRuntime.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.JSInterop +{ + /// + /// Represents an instance of a JavaScript runtime to which calls may be dispatched. + /// + public interface IJSInProcessRuntime : IJSRuntime + { + /// + /// Invokes the specified JavaScript function synchronously. + /// + /// The JSON-serializable return type. + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + T Invoke(string identifier, params object[] args); + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/IJSRuntime.cs b/src/JSInterop/Microsoft.JSInterop/src/IJSRuntime.cs new file mode 100644 index 000000000000..26a7be400fc0 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/IJSRuntime.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.JSInterop +{ + /// + /// Represents an instance of a JavaScript runtime to which calls may be dispatched. + /// + public interface IJSRuntime + { + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// will apply timeouts to this operation based on the value configured in . To dispatch a call with a different timeout, or no timeout, + /// consider using . + /// + /// + /// The JSON-serializable return type. + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + ValueTask InvokeAsync(string identifier, object[] args); + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// The JSON-serializable return type. + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// + /// A cancellation token to signal the cancellation of the operation. Specifying this parameter will override any default cancellations such as due to timeouts + /// () from being applied. + /// + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object[] args); + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs new file mode 100644 index 000000000000..8d37905980e8 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetDispatcher.cs @@ -0,0 +1,429 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Microsoft.JSInterop.Infrastructure +{ + /// + /// Provides methods that receive incoming calls from JS to .NET. + /// + public static class DotNetDispatcher + { + private const string DisposeDotNetObjectReferenceMethodName = "__Dispose"; + internal static readonly JsonEncodedText DotNetObjectRefKey = JsonEncodedText.Encode("__dotNetObject"); + + private static readonly ConcurrentDictionary> _cachedMethodsByAssembly + = new ConcurrentDictionary>(); + + private static readonly ConcurrentDictionary> _cachedMethodsByType + = new ConcurrentDictionary>(); + + /// + /// Receives a call from JS to .NET, locating and invoking the specified method. + /// + /// The . + /// The . + /// A JSON representation of the parameters. + /// A JSON representation of the return value, or null. + public static string Invoke(JSRuntime jsRuntime, in DotNetInvocationInfo invocationInfo, string argsJson) + { + // This method doesn't need [JSInvokable] because the platform is responsible for having + // some way to dispatch calls here. The logic inside here is the thing that checks whether + // the targeted method has [JSInvokable]. It is not itself subject to that restriction, + // because there would be nobody to police that. This method *is* the police. + + IDotNetObjectReference targetInstance = default; + if (invocationInfo.DotNetObjectId != default) + { + targetInstance = jsRuntime.GetObjectReference(invocationInfo.DotNetObjectId); + } + + var syncResult = InvokeSynchronously(jsRuntime, invocationInfo, targetInstance, argsJson); + if (syncResult == null) + { + return null; + } + + return JsonSerializer.Serialize(syncResult, jsRuntime.JsonSerializerOptions); + } + + /// + /// Receives a call from JS to .NET, locating and invoking the specified method asynchronously. + /// + /// The . + /// The . + /// A JSON representation of the parameters. + /// A JSON representation of the return value, or null. + public static void BeginInvokeDotNet(JSRuntime jsRuntime, DotNetInvocationInfo invocationInfo, string argsJson) + { + // This method doesn't need [JSInvokable] because the platform is responsible for having + // some way to dispatch calls here. The logic inside here is the thing that checks whether + // the targeted method has [JSInvokable]. It is not itself subject to that restriction, + // because there would be nobody to police that. This method *is* the police. + + // Using ExceptionDispatchInfo here throughout because we want to always preserve + // original stack traces. + + var callId = invocationInfo.CallId; + + object syncResult = null; + ExceptionDispatchInfo syncException = null; + IDotNetObjectReference targetInstance = null; + try + { + if (invocationInfo.DotNetObjectId != default) + { + targetInstance = jsRuntime.GetObjectReference(invocationInfo.DotNetObjectId); + } + + syncResult = InvokeSynchronously(jsRuntime, invocationInfo, targetInstance, argsJson); + } + catch (Exception ex) + { + syncException = ExceptionDispatchInfo.Capture(ex); + } + + // If there was no callId, the caller does not want to be notified about the result + if (callId == null) + { + return; + } + else if (syncException != null) + { + // Threw synchronously, let's respond. + jsRuntime.EndInvokeDotNet(invocationInfo, new DotNetInvocationResult(syncException.SourceException, "InvocationFailure")); + } + else if (syncResult is Task task) + { + // Returned a task - we need to continue that task and then report an exception + // or return the value. + task.ContinueWith(t => + { + if (t.Exception != null) + { + var exceptionDispatchInfo = ExceptionDispatchInfo.Capture(t.Exception.GetBaseException()); + var dispatchResult = new DotNetInvocationResult(exceptionDispatchInfo.SourceException, "InvocationFailure"); + jsRuntime.EndInvokeDotNet(invocationInfo, dispatchResult); + } + + var result = TaskGenericsUtil.GetTaskResult(task); + jsRuntime.EndInvokeDotNet(invocationInfo, new DotNetInvocationResult(result)); + }, TaskScheduler.Current); + } + else + { + var dispatchResult = new DotNetInvocationResult(syncResult); + jsRuntime.EndInvokeDotNet(invocationInfo, dispatchResult); + } + } + + private static object InvokeSynchronously(JSRuntime jsRuntime, in DotNetInvocationInfo callInfo, IDotNetObjectReference objectReference, string argsJson) + { + var assemblyName = callInfo.AssemblyName; + var methodIdentifier = callInfo.MethodIdentifier; + + AssemblyKey assemblyKey; + MethodInfo methodInfo; + Type[] parameterTypes; + if (objectReference is null) + { + assemblyKey = new AssemblyKey(assemblyName); + (methodInfo, parameterTypes) = GetCachedMethodInfo(assemblyKey, methodIdentifier); + } + else + { + if (assemblyName != null) + { + throw new ArgumentException($"For instance method calls, '{nameof(assemblyName)}' should be null. Value received: '{assemblyName}'."); + } + + if (string.Equals(DisposeDotNetObjectReferenceMethodName, methodIdentifier, StringComparison.Ordinal)) + { + // The client executed dotNetObjectReference.dispose(). Dispose the reference and exit. + objectReference.Dispose(); + return default; + } + + (methodInfo, parameterTypes) = GetCachedMethodInfo(objectReference, methodIdentifier); + } + + var suppliedArgs = ParseArguments(jsRuntime, methodIdentifier, argsJson, parameterTypes); + + try + { + // objectReference will be null if this call invokes a static JSInvokable method. + return methodInfo.Invoke(objectReference?.Value, suppliedArgs); + } + catch (TargetInvocationException tie) // Avoid using exception filters for AOT runtime support + { + if (tie.InnerException != null) + { + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + throw null; // unreached + } + + throw; + } + } + + internal static object[] ParseArguments(JSRuntime jsRuntime, string methodIdentifier, string arguments, Type[] parameterTypes) + { + if (parameterTypes.Length == 0) + { + return Array.Empty(); + } + + var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments); + var reader = new Utf8JsonReader(utf8JsonBytes); + if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException("Invalid JSON"); + } + + var suppliedArgs = new object[parameterTypes.Length]; + + var index = 0; + while (index < parameterTypes.Length && reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var parameterType = parameterTypes[index]; + if (reader.TokenType == JsonTokenType.StartObject && IsIncorrectDotNetObjectRefUse(parameterType, reader)) + { + throw new InvalidOperationException($"In call to '{methodIdentifier}', parameter of type '{parameterType.Name}' at index {(index + 1)} must be declared as type 'DotNetObjectRef<{parameterType.Name}>' to receive the incoming value."); + } + + suppliedArgs[index] = JsonSerializer.Deserialize(ref reader, parameterType, jsRuntime.JsonSerializerOptions); + index++; + } + + if (index < parameterTypes.Length) + { + // If we parsed fewer parameters, we can always make a definitive claim about how many parameters were received. + throw new ArgumentException($"The call to '{methodIdentifier}' expects '{parameterTypes.Length}' parameters, but received '{index}'."); + } + + if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray) + { + // Either we received more parameters than we expected or the JSON is malformed. + throw new JsonException($"Unexpected JSON token {reader.TokenType}. Ensure that the call to `{methodIdentifier}' is supplied with exactly '{parameterTypes.Length}' parameters."); + } + + return suppliedArgs; + + // Note that the JsonReader instance is intentionally not passed by ref (or an in parameter) since we want a copy of the original reader. + static bool IsIncorrectDotNetObjectRefUse(Type parameterType, Utf8JsonReader jsonReader) + { + // Check for incorrect use of DotNetObjectRef at the top level. We know it's + // an incorrect use if there's a object that looks like { '__dotNetObject': }, + // but we aren't assigning to DotNetObjectRef{T}. + if (jsonReader.Read() && + jsonReader.TokenType == JsonTokenType.PropertyName && + jsonReader.ValueTextEquals(DotNetObjectRefKey.EncodedUtf8Bytes)) + { + // The JSON payload has the shape we expect from a DotNetObjectRef instance. + return !parameterType.IsGenericType || parameterType.GetGenericTypeDefinition() != typeof(DotNetObjectReference<>); + } + + return false; + } + } + + /// + /// Receives notification that a call from .NET to JS has finished, marking the + /// associated as completed. + /// + /// + /// All exceptions from are caught + /// are delivered via JS interop to the JavaScript side when it requests confirmation, as + /// the mechanism to call relies on + /// using JS->.NET interop. This overload is meant for directly triggering completion callbacks + /// for .NET -> JS operations without going through JS interop, so the callsite for this + /// method is responsible for handling any possible exception generated from the arguments + /// passed in as parameters. + /// + /// The . + /// The serialized arguments for the callback completion. + /// + /// This method can throw any exception either from the argument received or as a result + /// of executing any callback synchronously upon completion. + /// + public static void EndInvokeJS(JSRuntime jsRuntime, string arguments) + { + var utf8JsonBytes = Encoding.UTF8.GetBytes(arguments); + + // The payload that we're trying to parse is of the format + // [ taskId: long, success: boolean, value: string? | object ] + // where value is the .NET type T originally specified on InvokeAsync or the error string if success is false. + // We parse the first two arguments and call in to JSRuntimeBase to deserialize the actual value. + + var reader = new Utf8JsonReader(utf8JsonBytes); + + if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException("Invalid JSON"); + } + + reader.Read(); + var taskId = reader.GetInt64(); + + reader.Read(); + var success = reader.GetBoolean(); + + reader.Read(); + jsRuntime.EndInvokeJS(taskId, success, ref reader); + + if (!reader.Read() || reader.TokenType != JsonTokenType.EndArray) + { + throw new JsonException("Invalid JSON"); + } + } + + private static (MethodInfo, Type[]) GetCachedMethodInfo(AssemblyKey assemblyKey, string methodIdentifier) + { + if (string.IsNullOrWhiteSpace(assemblyKey.AssemblyName)) + { + throw new ArgumentException("Cannot be null, empty, or whitespace.", nameof(assemblyKey.AssemblyName)); + } + + if (string.IsNullOrWhiteSpace(methodIdentifier)) + { + throw new ArgumentException("Cannot be null, empty, or whitespace.", nameof(methodIdentifier)); + } + + var assemblyMethods = _cachedMethodsByAssembly.GetOrAdd(assemblyKey, ScanAssemblyForCallableMethods); + if (assemblyMethods.TryGetValue(methodIdentifier, out var result)) + { + return result; + } + else + { + throw new ArgumentException($"The assembly '{assemblyKey.AssemblyName}' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")]."); + } + } + + private static (MethodInfo methodInfo, Type[] parameterTypes) GetCachedMethodInfo(IDotNetObjectReference objectReference, string methodIdentifier) + { + var type = objectReference.Value.GetType(); + var assemblyMethods = _cachedMethodsByType.GetOrAdd(type, ScanTypeForCallableMethods); + if (assemblyMethods.TryGetValue(methodIdentifier, out var result)) + { + return result; + } + else + { + throw new ArgumentException($"The type '{type.Name}' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")]."); + } + + static Dictionary ScanTypeForCallableMethods(Type type) + { + var result = new Dictionary(StringComparer.Ordinal); + var invokableMethods = type + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(method => !method.ContainsGenericParameters && method.IsDefined(typeof(JSInvokableAttribute), inherit: false)); + + foreach (var method in invokableMethods) + { + var identifier = method.GetCustomAttribute(false).Identifier ?? method.Name; + var parameterTypes = method.GetParameters().Select(p => p.ParameterType).ToArray(); + + if (result.ContainsKey(identifier)) + { + throw new InvalidOperationException($"The type {type.Name} contains more than one " + + $"[JSInvokable] method with identifier '{identifier}'. All [JSInvokable] methods within the same " + + $"type must have different identifiers. You can pass a custom identifier as a parameter to " + + $"the [JSInvokable] attribute."); + } + + result.Add(identifier, (method, parameterTypes)); + } + + return result; + } + } + + private static Dictionary ScanAssemblyForCallableMethods(AssemblyKey assemblyKey) + { + // TODO: Consider looking first for assembly-level attributes (i.e., if there are any, + // only use those) to avoid scanning, especially for framework assemblies. + var result = new Dictionary(StringComparer.Ordinal); + var invokableMethods = GetRequiredLoadedAssembly(assemblyKey) + .GetExportedTypes() + .SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + .Where(method => !method.ContainsGenericParameters && method.IsDefined(typeof(JSInvokableAttribute), inherit: false)); + foreach (var method in invokableMethods) + { + var identifier = method.GetCustomAttribute(false).Identifier ?? method.Name; + var parameterTypes = method.GetParameters().Select(p => p.ParameterType).ToArray(); + + if (result.ContainsKey(identifier)) + { + throw new InvalidOperationException($"The assembly '{assemblyKey.AssemblyName}' contains more than one " + + $"[JSInvokable] method with identifier '{identifier}'. All [JSInvokable] methods within the same " + + $"assembly must have different identifiers. You can pass a custom identifier as a parameter to " + + $"the [JSInvokable] attribute."); + } + + result.Add(identifier, (method, parameterTypes)); + } + + return result; + } + + private static Assembly GetRequiredLoadedAssembly(AssemblyKey assemblyKey) + { + // We don't want to load assemblies on demand here, because we don't necessarily trust + // "assemblyName" to be something the developer intended to load. So only pick from the + // set of already-loaded assemblies. + // In some edge cases this might force developers to explicitly call something on the + // target assembly (from .NET) before they can invoke its allowed methods from JS. + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + + // Using LastOrDefault to workaround for https://github.com/dotnet/arcade/issues/2816. + // In most ordinary scenarios, we wouldn't have two instances of the same Assembly in the AppDomain + // so this doesn't change the outcome. + var assembly = loadedAssemblies.LastOrDefault(a => new AssemblyKey(a).Equals(assemblyKey)); + + return assembly + ?? throw new ArgumentException($"There is no loaded assembly with the name '{assemblyKey.AssemblyName}'."); + } + + private readonly struct AssemblyKey : IEquatable + { + public AssemblyKey(Assembly assembly) + { + Assembly = assembly; + AssemblyName = assembly.GetName().Name; + } + + public AssemblyKey(string assemblyName) + { + Assembly = null; + AssemblyName = assemblyName; + } + + public Assembly Assembly { get; } + + public string AssemblyName { get; } + + public bool Equals(AssemblyKey other) + { + if (Assembly != null && other.Assembly != null) + { + return Assembly == other.Assembly; + } + + return AssemblyName.Equals(other.AssemblyName, StringComparison.Ordinal); + } + + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(AssemblyName); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetInvocationInfo.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetInvocationInfo.cs new file mode 100644 index 000000000000..942fc34da009 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetInvocationInfo.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.JSInterop.Infrastructure +{ + /// + /// Information about a JSInterop call from JavaScript to .NET. + /// + public readonly struct DotNetInvocationInfo + { + /// + /// Initializes a new instance of . + /// + /// The name of the assembly containing the method. + /// The identifier of the method to be invoked. + /// The object identifier for instance method calls. + /// The call identifier. + public DotNetInvocationInfo(string assemblyName, string methodIdentifier, long dotNetObjectId, string callId) + { + CallId = callId; + AssemblyName = assemblyName; + MethodIdentifier = methodIdentifier; + DotNetObjectId = dotNetObjectId; + } + + /// + /// Gets the name of the assembly containing the method. + /// Only one of or may be specified. + /// + public string AssemblyName { get; } + + /// + /// Gets the identifier of the method to be invoked. This is the value specified in the . + /// + public string MethodIdentifier { get; } + + /// + /// Gets the object identifier for instance method calls. + /// Only one of or may be specified. + /// + public long DotNetObjectId { get; } + + /// + /// Gets the call identifier. This value is when the client does not expect a value to be returned. + /// + public string CallId { get; } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetInvocationResult.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetInvocationResult.cs new file mode 100644 index 000000000000..d62dd532ee04 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetInvocationResult.cs @@ -0,0 +1,55 @@ +using System; + +namespace Microsoft.JSInterop.Infrastructure +{ + /// + /// Result of a .NET invocation that is returned to JavaScript. + /// + public readonly struct DotNetInvocationResult + { + /// + /// Constructor for a failed invocation. + /// + /// The that caused the failure. + /// The error kind. + public DotNetInvocationResult(Exception exception, string errorKind) + { + Result = default; + Exception = exception ?? throw new ArgumentNullException(nameof(exception)); + ErrorKind = errorKind; + Success = false; + } + + /// + /// Constructor for a successful invocation. + /// + /// The result. + public DotNetInvocationResult(object result) + { + Result = result; + Exception = default; + ErrorKind = default; + Success = true; + } + + /// + /// Gets the that caused the failure. + /// + public Exception Exception { get; } + + /// + /// Gets the error kind. + /// + public string ErrorKind { get; } + + /// + /// Gets the result of a successful invocation. + /// + public object Result { get; } + + /// + /// if the invocation succeeded, otherwise . + /// + public bool Success { get; } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetObjectReferenceJsonConverter.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetObjectReferenceJsonConverter.cs new file mode 100644 index 000000000000..7658bbc2c355 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetObjectReferenceJsonConverter.cs @@ -0,0 +1,63 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.JSInterop.Infrastructure +{ + internal sealed class DotNetObjectReferenceJsonConverter : JsonConverter> where TValue : class + { + public DotNetObjectReferenceJsonConverter(JSRuntime jsRuntime) + { + JSRuntime = jsRuntime; + } + + private static JsonEncodedText DotNetObjectRefKey => DotNetDispatcher.DotNetObjectRefKey; + + public JSRuntime JSRuntime { get; } + + public override DotNetObjectReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + long dotNetObjectId = 0; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + if (dotNetObjectId == 0 && reader.ValueTextEquals(DotNetObjectRefKey.EncodedUtf8Bytes)) + { + reader.Read(); + dotNetObjectId = reader.GetInt64(); + } + else + { + throw new JsonException($"Unexcepted JSON property {reader.GetString()}."); + } + } + else + { + throw new JsonException($"Unexcepted JSON Token {reader.TokenType}."); + } + } + + if (dotNetObjectId is 0) + { + throw new JsonException($"Required property {DotNetObjectRefKey} not found."); + } + + var value = (DotNetObjectReference)JSRuntime.GetObjectReference(dotNetObjectId); + return value; + } + + public override void Write(Utf8JsonWriter writer, DotNetObjectReference value, JsonSerializerOptions options) + { + var objectId = JSRuntime.TrackObjectReference(value); + + writer.WriteStartObject(); + writer.WriteNumber(DotNetObjectRefKey, objectId); + writer.WriteEndObject(); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetObjectReferenceJsonConverterFactory.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetObjectReferenceJsonConverterFactory.cs new file mode 100644 index 000000000000..288bfdd090b1 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetObjectReferenceJsonConverterFactory.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.JSInterop.Infrastructure +{ + internal sealed class DotNetObjectReferenceJsonConverterFactory : JsonConverterFactory + { + public DotNetObjectReferenceJsonConverterFactory(JSRuntime jsRuntime) + { + JSRuntime = jsRuntime; + } + + public JSRuntime JSRuntime { get; } + + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(DotNetObjectReference<>); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions jsonSerializerOptions) + { + // System.Text.Json handles caching the converters per type on our behalf. No caching is required here. + var instanceType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(DotNetObjectReferenceJsonConverter<>).MakeGenericType(instanceType); + + return (JsonConverter)Activator.CreateInstance(converterType, JSRuntime); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/IDotNetObjectReference.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/IDotNetObjectReference.cs new file mode 100644 index 000000000000..4b84f2bd0c78 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/IDotNetObjectReference.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.JSInterop.Infrastructure +{ + internal interface IDotNetObjectReference : IDisposable + { + object Value { get; } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/TaskGenericsUtil.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/TaskGenericsUtil.cs new file mode 100644 index 000000000000..4e14d5078338 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/TaskGenericsUtil.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.JSInterop.Infrastructure +{ + internal static class TaskGenericsUtil + { + private static ConcurrentDictionary _cachedResultGetters + = new ConcurrentDictionary(); + + private static ConcurrentDictionary _cachedResultSetters + = new ConcurrentDictionary(); + + public static void SetTaskCompletionSourceResult(object taskCompletionSource, object result) + => CreateResultSetter(taskCompletionSource).SetResult(taskCompletionSource, result); + + public static void SetTaskCompletionSourceException(object taskCompletionSource, Exception exception) + => CreateResultSetter(taskCompletionSource).SetException(taskCompletionSource, exception); + + public static Type GetTaskCompletionSourceResultType(object taskCompletionSource) + => CreateResultSetter(taskCompletionSource).ResultType; + + public static object GetTaskResult(Task task) + { + var getter = _cachedResultGetters.GetOrAdd(task.GetType(), taskInstanceType => + { + var resultType = GetTaskResultType(taskInstanceType); + return resultType == null + ? new VoidTaskResultGetter() + : (ITaskResultGetter)Activator.CreateInstance( + typeof(TaskResultGetter<>).MakeGenericType(resultType)); + }); + return getter.GetResult(task); + } + + private static Type GetTaskResultType(Type taskType) + { + // It might be something derived from Task or Task, so we have to scan + // up the inheritance hierarchy to find the Task or Task + while (taskType != typeof(Task) && + (!taskType.IsGenericType || taskType.GetGenericTypeDefinition() != typeof(Task<>))) + { + taskType = taskType.BaseType + ?? throw new ArgumentException($"The type '{taskType.FullName}' is not inherited from '{typeof(Task).FullName}'."); + } + + return taskType.IsGenericType + ? taskType.GetGenericArguments().Single() + : null; + } + + interface ITcsResultSetter + { + Type ResultType { get; } + void SetResult(object taskCompletionSource, object result); + void SetException(object taskCompletionSource, Exception exception); + } + + private interface ITaskResultGetter + { + object GetResult(Task task); + } + + private class TaskResultGetter : ITaskResultGetter + { + public object GetResult(Task task) => ((Task)task).Result; + } + + private class VoidTaskResultGetter : ITaskResultGetter + { + public object GetResult(Task task) + { + task.Wait(); // Throw if the task failed + return null; + } + } + + private class TcsResultSetter : ITcsResultSetter + { + public Type ResultType => typeof(T); + + public void SetResult(object tcs, object result) + { + var typedTcs = (TaskCompletionSource)tcs; + + // If necessary, attempt a cast + var typedResult = result is T resultT + ? resultT + : (T)Convert.ChangeType(result, typeof(T)); + + typedTcs.SetResult(typedResult); + } + + public void SetException(object tcs, Exception exception) + { + var typedTcs = (TaskCompletionSource)tcs; + typedTcs.SetException(exception); + } + } + + private static ITcsResultSetter CreateResultSetter(object taskCompletionSource) + { + return _cachedResultSetters.GetOrAdd(taskCompletionSource.GetType(), tcsType => + { + var resultType = tcsType.GetGenericArguments().Single(); + return (ITcsResultSetter)Activator.CreateInstance( + typeof(TcsResultSetter<>).MakeGenericType(resultType)); + }); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSException.cs b/src/JSInterop/Microsoft.JSInterop/src/JSException.cs new file mode 100644 index 000000000000..c2e4f1b7aece --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/JSException.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.JSInterop +{ + /// + /// Represents errors that occur during an interop call from .NET to JavaScript. + /// + public class JSException : Exception + { + /// + /// Constructs an instance of . + /// + /// The exception message. + public JSException(string message) : base(message) + { + } + + /// + /// Constructs an instance of . + /// + /// The exception message. + /// The inner exception. + public JSException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSInProcessRuntime.cs b/src/JSInterop/Microsoft.JSInterop/src/JSInProcessRuntime.cs new file mode 100644 index 000000000000..e84ab1b631cf --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/JSInProcessRuntime.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Json; + +namespace Microsoft.JSInterop +{ + /// + /// Abstract base class for an in-process JavaScript runtime. + /// + public abstract class JSInProcessRuntime : JSRuntime, IJSInProcessRuntime + { + /// + /// Invokes the specified JavaScript function synchronously. + /// + /// The JSON-serializable return type. + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + public TValue Invoke(string identifier, params object[] args) + { + var resultJson = InvokeJS(identifier, JsonSerializer.Serialize(args, JsonSerializerOptions)); + if (resultJson is null) + { + return default; + } + + return JsonSerializer.Deserialize(resultJson, JsonSerializerOptions); + } + + /// + /// Performs a synchronous function invocation. + /// + /// The identifier for the function to invoke. + /// A JSON representation of the arguments. + /// A JSON representation of the result. + protected abstract string InvokeJS(string identifier, string argsJson); + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSInProcessRuntimeExtensions.cs b/src/JSInterop/Microsoft.JSInterop/src/JSInProcessRuntimeExtensions.cs new file mode 100644 index 000000000000..5a040d9b786c --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/JSInProcessRuntimeExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.JSInterop +{ + /// + /// Extensions for . + /// + public static class JSInProcessRuntimeExtensions + { + /// + /// Invokes the specified JavaScript function synchronously. + /// + /// The . + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// JSON-serializable arguments. + public static void InvokeVoid(this IJSInProcessRuntime jsRuntime, string identifier, params object[] args) + { + if (jsRuntime == null) + { + throw new ArgumentNullException(nameof(jsRuntime)); + } + + jsRuntime.Invoke(identifier, args); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSInvokableAttribute.cs b/src/JSInterop/Microsoft.JSInterop/src/JSInvokableAttribute.cs new file mode 100644 index 000000000000..b710d54b2cb0 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/JSInvokableAttribute.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.JSInterop +{ + /// + /// Identifies a .NET method as allowing invocation from JavaScript code. + /// Any method marked with this attribute may receive arbitrary parameter values + /// from untrusted callers. All inputs should be validated carefully. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public sealed class JSInvokableAttribute : Attribute + { + /// + /// Gets the identifier for the method. The identifier must be unique within the scope + /// of an assembly. + /// + /// If not set, the identifier is taken from the name of the method. In this case the + /// method name must be unique within the assembly. + /// + public string Identifier { get; } + + /// + /// Constructs an instance of without setting + /// an identifier for the method. + /// + public JSInvokableAttribute() + { + } + + /// + /// Constructs an instance of using the specified + /// identifier. + /// + /// An identifier for the method, which must be unique within the scope of the assembly. + public JSInvokableAttribute(string identifier) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("Cannot be null or empty", nameof(identifier)); + } + + Identifier = identifier; + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs b/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs new file mode 100644 index 000000000000..8a97ada85903 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs @@ -0,0 +1,235 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.JSInterop.Infrastructure; + +namespace Microsoft.JSInterop +{ + /// + /// Abstract base class for a JavaScript runtime. + /// + public abstract partial class JSRuntime : IJSRuntime + { + private long _nextObjectReferenceId = 0; // 0 signals no object, but we increment prior to assignment. The first tracked object should have id 1 + private long _nextPendingTaskId = 1; // Start at 1 because zero signals "no response needed" + private readonly ConcurrentDictionary _pendingTasks = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _trackedRefsById = new ConcurrentDictionary(); + private readonly ConcurrentDictionary _cancellationRegistrations = + new ConcurrentDictionary(); + + /// + /// Initializes a new instance of . + /// + protected JSRuntime() + { + JsonSerializerOptions = new JsonSerializerOptions + { + MaxDepth = 32, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + Converters = + { + new DotNetObjectReferenceJsonConverterFactory(this), + } + }; + } + + /// + /// Gets the used to serialize and deserialize interop payloads. + /// + protected internal JsonSerializerOptions JsonSerializerOptions { get; } + + /// + /// Gets or sets the default timeout for asynchronous JavaScript calls. + /// + protected TimeSpan? DefaultAsyncTimeout { get; set; } + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// will apply timeouts to this operation based on the value configured in . To dispatch a call with a different, or no timeout, + /// consider using . + /// + /// + /// The JSON-serializable return type. + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + public ValueTask InvokeAsync(string identifier, object[] args) + { + if (DefaultAsyncTimeout.HasValue) + { + return InvokeWithDefaultCancellation(identifier, args); + } + + return InvokeAsync(identifier, CancellationToken.None, args); + } + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// The JSON-serializable return type. + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// + /// A cancellation token to signal the cancellation of the operation. Specifying this parameter will override any default cancellations such as due to timeouts + /// () from being applied. + /// + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object[] args) + { + var taskId = Interlocked.Increment(ref _nextPendingTaskId); + var tcs = new TaskCompletionSource(TaskContinuationOptions.RunContinuationsAsynchronously); + if (cancellationToken != default) + { + _cancellationRegistrations[taskId] = cancellationToken.Register(() => + { + tcs.TrySetCanceled(cancellationToken); + CleanupTasksAndRegistrations(taskId); + }); + } + _pendingTasks[taskId] = tcs; + + try + { + if (cancellationToken.IsCancellationRequested) + { + tcs.TrySetCanceled(cancellationToken); + CleanupTasksAndRegistrations(taskId); + + return new ValueTask(tcs.Task); + } + + var argsJson = args?.Any() == true ? + JsonSerializer.Serialize(args, JsonSerializerOptions) : + null; + BeginInvokeJS(taskId, identifier, argsJson); + + return new ValueTask(tcs.Task); + } + catch + { + CleanupTasksAndRegistrations(taskId); + throw; + } + } + + private void CleanupTasksAndRegistrations(long taskId) + { + _pendingTasks.TryRemove(taskId, out _); + if (_cancellationRegistrations.TryRemove(taskId, out var registration)) + { + registration.Dispose(); + } + } + + private async ValueTask InvokeWithDefaultCancellation(string identifier, object[] args) + { + using (var cts = new CancellationTokenSource(DefaultAsyncTimeout.Value)) + { + // We need to await here due to the using + return await InvokeAsync(identifier, cts.Token, args); + } + } + + /// + /// Begins an asynchronous function invocation. + /// + /// The identifier for the function invocation, or zero if no async callback is required. + /// The identifier for the function to invoke. + /// A JSON representation of the arguments. + protected abstract void BeginInvokeJS(long taskId, string identifier, string argsJson); + + /// + /// Completes an async JS interop call from JavaScript to .NET + /// + /// The . + /// The . + protected internal abstract void EndInvokeDotNet( + DotNetInvocationInfo invocationInfo, + in DotNetInvocationResult invocationResult); + + internal void EndInvokeJS(long taskId, bool succeeded, ref Utf8JsonReader jsonReader) + { + if (!_pendingTasks.TryRemove(taskId, out var tcs)) + { + // We should simply return if we can't find an id for the invocation. + // This likely means that the method that initiated the call defined a timeout and stopped waiting. + return; + } + + CleanupTasksAndRegistrations(taskId); + + try + { + if (succeeded) + { + var resultType = TaskGenericsUtil.GetTaskCompletionSourceResultType(tcs); + + var result = JsonSerializer.Deserialize(ref jsonReader, resultType, JsonSerializerOptions); + TaskGenericsUtil.SetTaskCompletionSourceResult(tcs, result); + } + else + { + var exceptionText = jsonReader.GetString() ?? string.Empty; + TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(exceptionText)); + } + } + catch (Exception exception) + { + var message = $"An exception occurred executing JS interop: {exception.Message}. See InnerException for more details."; + TaskGenericsUtil.SetTaskCompletionSourceException(tcs, new JSException(message, exception)); + } + } + + internal long TrackObjectReference(DotNetObjectReference dotNetObjectReference) where TValue : class + { + if (dotNetObjectReference == null) + { + throw new ArgumentNullException(nameof(dotNetObjectReference)); + } + + dotNetObjectReference.ThrowIfDisposed(); + + var jsRuntime = dotNetObjectReference.JSRuntime; + if (jsRuntime is null) + { + var dotNetObjectId = Interlocked.Increment(ref _nextObjectReferenceId); + + dotNetObjectReference.JSRuntime = this; + dotNetObjectReference.ObjectId = dotNetObjectId; + + _trackedRefsById[dotNetObjectId] = dotNetObjectReference; + } + else if (!ReferenceEquals(this, jsRuntime)) + { + throw new InvalidOperationException($"{dotNetObjectReference.GetType().Name} is already being tracked by a different instance of {nameof(JSRuntime)}." + + $" A common cause is caching an instance of {nameof(DotNetObjectReference)} globally. Consider creating instances of {nameof(DotNetObjectReference)} at the JSInterop callsite."); + } + + Debug.Assert(dotNetObjectReference.ObjectId != 0); + return dotNetObjectReference.ObjectId; + } + + internal IDotNetObjectReference GetObjectReference(long dotNetObjectId) + { + return _trackedRefsById.TryGetValue(dotNetObjectId, out var dotNetObjectRef) + ? dotNetObjectRef + : throw new ArgumentException($"There is no tracked object with id '{dotNetObjectId}'. Perhaps the DotNetObjectReference instance was already disposed.", nameof(dotNetObjectId)); + } + + /// + /// Stops tracking the specified .NET object reference. + /// This may be invoked either by disposing a DotNetObjectRef in .NET code, or via JS interop by calling "dispose" on the corresponding instance in JavaScript code + /// + /// The ID of the . + internal void ReleaseObjectReference(long dotNetObjectId) => _trackedRefsById.TryRemove(dotNetObjectId, out _); + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeExtensions.cs b/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeExtensions.cs new file mode 100644 index 000000000000..354eddbbf660 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/JSRuntimeExtensions.cs @@ -0,0 +1,140 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.JSInterop +{ + /// + /// Extensions for . + /// + public static class JSRuntimeExtensions + { + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// The . + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// JSON-serializable arguments. + /// A that represents the asynchronous invocation operation. + public static async ValueTask InvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, params object[] args) + { + if (jsRuntime is null) + { + throw new ArgumentNullException(nameof(jsRuntime)); + } + + await jsRuntime.InvokeAsync(identifier, args); + } + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// will apply timeouts to this operation based on the value configured in . To dispatch a call with a different timeout, or no timeout, + /// consider using . + /// + /// + /// The . + /// The JSON-serializable return type. + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + public static ValueTask InvokeAsync(this IJSRuntime jsRuntime, string identifier, params object[] args) + { + if (jsRuntime is null) + { + throw new ArgumentNullException(nameof(jsRuntime)); + } + + return jsRuntime.InvokeAsync(identifier, args); + } + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// The JSON-serializable return type. + /// The . + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// + /// A cancellation token to signal the cancellation of the operation. Specifying this parameter will override any default cancellations such as due to timeouts + /// () from being applied. + /// + /// JSON-serializable arguments. + /// An instance of obtained by JSON-deserializing the return value. + public static ValueTask InvokeAsync(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object[] args) + { + if (jsRuntime is null) + { + throw new ArgumentNullException(nameof(jsRuntime)); + } + + return jsRuntime.InvokeAsync(identifier, cancellationToken, args); + } + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// The . + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// + /// A cancellation token to signal the cancellation of the operation. Specifying this parameter will override any default cancellations such as due to timeouts + /// () from being applied. + /// + /// JSON-serializable arguments. + /// A that represents the asynchronous invocation operation. + public static async ValueTask InvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, CancellationToken cancellationToken, params object[] args) + { + if (jsRuntime is null) + { + throw new ArgumentNullException(nameof(jsRuntime)); + } + + await jsRuntime.InvokeAsync(identifier, cancellationToken, args); + } + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// The . + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// The duration after which to cancel the async operation. Overrides default timeouts (). + /// JSON-serializable arguments. + /// A that represents the asynchronous invocation operation. + public static async ValueTask InvokeAsync(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object[] args) + { + if (jsRuntime is null) + { + throw new ArgumentNullException(nameof(jsRuntime)); + } + + + using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout); + var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None; + + return await jsRuntime.InvokeAsync(identifier, cancellationToken, args); + } + + /// + /// Invokes the specified JavaScript function asynchronously. + /// + /// The . + /// An identifier for the function to invoke. For example, the value "someScope.someFunction" will invoke the function window.someScope.someFunction. + /// The duration after which to cancel the async operation. Overrides default timeouts (). + /// JSON-serializable arguments. + /// A that represents the asynchronous invocation operation. + public static async ValueTask InvokeVoidAsync(this IJSRuntime jsRuntime, string identifier, TimeSpan timeout, params object[] args) + { + if (jsRuntime is null) + { + throw new ArgumentNullException(nameof(jsRuntime)); + } + + using var cancellationTokenSource = timeout == Timeout.InfiniteTimeSpan ? null : new CancellationTokenSource(timeout); + var cancellationToken = cancellationTokenSource?.Token ?? CancellationToken.None; + + await jsRuntime.InvokeAsync(identifier, cancellationToken, args); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Microsoft.JSInterop.csproj b/src/JSInterop/Microsoft.JSInterop/src/Microsoft.JSInterop.csproj new file mode 100644 index 000000000000..a45484e0d3a7 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Microsoft.JSInterop.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(DefaultNetCoreTargetFramework) + Abstractions and features for interop between .NET and JavaScript code. + javascript;interop + true + true + true + + + + + + + + + + + diff --git a/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceTest.cs b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceTest.cs new file mode 100644 index 000000000000..95fad485a7a4 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/DotNetObjectReferenceTest.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.JSInterop +{ + public class DotNetObjectReferenceTest + { + [Fact] + public void CanAccessValue() + { + var obj = new object(); + Assert.Same(obj, DotNetObjectReference.Create(obj).Value); + } + + [Fact] + public void TrackObjectReference_AssignsObjectId() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var objRef = DotNetObjectReference.Create(new object()); + + // Act + var objectId = jsRuntime.TrackObjectReference(objRef); + + // Act + Assert.Equal(objectId, objRef.ObjectId); + Assert.Equal(1, objRef.ObjectId); + } + + [Fact] + public void TrackObjectReference_AllowsMultipleCallsUsingTheSameJSRuntime() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var objRef = DotNetObjectReference.Create(new object()); + + // Act + var objectId1 = jsRuntime.TrackObjectReference(objRef); + var objectId2 = jsRuntime.TrackObjectReference(objRef); + + // Act + Assert.Equal(objectId1, objectId2); + } + + [Fact] + public void TrackObjectReference_ThrowsIfDifferentJSRuntimeInstancesAreUsed() + { + // Arrange + var objRef = DotNetObjectReference.Create("Hello world"); + var expected = $"{objRef.GetType().Name} is already being tracked by a different instance of {nameof(JSRuntime)}. A common cause is caching an instance of {nameof(DotNetObjectReference)}" + + $" globally. Consider creating instances of {nameof(DotNetObjectReference)} at the JSInterop callsite."; + var jsRuntime1 = new TestJSRuntime(); + var jsRuntime2 = new TestJSRuntime(); + jsRuntime1.TrackObjectReference(objRef); + + // Act + var ex = Assert.Throws(() => jsRuntime2.TrackObjectReference(objRef)); + + // Assert + Assert.Equal(expected, ex.Message); + } + + [Fact] + public void Dispose_StopsTrackingObject() + { + // Arrange + var objRef = DotNetObjectReference.Create("Hello world"); + var jsRuntime = new TestJSRuntime(); + jsRuntime.TrackObjectReference(objRef); + var objectId = objRef.ObjectId; + var expected = $"There is no tracked object with id '{objectId}'. Perhaps the DotNetObjectReference instance was already disposed."; + + // Act + Assert.Same(objRef, jsRuntime.GetObjectReference(objectId)); + objRef.Dispose(); + + // Assert + Assert.True(objRef.Disposed); + Assert.Throws(() => jsRuntime.GetObjectReference(objectId)); + } + + [Fact] + public void DoubleDispose_Works() + { + // Arrange + var objRef = DotNetObjectReference.Create("Hello world"); + var jsRuntime = new TestJSRuntime(); + jsRuntime.TrackObjectReference(objRef); + var objectId = objRef.ObjectId; + + // Act + Assert.Same(objRef, jsRuntime.GetObjectReference(objectId)); + objRef.Dispose(); + + // Assert + objRef.Dispose(); + // If we got this far, this did not throw. + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetDispatcherTest.cs b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetDispatcherTest.cs new file mode 100644 index 000000000000..1f482b268077 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetDispatcherTest.cs @@ -0,0 +1,915 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.JSInterop.Infrastructure +{ + public class DotNetDispatcherTest + { + private readonly static string thisAssemblyName = typeof(DotNetDispatcherTest).Assembly.GetName().Name; + + [Fact] + public void CannotInvokeWithEmptyAssemblyName() + { + var ex = Assert.Throws(() => + { + DotNetDispatcher.Invoke(new TestJSRuntime(), new DotNetInvocationInfo(" ", "SomeMethod", default, default), "[]"); + }); + + Assert.StartsWith("Cannot be null, empty, or whitespace.", ex.Message); + Assert.Equal("AssemblyName", ex.ParamName); + } + + [Fact] + public void CannotInvokeWithEmptyMethodIdentifier() + { + var ex = Assert.Throws(() => + { + DotNetDispatcher.Invoke(new TestJSRuntime(), new DotNetInvocationInfo("SomeAssembly", " ", default, default), "[]"); + }); + + Assert.StartsWith("Cannot be null, empty, or whitespace.", ex.Message); + Assert.Equal("methodIdentifier", ex.ParamName); + } + + [Fact] + public void CannotInvokeMethodsOnUnloadedAssembly() + { + var assemblyName = "Some.Fake.Assembly"; + var ex = Assert.Throws(() => + { + DotNetDispatcher.Invoke(new TestJSRuntime(), new DotNetInvocationInfo(assemblyName, "SomeMethod", default, default), null); + }); + + Assert.Equal($"There is no loaded assembly with the name '{assemblyName}'.", ex.Message); + } + + // Note: Currently it's also not possible to invoke generic methods. + // That's not something determined by DotNetDispatcher, but rather by the fact that we + // don't close over the generics in the reflection code. + // Not defining this behavior through unit tests because the default outcome is + // fine (an exception stating what info is missing). + + [Theory] + [InlineData("MethodOnInternalType")] + [InlineData("PrivateMethod")] + [InlineData("ProtectedMethod")] + [InlineData("StaticMethodWithoutAttribute")] // That's not really its identifier; just making the point that there's no way to invoke it + [InlineData("InstanceMethodWithoutAttribute")] // That's not really its identifier; just making the point that there's no way to invoke it + public void CannotInvokeUnsuitableMethods(string methodIdentifier) + { + var ex = Assert.Throws(() => + { + DotNetDispatcher.Invoke(new TestJSRuntime(), new DotNetInvocationInfo(thisAssemblyName, methodIdentifier, default, default), null); + }); + + Assert.Equal($"The assembly '{thisAssemblyName}' does not contain a public invokable method with [JSInvokableAttribute(\"{methodIdentifier}\")].", ex.Message); + } + + [Fact] + public void CanInvokeStaticVoidMethod() + { + // Arrange/Act + var jsRuntime = new TestJSRuntime(); + SomePublicType.DidInvokeMyInvocableStaticVoid = false; + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, "InvocableStaticVoid", default, default), null); + + // Assert + Assert.Null(resultJson); + Assert.True(SomePublicType.DidInvokeMyInvocableStaticVoid); + } + + [Fact] + public void CanInvokeStaticNonVoidMethod() + { + // Arrange/Act + var jsRuntime = new TestJSRuntime(); + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, "InvocableStaticNonVoid", default, default), null); + var result = JsonSerializer.Deserialize(resultJson, jsRuntime.JsonSerializerOptions); + + // Assert + Assert.Equal("Test", result.StringVal); + Assert.Equal(123, result.IntVal); + } + + [Fact] + public void CanInvokeStaticNonVoidMethodWithoutCustomIdentifier() + { + // Arrange/Act + var jsRuntime = new TestJSRuntime(); + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, nameof(SomePublicType.InvokableMethodWithoutCustomIdentifier), default, default), null); + var result = JsonSerializer.Deserialize(resultJson, jsRuntime.JsonSerializerOptions); + + // Assert + Assert.Equal("InvokableMethodWithoutCustomIdentifier", result.StringVal); + Assert.Equal(456, result.IntVal); + } + + [Fact] + public void CanInvokeStaticWithParams() + { + // Arrange: Track a .NET object to use as an arg + var jsRuntime = new TestJSRuntime(); + var arg3 = new TestDTO { IntVal = 999, StringVal = "My string" }; + var objectRef = DotNetObjectReference.Create(arg3); + jsRuntime.Invoke("unimportant", objectRef); + + // Arrange: Remaining args + var argsJson = JsonSerializer.Serialize(new object[] + { + new TestDTO { StringVal = "Another string", IntVal = 456 }, + new[] { 100, 200 }, + objectRef + }, jsRuntime.JsonSerializerOptions); + + // Act + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, "InvocableStaticWithParams", default, default), argsJson); + var result = JsonDocument.Parse(resultJson); + var root = result.RootElement; + + // Assert: First result value marshalled via JSON + var resultDto1 = JsonSerializer.Deserialize(root[0].GetRawText(), jsRuntime.JsonSerializerOptions); + + Assert.Equal("ANOTHER STRING", resultDto1.StringVal); + Assert.Equal(756, resultDto1.IntVal); + + // Assert: Second result value marshalled by ref + var resultDto2Ref = root[1]; + Assert.False(resultDto2Ref.TryGetProperty(nameof(TestDTO.StringVal), out _)); + Assert.False(resultDto2Ref.TryGetProperty(nameof(TestDTO.IntVal), out _)); + + Assert.True(resultDto2Ref.TryGetProperty(DotNetDispatcher.DotNetObjectRefKey.EncodedUtf8Bytes, out var property)); + var resultDto2 = Assert.IsType>(jsRuntime.GetObjectReference(property.GetInt64())).Value; + Assert.Equal("MY STRING", resultDto2.StringVal); + Assert.Equal(1299, resultDto2.IntVal); + } + + [Fact] + public void InvokingWithIncorrectUseOfDotNetObjectRefThrows() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var method = nameof(SomePublicType.IncorrectDotNetObjectRefUsage); + var arg3 = new TestDTO { IntVal = 999, StringVal = "My string" }; + var objectRef = DotNetObjectReference.Create(arg3); + jsRuntime.Invoke("unimportant", objectRef); + + // Arrange: Remaining args + var argsJson = JsonSerializer.Serialize(new object[] + { + new TestDTO { StringVal = "Another string", IntVal = 456 }, + new[] { 100, 200 }, + objectRef + }, jsRuntime.JsonSerializerOptions); + + // Act & Assert + var ex = Assert.Throws(() => + DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, method, default, default), argsJson)); + Assert.Equal($"In call to '{method}', parameter of type '{nameof(TestDTO)}' at index 3 must be declared as type 'DotNetObjectRef' to receive the incoming value.", ex.Message); + } + + [Fact] + public void CanInvokeInstanceVoidMethod() + { + // Arrange: Track some instance + var jsRuntime = new TestJSRuntime(); + var targetInstance = new SomePublicType(); + var objectRef = DotNetObjectReference.Create(targetInstance); + jsRuntime.Invoke("unimportant", objectRef); + + // Act + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, "InvokableInstanceVoid", 1, default), null); + + // Assert + Assert.Null(resultJson); + Assert.True(targetInstance.DidInvokeMyInvocableInstanceVoid); + } + + [Fact] + public void CanInvokeBaseInstanceVoidMethod() + { + // Arrange: Track some instance + var jsRuntime = new TestJSRuntime(); + var targetInstance = new DerivedClass(); + var objectRef = DotNetObjectReference.Create(targetInstance); + jsRuntime.Invoke("unimportant", objectRef); + + // Act + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, "BaseClassInvokableInstanceVoid", 1, default), null); + + // Assert + Assert.Null(resultJson); + Assert.True(targetInstance.DidInvokeMyBaseClassInvocableInstanceVoid); + } + + [Fact] + public void DotNetObjectReferencesCanBeDisposed() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var targetInstance = new SomePublicType(); + var objectRef = DotNetObjectReference.Create(targetInstance); + jsRuntime.Invoke("unimportant", objectRef); + + // Act + DotNetDispatcher.BeginInvokeDotNet(jsRuntime, new DotNetInvocationInfo(null, "__Dispose", objectRef.ObjectId, default), null); + + // Assert + Assert.True(objectRef.Disposed); + } + + [Fact] + public void CannotUseDotNetObjectRefAfterDisposal() + { + // This test addresses the case where the developer calls objectRef.Dispose() + // from .NET code, as opposed to .dispose() from JS code + + // Arrange: Track some instance, then dispose it + var jsRuntime = new TestJSRuntime(); + var targetInstance = new SomePublicType(); + var objectRef = DotNetObjectReference.Create(targetInstance); + jsRuntime.Invoke("unimportant", objectRef); + objectRef.Dispose(); + + // Act/Assert + var ex = Assert.Throws( + () => DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, "InvokableInstanceVoid", 1, default), null)); + Assert.StartsWith("There is no tracked object with id '1'.", ex.Message); + } + + [Fact] + public void CannotUseDotNetObjectRefAfterReleaseDotNetObject() + { + // This test addresses the case where the developer calls .dispose() + // from JS code, as opposed to objectRef.Dispose() from .NET code + + // Arrange: Track some instance, then dispose it + var jsRuntime = new TestJSRuntime(); + var targetInstance = new SomePublicType(); + var objectRef = DotNetObjectReference.Create(targetInstance); + jsRuntime.Invoke("unimportant", objectRef); + objectRef.Dispose(); + + // Act/Assert + var ex = Assert.Throws( + () => DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, "InvokableInstanceVoid", 1, default), null)); + Assert.StartsWith("There is no tracked object with id '1'.", ex.Message); + } + + [Fact] + public void EndInvoke_WithSuccessValue() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var testDTO = new TestDTO { StringVal = "Hello", IntVal = 4 }; + var task = jsRuntime.InvokeAsync("unimportant"); + var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, true, testDTO }, jsRuntime.JsonSerializerOptions); + + // Act + DotNetDispatcher.EndInvokeJS(jsRuntime, argsJson); + + // Assert + Assert.True(task.IsCompletedSuccessfully); + var result = task.Result; + Assert.Equal(testDTO.StringVal, result.StringVal); + Assert.Equal(testDTO.IntVal, result.IntVal); + } + + [Fact] + public async Task EndInvoke_WithErrorString() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var expected = "Some error"; + var task = jsRuntime.InvokeAsync("unimportant"); + var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, expected }, jsRuntime.JsonSerializerOptions); + + // Act + DotNetDispatcher.EndInvokeJS(jsRuntime, argsJson); + + // Assert + var ex = await Assert.ThrowsAsync(async () => await task); + Assert.Equal(expected, ex.Message); + } + + [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/12357")] + public void EndInvoke_AfterCancel() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var testDTO = new TestDTO { StringVal = "Hello", IntVal = 4 }; + var cts = new CancellationTokenSource(); + var task = jsRuntime.InvokeAsync("unimportant", cts.Token); + var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, true, testDTO }, jsRuntime.JsonSerializerOptions); + + // Act + cts.Cancel(); + DotNetDispatcher.EndInvokeJS(jsRuntime, argsJson); + + // Assert + Assert.True(task.IsCanceled); + } + + [Fact] + public async Task EndInvoke_WithNullError() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var task = jsRuntime.InvokeAsync("unimportant"); + var argsJson = JsonSerializer.Serialize(new object[] { jsRuntime.LastInvocationAsyncHandle, false, null }, jsRuntime.JsonSerializerOptions); + + // Act + DotNetDispatcher.EndInvokeJS(jsRuntime, argsJson); + + // Assert + var ex = await Assert.ThrowsAsync(async () => await task); + Assert.Empty(ex.Message); + } + + [Fact] + public void CanInvokeInstanceMethodWithParams() + { + // Arrange: Track some instance plus another object we'll pass as a param + var jsRuntime = new TestJSRuntime(); + var targetInstance = new SomePublicType(); + var arg2 = new TestDTO { IntVal = 1234, StringVal = "My string" }; + jsRuntime.Invoke("unimportant", + DotNetObjectReference.Create(targetInstance), + DotNetObjectReference.Create(arg2)); + var argsJson = "[\"myvalue\",{\"__dotNetObject\":2}]"; + + // Act + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, "InvokableInstanceMethod", 1, default), argsJson); + + // Assert + Assert.Equal("[\"You passed myvalue\",{\"__dotNetObject\":3}]", resultJson); + var resultDto = ((DotNetObjectReference)jsRuntime.GetObjectReference(3)).Value; + Assert.Equal(1235, resultDto.IntVal); + Assert.Equal("MY STRING", resultDto.StringVal); + } + + [Fact] + public void CanInvokeNonGenericInstanceMethodOnGenericType() + { + var jsRuntime = new TestJSRuntime(); + var targetInstance = new GenericType(); + jsRuntime.Invoke("_setup", + DotNetObjectReference.Create(targetInstance)); + var argsJson = "[\"hello world\"]"; + + // Act + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, nameof(GenericType.EchoStringParameter), 1, default), argsJson); + + // Assert + Assert.Equal("\"hello world\"", resultJson); + } + + [Fact] + public void CanInvokeMethodsThatAcceptGenericParametersOnGenericTypes() + { + var jsRuntime = new TestJSRuntime(); + var targetInstance = new GenericType(); + jsRuntime.Invoke("_setup", + DotNetObjectReference.Create(targetInstance)); + var argsJson = "[\"hello world\"]"; + + // Act + var resultJson = DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, nameof(GenericType.EchoParameter), 1, default), argsJson); + + // Assert + Assert.Equal("\"hello world\"", resultJson); + } + + [Fact] + public void CannotInvokeStaticOpenGenericMethods() + { + var methodIdentifier = "StaticGenericMethod"; + var jsRuntime = new TestJSRuntime(); + + // Act + var ex = Assert.Throws(() => DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, methodIdentifier, 0, default), "[7]")); + Assert.Contains($"The assembly '{thisAssemblyName}' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].", ex.Message); + } + + [Fact] + public void CannotInvokeInstanceOpenGenericMethods() + { + var methodIdentifier = "InstanceGenericMethod"; + var targetInstance = new GenericType(); + var jsRuntime = new TestJSRuntime(); + jsRuntime.Invoke("_setup", + DotNetObjectReference.Create(targetInstance)); + var argsJson = "[\"hello world\"]"; + + // Act + var ex = Assert.Throws(() => DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, methodIdentifier, 1, default), argsJson)); + Assert.Contains($"The type 'GenericType`1' does not contain a public invokable method with [{nameof(JSInvokableAttribute)}(\"{methodIdentifier}\")].", ex.Message); + } + + [Fact] + public void CannotInvokeMethodsWithGenericParameters_IfTypesDoNotMatch() + { + var jsRuntime = new TestJSRuntime(); + var targetInstance = new GenericType(); + jsRuntime.Invoke("_setup", + DotNetObjectReference.Create(targetInstance)); + var argsJson = "[\"hello world\"]"; + + // Act & Assert + Assert.Throws(() => + DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(null, nameof(GenericType.EchoParameter), 1, default), argsJson)); + } + + [Fact] + public void CannotInvokeWithFewerNumberOfParameters() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var argsJson = JsonSerializer.Serialize(new object[] + { + new TestDTO { StringVal = "Another string", IntVal = 456 }, + new[] { 100, 200 }, + }, jsRuntime.JsonSerializerOptions); + + // Act/Assert + var ex = Assert.Throws(() => + { + DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, "InvocableStaticWithParams", default, default), argsJson); + }); + + Assert.Equal("The call to 'InvocableStaticWithParams' expects '3' parameters, but received '2'.", ex.Message); + } + + [Fact] + public void CannotInvokeWithMoreParameters() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var objectRef = DotNetObjectReference.Create(new TestDTO { IntVal = 4 }); + var argsJson = JsonSerializer.Serialize(new object[] + { + new TestDTO { StringVal = "Another string", IntVal = 456 }, + new[] { 100, 200 }, + objectRef, + 7, + }, jsRuntime.JsonSerializerOptions); + + // Act/Assert + var ex = Assert.Throws(() => + { + DotNetDispatcher.Invoke(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, "InvocableStaticWithParams", default, default), argsJson); + }); + + Assert.Equal("Unexpected JSON token Number. Ensure that the call to `InvocableStaticWithParams' is supplied with exactly '3' parameters.", ex.Message); + } + + [Fact] + public async Task CanInvokeAsyncMethod() + { + // Arrange: Track some instance plus another object we'll pass as a param + var jsRuntime = new TestJSRuntime(); + var targetInstance = new SomePublicType(); + var arg2 = new TestDTO { IntVal = 1234, StringVal = "My string" }; + var arg1Ref = DotNetObjectReference.Create(targetInstance); + var arg2Ref = DotNetObjectReference.Create(arg2); + jsRuntime.Invoke("unimportant", arg1Ref, arg2Ref); + + // Arrange: all args + var argsJson = JsonSerializer.Serialize(new object[] + { + new TestDTO { IntVal = 1000, StringVal = "String via JSON" }, + arg2Ref, + }, jsRuntime.JsonSerializerOptions); + + // Act + var callId = "123"; + var resultTask = jsRuntime.NextInvocationTask; + DotNetDispatcher.BeginInvokeDotNet(jsRuntime, new DotNetInvocationInfo(null, "InvokableAsyncMethod", 1, callId), argsJson); + await resultTask; + + // Assert: Correct completion information + Assert.Equal(callId, jsRuntime.LastCompletionCallId); + Assert.True(jsRuntime.LastCompletionResult.Success); + var result = Assert.IsType(jsRuntime.LastCompletionResult.Result); + var resultDto1 = Assert.IsType(result[0]); + + Assert.Equal("STRING VIA JSON", resultDto1.StringVal); + Assert.Equal(2000, resultDto1.IntVal); + + // Assert: Second result value marshalled by ref + var resultDto2Ref = Assert.IsType>(result[1]); + var resultDto2 = resultDto2Ref.Value; + Assert.Equal("MY STRING", resultDto2.StringVal); + Assert.Equal(2468, resultDto2.IntVal); + } + + [Fact] + public async Task CanInvokeSyncThrowingMethod() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + + // Act + var callId = "123"; + var resultTask = jsRuntime.NextInvocationTask; + DotNetDispatcher.BeginInvokeDotNet(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, nameof(ThrowingClass.ThrowingMethod), default, callId), default); + + await resultTask; // This won't throw, it sets properties on the jsRuntime. + + // Assert + Assert.Equal(callId, jsRuntime.LastCompletionCallId); + Assert.False(jsRuntime.LastCompletionResult.Success); // Fails + + // Make sure the method that threw the exception shows up in the call stack + // https://github.com/dotnet/aspnetcore/issues/8612 + Assert.Contains(nameof(ThrowingClass.ThrowingMethod), jsRuntime.LastCompletionResult.Exception.ToString()); + } + + [Fact] + public async Task CanInvokeAsyncThrowingMethod() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + + // Act + var callId = "123"; + var resultTask = jsRuntime.NextInvocationTask; + DotNetDispatcher.BeginInvokeDotNet(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, nameof(ThrowingClass.AsyncThrowingMethod), default, callId), default); + + await resultTask; // This won't throw, it sets properties on the jsRuntime. + + // Assert + Assert.Equal(callId, jsRuntime.LastCompletionCallId); + Assert.False(jsRuntime.LastCompletionResult.Success); // Fails + + // Make sure the method that threw the exception shows up in the call stack + // https://github.com/dotnet/aspnetcore/issues/8612 + Assert.Contains(nameof(ThrowingClass.AsyncThrowingMethod), jsRuntime.LastCompletionResult.Exception.ToString()); + } + + [Fact] + public async Task BeginInvoke_ThrowsWithInvalidArgsJson_WithCallId() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var callId = "123"; + var resultTask = jsRuntime.NextInvocationTask; + DotNetDispatcher.BeginInvokeDotNet(jsRuntime, new DotNetInvocationInfo(thisAssemblyName, "InvocableStaticWithParams", default, callId), "not json"); + + await resultTask; // This won't throw, it sets properties on the jsRuntime. + + // Assert + Assert.Equal(callId, jsRuntime.LastCompletionCallId); + Assert.False(jsRuntime.LastCompletionResult.Success); // Fails + var exception = jsRuntime.LastCompletionResult.Exception; + Assert.Contains("JsonReaderException: '<' is an invalid start of a value.", exception.ToString()); + } + + [Fact] + public void BeginInvoke_ThrowsWithInvalid_DotNetObjectRef() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var callId = "123"; + var resultTask = jsRuntime.NextInvocationTask; + DotNetDispatcher.BeginInvokeDotNet(jsRuntime, new DotNetInvocationInfo(null, "InvokableInstanceVoid", 1, callId), null); + + // Assert + Assert.Equal(callId, jsRuntime.LastCompletionCallId); + Assert.False(jsRuntime.LastCompletionResult.Success); // Fails + var exception = jsRuntime.LastCompletionResult.Exception; + Assert.StartsWith("System.ArgumentException: There is no tracked object with id '1'. Perhaps the DotNetObjectReference instance was already disposed.", exception.ToString()); + } + + [Theory] + [InlineData("")] + [InlineData("")] + public void ParseArguments_ThrowsIfJsonIsInvalid(string arguments) + { + Assert.ThrowsAny(() => DotNetDispatcher.ParseArguments(new TestJSRuntime(), "SomeMethod", arguments, new[] { typeof(string) })); + } + + [Theory] + [InlineData("{\"key\":\"value\"}")] + [InlineData("\"Test\"")] + public void ParseArguments_ThrowsIfTheArgsJsonIsNotArray(string arguments) + { + // Act & Assert + Assert.ThrowsAny(() => DotNetDispatcher.ParseArguments(new TestJSRuntime(), "SomeMethod", arguments, new[] { typeof(string) })); + } + + [Theory] + [InlineData("[\"hello\"")] + [InlineData("[\"hello\",")] + public void ParseArguments_ThrowsIfTheArgsJsonIsInvalidArray(string arguments) + { + // Act & Assert + Assert.ThrowsAny(() => DotNetDispatcher.ParseArguments(new TestJSRuntime(), "SomeMethod", arguments, new[] { typeof(string) })); + } + + [Fact] + public void ParseArguments_Works() + { + // Arrange + var arguments = "[\"Hello\", 2]"; + + // Act + var result = DotNetDispatcher.ParseArguments(new TestJSRuntime(), "SomeMethod", arguments, new[] { typeof(string), typeof(int), }); + + // Assert + Assert.Equal(new object[] { "Hello", 2 }, result); + } + + [Fact] + public void ParseArguments_SingleArgument() + { + // Arrange + var arguments = "[{\"IntVal\": 7}]"; + + // Act + var result = DotNetDispatcher.ParseArguments(new TestJSRuntime(), "SomeMethod", arguments, new[] { typeof(TestDTO), }); + + // Assert + var value = Assert.IsType(Assert.Single(result)); + Assert.Equal(7, value.IntVal); + Assert.Null(value.StringVal); + } + + [Fact] + public void ParseArguments_NullArgument() + { + // Arrange + var arguments = "[4, null]"; + + // Act + var result = DotNetDispatcher.ParseArguments(new TestJSRuntime(), "SomeMethod", arguments, new[] { typeof(int), typeof(TestDTO), }); + + // Assert + Assert.Collection( + result, + v => Assert.Equal(4, v), + v => Assert.Null(v)); + } + + [Fact] + public void ParseArguments_Throws_WithIncorrectDotNetObjectRefUsage() + { + // Arrange + var method = "SomeMethod"; + var arguments = "[4, {\"__dotNetObject\": 7}]"; + + // Act + var ex = Assert.Throws(() => DotNetDispatcher.ParseArguments(new TestJSRuntime(), method, arguments, new[] { typeof(int), typeof(TestDTO), })); + + // Assert + Assert.Equal($"In call to '{method}', parameter of type '{nameof(TestDTO)}' at index 2 must be declared as type 'DotNetObjectRef' to receive the incoming value.", ex.Message); + } + + [Fact] + public void EndInvokeJS_ThrowsIfJsonIsEmptyString() + { + Assert.ThrowsAny(() => DotNetDispatcher.EndInvokeJS(new TestJSRuntime(), "")); + } + + [Fact] + public void EndInvokeJS_ThrowsIfJsonIsNotArray() + { + Assert.ThrowsAny(() => DotNetDispatcher.EndInvokeJS(new TestJSRuntime(), "{\"key\": \"value\"}")); + } + + [Fact] + public void EndInvokeJS_ThrowsIfJsonArrayIsInComplete() + { + Assert.ThrowsAny(() => DotNetDispatcher.EndInvokeJS(new TestJSRuntime(), "[7, false")); + } + + [Fact] + public void EndInvokeJS_ThrowsIfJsonArrayHasMoreThan3Arguments() + { + Assert.ThrowsAny(() => DotNetDispatcher.EndInvokeJS(new TestJSRuntime(), "[7, false, \"Hello\", 5]")); + } + + [Fact] + public void EndInvokeJS_Works() + { + var jsRuntime = new TestJSRuntime(); + var task = jsRuntime.InvokeAsync("somemethod"); + + DotNetDispatcher.EndInvokeJS(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, {{\"intVal\": 7}}]"); + + Assert.True(task.IsCompletedSuccessfully); + Assert.Equal(7, task.Result.IntVal); + } + + [Fact] + public void EndInvokeJS_WithArrayValue() + { + var jsRuntime = new TestJSRuntime(); + var task = jsRuntime.InvokeAsync("somemethod"); + + DotNetDispatcher.EndInvokeJS(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, [1, 2, 3]]"); + + Assert.True(task.IsCompletedSuccessfully); + Assert.Equal(new[] { 1, 2, 3 }, task.Result); + } + + [Fact] + public void EndInvokeJS_WithNullValue() + { + var jsRuntime = new TestJSRuntime(); + var task = jsRuntime.InvokeAsync("somemethod"); + + DotNetDispatcher.EndInvokeJS(jsRuntime, $"[{jsRuntime.LastInvocationAsyncHandle}, true, null]"); + + Assert.True(task.IsCompletedSuccessfully); + Assert.Null(task.Result); + } + + internal class SomeInteralType + { + [JSInvokable("MethodOnInternalType")] public void MyMethod() { } + } + + public class SomePublicType + { + public static bool DidInvokeMyInvocableStaticVoid; + public bool DidInvokeMyInvocableInstanceVoid; + + [JSInvokable("PrivateMethod")] private static void MyPrivateMethod() { } + [JSInvokable("ProtectedMethod")] protected static void MyProtectedMethod() { } + protected static void StaticMethodWithoutAttribute() { } + protected static void InstanceMethodWithoutAttribute() { } + + [JSInvokable("InvocableStaticVoid")] + public static void MyInvocableVoid() + { + DidInvokeMyInvocableStaticVoid = true; + } + + [JSInvokable("InvocableStaticNonVoid")] + public static object MyInvocableNonVoid() + => new TestDTO { StringVal = "Test", IntVal = 123 }; + + [JSInvokable("InvocableStaticWithParams")] + public static object[] MyInvocableWithParams(TestDTO dtoViaJson, int[] incrementAmounts, DotNetObjectReference dtoByRef) + => new object[] + { + new TestDTO // Return via JSON marshalling + { + StringVal = dtoViaJson.StringVal.ToUpperInvariant(), + IntVal = dtoViaJson.IntVal + incrementAmounts.Sum() + }, + DotNetObjectReference.Create(new TestDTO // Return by ref + { + StringVal = dtoByRef.Value.StringVal.ToUpperInvariant(), + IntVal = dtoByRef.Value.IntVal + incrementAmounts.Sum() + }) + }; + + [JSInvokable(nameof(IncorrectDotNetObjectRefUsage))] + public static object[] IncorrectDotNetObjectRefUsage(TestDTO dtoViaJson, int[] incrementAmounts, TestDTO dtoByRef) + => throw new InvalidOperationException("Shouldn't be called"); + + [JSInvokable] + public static TestDTO InvokableMethodWithoutCustomIdentifier() + => new TestDTO { StringVal = "InvokableMethodWithoutCustomIdentifier", IntVal = 456 }; + + [JSInvokable] + public void InvokableInstanceVoid() + { + DidInvokeMyInvocableInstanceVoid = true; + } + + [JSInvokable] + public object[] InvokableInstanceMethod(string someString, DotNetObjectReference someDTORef) + { + var someDTO = someDTORef.Value; + // Returning an array to make the point that object references + // can be embedded anywhere in the result + return new object[] + { + $"You passed {someString}", + DotNetObjectReference.Create(new TestDTO + { + IntVal = someDTO.IntVal + 1, + StringVal = someDTO.StringVal.ToUpperInvariant() + }) + }; + } + + [JSInvokable] + public async Task InvokableAsyncMethod(TestDTO dtoViaJson, DotNetObjectReference dtoByRefWrapper) + { + await Task.Delay(50); + var dtoByRef = dtoByRefWrapper.Value; + return new object[] + { + new TestDTO // Return via JSON + { + StringVal = dtoViaJson.StringVal.ToUpperInvariant(), + IntVal = dtoViaJson.IntVal * 2, + }, + DotNetObjectReference.Create(new TestDTO // Return by ref + { + StringVal = dtoByRef.StringVal.ToUpperInvariant(), + IntVal = dtoByRef.IntVal * 2, + }) + }; + } + } + + public class BaseClass + { + public bool DidInvokeMyBaseClassInvocableInstanceVoid; + + [JSInvokable] + public void BaseClassInvokableInstanceVoid() + { + DidInvokeMyBaseClassInvocableInstanceVoid = true; + } + } + + public class DerivedClass : BaseClass + { + } + + public class TestDTO + { + public string StringVal { get; set; } + public int IntVal { get; set; } + } + + public class ThrowingClass + { + [JSInvokable] + public static string ThrowingMethod() + { + throw new InvalidTimeZoneException(); + } + + [JSInvokable] + public static async Task AsyncThrowingMethod() + { + await Task.Yield(); + throw new InvalidTimeZoneException(); + } + } + + public class GenericType + { + [JSInvokable] public string EchoStringParameter(string input) => input; + [JSInvokable] public TValue EchoParameter(TValue input) => input; + } + + public class GenericMethodClass + { + [JSInvokable("StaticGenericMethod")] public static string StaticGenericMethod(TValue input) => input.ToString(); + [JSInvokable("InstanceGenericMethod")] public string GenericMethod(TValue input) => input.ToString(); + } + + public class TestJSRuntime : JSInProcessRuntime + { + private TaskCompletionSource _nextInvocationTcs = new TaskCompletionSource(); + public Task NextInvocationTask => _nextInvocationTcs.Task; + public long LastInvocationAsyncHandle { get; private set; } + public string LastInvocationIdentifier { get; private set; } + public string LastInvocationArgsJson { get; private set; } + + public string LastCompletionCallId { get; private set; } + public DotNetInvocationResult LastCompletionResult { get; private set; } + + protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) + { + LastInvocationAsyncHandle = asyncHandle; + LastInvocationIdentifier = identifier; + LastInvocationArgsJson = argsJson; + _nextInvocationTcs.SetResult(null); + _nextInvocationTcs = new TaskCompletionSource(); + } + + protected override string InvokeJS(string identifier, string argsJson) + { + LastInvocationAsyncHandle = default; + LastInvocationIdentifier = identifier; + LastInvocationArgsJson = argsJson; + _nextInvocationTcs.SetResult(null); + _nextInvocationTcs = new TaskCompletionSource(); + return null; + } + + protected internal override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) + { + LastCompletionCallId = invocationInfo.CallId; + LastCompletionResult = invocationResult; + _nextInvocationTcs.SetResult(null); + _nextInvocationTcs = new TaskCompletionSource(); + } + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetObjectReferenceJsonConverterTest.cs b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetObjectReferenceJsonConverterTest.cs new file mode 100644 index 000000000000..8d055aea2cae --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetObjectReferenceJsonConverterTest.cs @@ -0,0 +1,151 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Json; +using Xunit; + +namespace Microsoft.JSInterop.Infrastructure +{ + public class DotNetObjectReferenceJsonConverterTest + { + private readonly JSRuntime JSRuntime = new TestJSRuntime(); + private JsonSerializerOptions JsonSerializerOptions => JSRuntime.JsonSerializerOptions; + + [Fact] + public void Read_Throws_IfJsonIsMissingDotNetObjectProperty() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var dotNetObjectRef = DotNetObjectReference.Create(new TestModel()); + + var json = "{}"; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, JsonSerializerOptions)); + Assert.Equal("Required property __dotNetObject not found.", ex.Message); + } + + [Fact] + public void Read_Throws_IfJsonContainsUnknownContent() + { + // Arrange + var jsRuntime = new TestJSRuntime(); + var dotNetObjectRef = DotNetObjectReference.Create(new TestModel()); + + var json = "{\"foo\":2}"; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize>(json, JsonSerializerOptions)); + Assert.Equal("Unexcepted JSON property foo.", ex.Message); + } + + [Fact] + public void Read_Throws_IfJsonIsIncomplete() + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectReference.Create(input); + var objectId = JSRuntime.TrackObjectReference(dotNetObjectRef); + + var json = $"{{\"__dotNetObject\":{objectId}"; + + // Act & Assert + var ex = Record.Exception(() => JsonSerializer.Deserialize>(json, JsonSerializerOptions)); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public void Read_Throws_IfDotNetObjectIdAppearsMultipleTimes() + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectReference.Create(input); + var objectId = JSRuntime.TrackObjectReference(dotNetObjectRef); + + var json = $"{{\"__dotNetObject\":{objectId},\"__dotNetObject\":{objectId}}}"; + + // Act & Assert + var ex = Record.Exception(() => JsonSerializer.Deserialize>(json, JsonSerializerOptions)); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public void Read_ReadsJson() + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectReference.Create(input); + var objectId = JSRuntime.TrackObjectReference(dotNetObjectRef); + + var json = $"{{\"__dotNetObject\":{objectId}}}"; + + // Act + var deserialized = JsonSerializer.Deserialize>(json, JsonSerializerOptions); + + // Assert + Assert.Same(input, deserialized.Value); + Assert.Equal(objectId, deserialized.ObjectId); + } + + [Fact] + public void Read_ReturnsTheCorrectInstance() + { + // Arrange + // Track a few instances and verify that the deserialized value returns the correct value. + var instance1 = new TestModel(); + var instance2 = new TestModel(); + var ref1 = DotNetObjectReference.Create(instance1); + var ref2 = DotNetObjectReference.Create(instance2); + + var json = $"[{{\"__dotNetObject\":{JSRuntime.TrackObjectReference(ref1)}}},{{\"__dotNetObject\":{JSRuntime.TrackObjectReference(ref2)}}}]"; + + // Act + var deserialized = JsonSerializer.Deserialize[]>(json, JsonSerializerOptions); + + // Assert + Assert.Same(instance1, deserialized[0].Value); + Assert.Same(instance2, deserialized[1].Value); + } + + [Fact] + public void Read_ReadsJson_WithFormatting() + { + // Arrange + var input = new TestModel(); + var dotNetObjectRef = DotNetObjectReference.Create(input); + var objectId = JSRuntime.TrackObjectReference(dotNetObjectRef); + + var json = +@$"{{ + ""__dotNetObject"": {objectId} +}}"; + + // Act + var deserialized = JsonSerializer.Deserialize>(json, JsonSerializerOptions); + + // Assert + Assert.Same(input, deserialized.Value); + Assert.Equal(objectId, deserialized.ObjectId); + } + + [Fact] + public void WriteJsonTwice_KeepsObjectId() + { + // Arrange + var dotNetObjectRef = DotNetObjectReference.Create(new TestModel()); + + // Act + var json1 = JsonSerializer.Serialize(dotNetObjectRef, JsonSerializerOptions); + var json2 = JsonSerializer.Serialize(dotNetObjectRef, JsonSerializerOptions); + + // Assert + Assert.Equal($"{{\"__dotNetObject\":{dotNetObjectRef.ObjectId}}}", json1); + Assert.Equal(json1, json2); + } + + private class TestModel + { + + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/JSInProcessRuntimeExtensionsTest.cs b/src/JSInterop/Microsoft.JSInterop/test/JSInProcessRuntimeExtensionsTest.cs new file mode 100644 index 000000000000..3a7f0a4d7905 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/JSInProcessRuntimeExtensionsTest.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace Microsoft.JSInterop +{ + public class JSInProcessRuntimeExtensionsTest + { + [Fact] + public void InvokeVoid_Works() + { + // Arrange + var method = "someMethod"; + var args = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.Invoke(method, args)).Returns(new ValueTask(new object())); + + // Act + jsRuntime.Object.InvokeVoid(method, args); + + jsRuntime.Verify(); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/JSInProcessRuntimeTest.cs b/src/JSInterop/Microsoft.JSInterop/test/JSInProcessRuntimeTest.cs new file mode 100644 index 000000000000..f42e0801a097 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/JSInProcessRuntimeTest.cs @@ -0,0 +1,121 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.JSInterop.Infrastructure; +using Xunit; + +namespace Microsoft.JSInterop +{ + public class JSInProcessRuntimeBaseTest + { + [Fact] + public void DispatchesSyncCallsAndDeserializesResults() + { + // Arrange + var runtime = new TestJSInProcessRuntime + { + NextResultJson = "{\"intValue\":123,\"stringValue\":\"Hello\"}" + }; + + // Act + var syncResult = runtime.Invoke("test identifier 1", "arg1", 123, true); + var call = runtime.InvokeCalls.Single(); + + // Assert + Assert.Equal(123, syncResult.IntValue); + Assert.Equal("Hello", syncResult.StringValue); + Assert.Equal("test identifier 1", call.Identifier); + Assert.Equal("[\"arg1\",123,true]", call.ArgsJson); + } + + [Fact] + public void SerializesDotNetObjectWrappersInKnownFormat() + { + // Arrange + var runtime = new TestJSInProcessRuntime { NextResultJson = null }; + var obj1 = new object(); + var obj2 = new object(); + var obj3 = new object(); + + // Act + // Showing we can pass the DotNetObject either as top-level args or nested + var syncResult = runtime.Invoke>("test identifier", + DotNetObjectReference.Create(obj1), + new Dictionary + { + { "obj2", DotNetObjectReference.Create(obj2) }, + { "obj3", DotNetObjectReference.Create(obj3) }, + }); + + // Assert: Handles null result string + Assert.Null(syncResult); + + // Assert: Serialized as expected + var call = runtime.InvokeCalls.Single(); + Assert.Equal("test identifier", call.Identifier); + Assert.Equal("[{\"__dotNetObject\":1},{\"obj2\":{\"__dotNetObject\":2},\"obj3\":{\"__dotNetObject\":3}}]", call.ArgsJson); + + // Assert: Objects were tracked + Assert.Same(obj1, runtime.GetObjectReference(1).Value); + Assert.Same(obj2, runtime.GetObjectReference(2).Value); + Assert.Same(obj3, runtime.GetObjectReference(3).Value); + } + + [Fact] + public void SyncCallResultCanIncludeDotNetObjects() + { + // Arrange + var runtime = new TestJSInProcessRuntime + { + NextResultJson = "[{\"__dotNetObject\":2},{\"__dotNetObject\":1}]" + }; + var obj1 = new object(); + var obj2 = new object(); + + // Act + var syncResult = runtime.Invoke[]>( + "test identifier", + DotNetObjectReference.Create(obj1), + "some other arg", + DotNetObjectReference.Create(obj2)); + var call = runtime.InvokeCalls.Single(); + + // Assert + Assert.Equal(new[] { obj2, obj1 }, syncResult.Select(r => r.Value)); + } + + class TestDTO + { + public int IntValue { get; set; } + public string StringValue { get; set; } + } + + class TestJSInProcessRuntime : JSInProcessRuntime + { + public List InvokeCalls { get; set; } = new List(); + + public string NextResultJson { get; set; } + + protected override string InvokeJS(string identifier, string argsJson) + { + InvokeCalls.Add(new InvokeArgs { Identifier = identifier, ArgsJson = argsJson }); + return NextResultJson; + } + + public class InvokeArgs + { + public string Identifier { get; set; } + public string ArgsJson { get; set; } + } + + protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) + => throw new NotImplementedException("This test only covers sync calls"); + + protected internal override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) + => throw new NotImplementedException("This test only covers sync calls"); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeExtensionsTest.cs b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeExtensionsTest.cs new file mode 100644 index 000000000000..a5f69fbef20e --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeExtensionsTest.cs @@ -0,0 +1,181 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace Microsoft.JSInterop +{ + public class JSRuntimeExtensionsTest + { + [Fact] + public async Task InvokeAsync_WithParamsArgs() + { + // Arrange + var method = "someMethod"; + var expected = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, It.IsAny())) + .Callback((method, args) => + { + Assert.Equal(expected, args); + }) + .Returns(new ValueTask("Hello")) + .Verifiable(); + + // Act + var result = await jsRuntime.Object.InvokeAsync(method, "a", "b"); + + // Assert + Assert.Equal("Hello", result); + jsRuntime.Verify(); + } + + [Fact] + public async Task InvokeAsync_WithParamsArgsAndCancellationToken() + { + // Arrange + var method = "someMethod"; + var expected = new[] { "a", "b" }; + var cancellationToken = new CancellationToken(); + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, cancellationToken, It.IsAny())) + .Callback((method, cts, args) => + { + Assert.Equal(expected, args); + }) + .Returns(new ValueTask("Hello")) + .Verifiable(); + + // Act + var result = await jsRuntime.Object.InvokeAsync(method, cancellationToken, "a", "b"); + + // Assert + Assert.Equal("Hello", result); + jsRuntime.Verify(); + } + + [Fact] + public async Task InvokeVoidAsync_WithoutCancellationToken() + { + // Arrange + var method = "someMethod"; + var args = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, args)).Returns(new ValueTask(new object())); + + // Act + await jsRuntime.Object.InvokeVoidAsync(method, args); + + jsRuntime.Verify(); + } + + [Fact] + public async Task InvokeVoidAsync_WithCancellationToken() + { + // Arrange + var method = "someMethod"; + var args = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, It.IsAny(), args)).Returns(new ValueTask(new object())); + + // Act + await jsRuntime.Object.InvokeVoidAsync(method, new CancellationToken(), args); + + jsRuntime.Verify(); + } + + [Fact] + public async Task InvokeAsync_WithTimeout() + { + // Arrange + var expected = "Hello"; + var method = "someMethod"; + var args = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, It.IsAny(), args)) + .Callback((method, cts, args) => + { + // There isn't a very good way to test when the cts will cancel. We'll just verify that + // it'll get cancelled eventually. + Assert.True(cts.CanBeCanceled); + }) + .Returns(new ValueTask(expected)); + + // Act + var result = await jsRuntime.Object.InvokeAsync(method, TimeSpan.FromMinutes(5), args); + + Assert.Equal(expected, result); + jsRuntime.Verify(); + } + + [Fact] + public async Task InvokeAsync_WithInfiniteTimeout() + { + // Arrange + var expected = "Hello"; + var method = "someMethod"; + var args = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, It.IsAny(), args)) + .Callback((method, cts, args) => + { + Assert.False(cts.CanBeCanceled); + Assert.True(cts == CancellationToken.None); + }) + .Returns(new ValueTask(expected)); + + // Act + var result = await jsRuntime.Object.InvokeAsync(method, Timeout.InfiniteTimeSpan, args); + + Assert.Equal(expected, result); + jsRuntime.Verify(); + } + + [Fact] + public async Task InvokeVoidAsync_WithTimeout() + { + // Arrange + var method = "someMethod"; + var args = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, It.IsAny(), args)) + .Callback((method, cts, args) => + { + // There isn't a very good way to test when the cts will cancel. We'll just verify that + // it'll get cancelled eventually. + Assert.True(cts.CanBeCanceled); + }) + .Returns(new ValueTask(new object())); + + // Act + await jsRuntime.Object.InvokeVoidAsync(method, TimeSpan.FromMinutes(5), args); + + jsRuntime.Verify(); + } + + [Fact] + public async Task InvokeVoidAsync_WithInfiniteTimeout() + { + // Arrange + var method = "someMethod"; + var args = new[] { "a", "b" }; + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(s => s.InvokeAsync(method, It.IsAny(), args)) + .Callback((method, cts, args) => + { + Assert.False(cts.CanBeCanceled); + Assert.True(cts == CancellationToken.None); + }) + .Returns(new ValueTask(new object())); + + // Act + await jsRuntime.Object.InvokeVoidAsync(method, Timeout.InfiniteTimeSpan, args); + + jsRuntime.Verify(); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeTest.cs b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeTest.cs new file mode 100644 index 000000000000..66e0033d2a35 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeTest.cs @@ -0,0 +1,391 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.JSInterop.Infrastructure; +using Xunit; + +namespace Microsoft.JSInterop +{ + public class JSRuntimeTest + { + [Fact] + public void DispatchesAsyncCallsWithDistinctAsyncHandles() + { + // Arrange + var runtime = new TestJSRuntime(); + + // Act + runtime.InvokeAsync("test identifier 1", "arg1", 123, true); + runtime.InvokeAsync("test identifier 2", "some other arg"); + + // Assert + Assert.Collection(runtime.BeginInvokeCalls, + call => + { + Assert.Equal("test identifier 1", call.Identifier); + Assert.Equal("[\"arg1\",123,true]", call.ArgsJson); + }, + call => + { + Assert.Equal("test identifier 2", call.Identifier); + Assert.Equal("[\"some other arg\"]", call.ArgsJson); + Assert.NotEqual(runtime.BeginInvokeCalls[0].AsyncHandle, call.AsyncHandle); + }); + } + + [Fact] + public async Task InvokeAsync_CancelsAsyncTask_AfterDefaultTimeout() + { + // Arrange + var runtime = new TestJSRuntime(); + runtime.DefaultTimeout = TimeSpan.FromSeconds(1); + + // Act + var task = runtime.InvokeAsync("test identifier 1", "arg1", 123, true); + + // Assert + await Assert.ThrowsAsync(async () => await task); + } + + [Fact] + public void InvokeAsync_CompletesSuccessfullyBeforeTimeout() + { + // Arrange + var runtime = new TestJSRuntime(); + runtime.DefaultTimeout = TimeSpan.FromSeconds(10); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes("null")); + + // Act + var task = runtime.InvokeAsync("test identifier 1", "arg1", 123, true); + + runtime.EndInvokeJS(2, succeeded: true, ref reader); + + Assert.True(task.IsCompletedSuccessfully); + } + + [Fact] + public async Task InvokeAsync_CancelsAsyncTasksWhenCancellationTokenFires() + { + // Arrange + using var cts = new CancellationTokenSource(); + var runtime = new TestJSRuntime(); + + // Act + var task = runtime.InvokeAsync("test identifier 1", cts.Token, new object[] { "arg1", 123, true }); + + cts.Cancel(); + + // Assert + await Assert.ThrowsAsync(async () => await task); + } + + [Fact] + public async Task InvokeAsync_DoesNotStartWorkWhenCancellationHasBeenRequested() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var runtime = new TestJSRuntime(); + + // Act + var task = runtime.InvokeAsync("test identifier 1", cts.Token, new object[] { "arg1", 123, true }); + + cts.Cancel(); + + // Assert + await Assert.ThrowsAsync(async () => await task); + Assert.Empty(runtime.BeginInvokeCalls); + } + + [Fact] + public void CanCompleteAsyncCallsAsSuccess() + { + // Arrange + var runtime = new TestJSRuntime(); + + // Act/Assert: Tasks not initially completed + var unrelatedTask = runtime.InvokeAsync("unrelated call", Array.Empty()); + var task = runtime.InvokeAsync("test identifier", Array.Empty()); + Assert.False(unrelatedTask.IsCompleted); + Assert.False(task.IsCompleted); + var bytes = Encoding.UTF8.GetBytes("\"my result\""); + var reader = new Utf8JsonReader(bytes); + + // Act/Assert: Task can be completed + runtime.EndInvokeJS( + runtime.BeginInvokeCalls[1].AsyncHandle, + /* succeeded: */ true, + ref reader); + Assert.False(unrelatedTask.IsCompleted); + Assert.True(task.IsCompleted); + Assert.Equal("my result", task.Result); + } + + [Fact] + public void CanCompleteAsyncCallsWithComplexType() + { + // Arrange + var runtime = new TestJSRuntime(); + + var task = runtime.InvokeAsync("test identifier", Array.Empty()); + var bytes = Encoding.UTF8.GetBytes("{\"id\":10, \"name\": \"Test\"}"); + var reader = new Utf8JsonReader(bytes); + + // Act/Assert: Task can be completed + runtime.EndInvokeJS( + runtime.BeginInvokeCalls[0].AsyncHandle, + /* succeeded: */ true, + ref reader); + Assert.True(task.IsCompleted); + var poco = task.Result; + Assert.Equal(10, poco.Id); + Assert.Equal("Test", poco.Name); + } + + [Fact] + public void CanCompleteAsyncCallsWithComplexTypeUsingPropertyCasing() + { + // Arrange + var runtime = new TestJSRuntime(); + + var task = runtime.InvokeAsync("test identifier", Array.Empty()); + var bytes = Encoding.UTF8.GetBytes("{\"Id\":10, \"Name\": \"Test\"}"); + var reader = new Utf8JsonReader(bytes); + reader.Read(); + + // Act/Assert: Task can be completed + runtime.EndInvokeJS( + runtime.BeginInvokeCalls[0].AsyncHandle, + /* succeeded: */ true, + ref reader); + Assert.True(task.IsCompleted); + var poco = task.Result; + Assert.Equal(10, poco.Id); + Assert.Equal("Test", poco.Name); + } + + [Fact] + public void CanCompleteAsyncCallsAsFailure() + { + // Arrange + var runtime = new TestJSRuntime(); + + // Act/Assert: Tasks not initially completed + var unrelatedTask = runtime.InvokeAsync("unrelated call", Array.Empty()); + var task = runtime.InvokeAsync("test identifier", Array.Empty()); + Assert.False(unrelatedTask.IsCompleted); + Assert.False(task.IsCompleted); + var bytes = Encoding.UTF8.GetBytes("\"This is a test exception\""); + var reader = new Utf8JsonReader(bytes); + reader.Read(); + + // Act/Assert: Task can be failed + runtime.EndInvokeJS( + runtime.BeginInvokeCalls[1].AsyncHandle, + /* succeeded: */ false, + ref reader); + Assert.False(unrelatedTask.IsCompleted); + Assert.True(task.IsCompleted); + + var exception = Assert.IsType(task.AsTask().Exception); + var jsException = Assert.IsType(exception.InnerException); + Assert.Equal("This is a test exception", jsException.Message); + } + + [Fact] + public Task CanCompleteAsyncCallsWithErrorsDuringDeserialization() + { + // Arrange + var runtime = new TestJSRuntime(); + + // Act/Assert: Tasks not initially completed + var unrelatedTask = runtime.InvokeAsync("unrelated call", Array.Empty()); + var task = runtime.InvokeAsync("test identifier", Array.Empty()); + Assert.False(unrelatedTask.IsCompleted); + Assert.False(task.IsCompleted); + var bytes = Encoding.UTF8.GetBytes("Not a string"); + var reader = new Utf8JsonReader(bytes); + + // Act/Assert: Task can be failed + runtime.EndInvokeJS( + runtime.BeginInvokeCalls[1].AsyncHandle, + /* succeeded: */ true, + ref reader); + Assert.False(unrelatedTask.IsCompleted); + + return AssertTask(); + + async Task AssertTask() + { + var jsException = await Assert.ThrowsAsync(async () => await task); + Assert.IsAssignableFrom(jsException.InnerException); + } + } + + [Fact] + public Task CompletingSameAsyncCallMoreThanOnce_IgnoresSecondResultAsync() + { + // Arrange + var runtime = new TestJSRuntime(); + + // Act/Assert + var task = runtime.InvokeAsync("test identifier", Array.Empty()); + var asyncHandle = runtime.BeginInvokeCalls[0].AsyncHandle; + var firstReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Some data\"")); + var secondReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"Exception\"")); + + runtime.EndInvokeJS(asyncHandle, true, ref firstReader); + runtime.EndInvokeJS(asyncHandle, false, ref secondReader); + + return AssertTask(); + + async Task AssertTask() + { + var result = await task; + Assert.Equal("Some data", result); + } + } + + [Fact] + public void SerializesDotNetObjectWrappersInKnownFormat() + { + // Arrange + var runtime = new TestJSRuntime(); + var obj1 = new object(); + var obj2 = new object(); + var obj3 = new object(); + + // Act + // Showing we can pass the DotNetObject either as top-level args or nested + var obj1Ref = DotNetObjectReference.Create(obj1); + var obj1DifferentRef = DotNetObjectReference.Create(obj1); + runtime.InvokeAsync("test identifier", + obj1Ref, + new Dictionary + { + { "obj2", DotNetObjectReference.Create(obj2) }, + { "obj3", DotNetObjectReference.Create(obj3) }, + { "obj1SameRef", obj1Ref }, + { "obj1DifferentRef", obj1DifferentRef }, + }); + + // Assert: Serialized as expected + var call = runtime.BeginInvokeCalls.Single(); + Assert.Equal("test identifier", call.Identifier); + Assert.Equal("[{\"__dotNetObject\":1},{\"obj2\":{\"__dotNetObject\":2},\"obj3\":{\"__dotNetObject\":3},\"obj1SameRef\":{\"__dotNetObject\":1},\"obj1DifferentRef\":{\"__dotNetObject\":4}}]", call.ArgsJson); + + // Assert: Objects were tracked + Assert.Same(obj1Ref, runtime.GetObjectReference(1)); + Assert.Same(obj1, obj1Ref.Value); + Assert.NotSame(obj1Ref, runtime.GetObjectReference(2)); + Assert.Same(obj2, runtime.GetObjectReference(2).Value); + Assert.Same(obj3, runtime.GetObjectReference(3).Value); + Assert.Same(obj1, runtime.GetObjectReference(4).Value); + } + + [Fact] + public void CanSanitizeDotNetInteropExceptions() + { + // Arrange + var expectedMessage = "An error ocurred while invoking '[Assembly]::Method'. Swapping to 'Development' environment will " + + "display more detailed information about the error that occurred."; + + string GetMessage(DotNetInvocationInfo info) => $"An error ocurred while invoking '[{info.AssemblyName}]::{info.MethodIdentifier}'. Swapping to 'Development' environment will " + + "display more detailed information about the error that occurred."; + + var runtime = new TestJSRuntime() + { + OnDotNetException = (invocationInfo) => new JSError { Message = GetMessage(invocationInfo) } + }; + + var exception = new Exception("Some really sensitive data in here"); + var invocation = new DotNetInvocationInfo("Assembly", "Method", 0, "0"); + var result = new DotNetInvocationResult(exception, default); + + // Act + runtime.EndInvokeDotNet(invocation, result); + + // Assert + var call = runtime.EndInvokeDotNetCalls.Single(); + Assert.Equal("0", call.CallId); + Assert.False(call.Success); + var jsError = Assert.IsType(call.ResultOrError); + Assert.Equal(expectedMessage, jsError.Message); + } + + private class JSError + { + public string Message { get; set; } + } + + private class TestPoco + { + public int Id { get; set; } + + public string Name { get; set; } + } + + class TestJSRuntime : JSRuntime + { + public List BeginInvokeCalls = new List(); + public List EndInvokeDotNetCalls = new List(); + + public TimeSpan? DefaultTimeout + { + set + { + base.DefaultAsyncTimeout = value; + } + } + + public class BeginInvokeAsyncArgs + { + public long AsyncHandle { get; set; } + public string Identifier { get; set; } + public string ArgsJson { get; set; } + } + + public class EndInvokeDotNetArgs + { + public string CallId { get; set; } + public bool Success { get; set; } + public object ResultOrError { get; set; } + } + + public Func OnDotNetException { get; set; } + + protected internal override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) + { + var resultOrError = invocationResult.Success ? invocationResult.Result : invocationResult.Exception; + if (OnDotNetException != null && !invocationResult.Success) + { + resultOrError = OnDotNetException(invocationInfo); + } + + EndInvokeDotNetCalls.Add(new EndInvokeDotNetArgs + { + CallId = invocationInfo.CallId, + Success = invocationResult.Success, + ResultOrError = resultOrError, + }); + } + + protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) + { + BeginInvokeCalls.Add(new BeginInvokeAsyncArgs + { + AsyncHandle = asyncHandle, + Identifier = identifier, + ArgsJson = argsJson, + }); + } + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/Microsoft.JSInterop.Tests.csproj b/src/JSInterop/Microsoft.JSInterop/test/Microsoft.JSInterop.Tests.csproj new file mode 100644 index 000000000000..ba3a91a94eb3 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/Microsoft.JSInterop.Tests.csproj @@ -0,0 +1,15 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + + + + + + + + + diff --git a/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs b/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs new file mode 100644 index 000000000000..db9c5ddd36bf --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.JSInterop.Infrastructure; + +namespace Microsoft.JSInterop +{ + internal class TestJSRuntime : JSRuntime + { + protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) + { + throw new NotImplementedException(); + } + + protected internal override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/xunit.runner.json b/src/JSInterop/Microsoft.JSInterop/test/xunit.runner.json new file mode 100644 index 000000000000..0f2ad9f7698f --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "shadowCopy": true +} diff --git a/src/JSInterop/README.md b/src/JSInterop/README.md new file mode 100644 index 000000000000..dcdaf7618e74 --- /dev/null +++ b/src/JSInterop/README.md @@ -0,0 +1,8 @@ +# jsinterop + +This repo is for `Microsoft.JSInterop`, a package that provides abstractions and features for interop between .NET and JavaScript code. + +## Usage + +The primary use case is for applications built with Mono WebAssembly or Blazor. It's not expected that developers will typically use these libraries separately from Mono WebAssembly, Blazor, or a similar technology. + diff --git a/src/JSInterop/startvs.cmd b/src/JSInterop/startvs.cmd new file mode 100644 index 000000000000..9812a275beb6 --- /dev/null +++ b/src/JSInterop/startvs.cmd @@ -0,0 +1,3 @@ +@ECHO OFF + +%~dp0..\..\startvs.cmd %~dp0JSInterop.slnf \ No newline at end of file diff --git a/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.csproj b/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.csproj new file mode 100644 index 000000000000..af97cae5940d --- /dev/null +++ b/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.csproj @@ -0,0 +1,14 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + diff --git a/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.netcoreapp.cs b/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.netcoreapp.cs new file mode 100644 index 000000000000..3b6186e98fa7 --- /dev/null +++ b/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.netcoreapp.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Localization +{ + public partial interface IStringLocalizer + { + Microsoft.Extensions.Localization.LocalizedString this[string name] { get; } + Microsoft.Extensions.Localization.LocalizedString this[string name, params object[] arguments] { get; } + System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures); + } + public partial interface IStringLocalizerFactory + { + Microsoft.Extensions.Localization.IStringLocalizer Create(string baseName, string location); + Microsoft.Extensions.Localization.IStringLocalizer Create(System.Type resourceSource); + } + public partial interface IStringLocalizer : Microsoft.Extensions.Localization.IStringLocalizer + { + } + public partial class LocalizedString + { + public LocalizedString(string name, string value) { } + public LocalizedString(string name, string value, bool resourceNotFound) { } + public LocalizedString(string name, string value, bool resourceNotFound, string searchedLocation) { } + public string Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public bool ResourceNotFound { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string SearchedLocation { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Value { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public static implicit operator string (Microsoft.Extensions.Localization.LocalizedString localizedString) { throw null; } + public override string ToString() { throw null; } + } + public static partial class StringLocalizerExtensions + { + public static System.Collections.Generic.IEnumerable GetAllStrings(this Microsoft.Extensions.Localization.IStringLocalizer stringLocalizer) { throw null; } + public static Microsoft.Extensions.Localization.LocalizedString GetString(this Microsoft.Extensions.Localization.IStringLocalizer stringLocalizer, string name) { throw null; } + public static Microsoft.Extensions.Localization.LocalizedString GetString(this Microsoft.Extensions.Localization.IStringLocalizer stringLocalizer, string name, params object[] arguments) { throw null; } + } + public partial class StringLocalizer : Microsoft.Extensions.Localization.IStringLocalizer, Microsoft.Extensions.Localization.IStringLocalizer + { + public StringLocalizer(Microsoft.Extensions.Localization.IStringLocalizerFactory factory) { } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name] { get { throw null; } } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name, params object[] arguments] { get { throw null; } } + public System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures) { throw null; } + } +} diff --git a/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.netstandard2.0.cs b/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.netstandard2.0.cs new file mode 100644 index 000000000000..3b6186e98fa7 --- /dev/null +++ b/src/Localization/Abstractions/ref/Microsoft.Extensions.Localization.Abstractions.netstandard2.0.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Localization +{ + public partial interface IStringLocalizer + { + Microsoft.Extensions.Localization.LocalizedString this[string name] { get; } + Microsoft.Extensions.Localization.LocalizedString this[string name, params object[] arguments] { get; } + System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures); + } + public partial interface IStringLocalizerFactory + { + Microsoft.Extensions.Localization.IStringLocalizer Create(string baseName, string location); + Microsoft.Extensions.Localization.IStringLocalizer Create(System.Type resourceSource); + } + public partial interface IStringLocalizer : Microsoft.Extensions.Localization.IStringLocalizer + { + } + public partial class LocalizedString + { + public LocalizedString(string name, string value) { } + public LocalizedString(string name, string value, bool resourceNotFound) { } + public LocalizedString(string name, string value, bool resourceNotFound, string searchedLocation) { } + public string Name { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public bool ResourceNotFound { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string SearchedLocation { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Value { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public static implicit operator string (Microsoft.Extensions.Localization.LocalizedString localizedString) { throw null; } + public override string ToString() { throw null; } + } + public static partial class StringLocalizerExtensions + { + public static System.Collections.Generic.IEnumerable GetAllStrings(this Microsoft.Extensions.Localization.IStringLocalizer stringLocalizer) { throw null; } + public static Microsoft.Extensions.Localization.LocalizedString GetString(this Microsoft.Extensions.Localization.IStringLocalizer stringLocalizer, string name) { throw null; } + public static Microsoft.Extensions.Localization.LocalizedString GetString(this Microsoft.Extensions.Localization.IStringLocalizer stringLocalizer, string name, params object[] arguments) { throw null; } + } + public partial class StringLocalizer : Microsoft.Extensions.Localization.IStringLocalizer, Microsoft.Extensions.Localization.IStringLocalizer + { + public StringLocalizer(Microsoft.Extensions.Localization.IStringLocalizerFactory factory) { } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name] { get { throw null; } } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name, params object[] arguments] { get { throw null; } } + public System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures) { throw null; } + } +} diff --git a/src/Localization/Abstractions/src/IStringLocalizer.cs b/src/Localization/Abstractions/src/IStringLocalizer.cs new file mode 100644 index 000000000000..206092cd2d64 --- /dev/null +++ b/src/Localization/Abstractions/src/IStringLocalizer.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Microsoft.Extensions.Localization +{ + /// + /// Represents a service that provides localized strings. + /// + public interface IStringLocalizer + { + /// + /// Gets the string resource with the given name. + /// + /// The name of the string resource. + /// The string resource as a . + LocalizedString this[string name] { get; } + + /// + /// Gets the string resource with the given name and formatted with the supplied arguments. + /// + /// The name of the string resource. + /// The values to format the string with. + /// The formatted string resource as a . + LocalizedString this[string name, params object[] arguments] { get; } + + /// + /// Gets all string resources. + /// + /// + /// A indicating whether to include strings from parent cultures. + /// + /// The strings. + IEnumerable GetAllStrings(bool includeParentCultures); + } +} diff --git a/src/Localization/Abstractions/src/IStringLocalizerFactory.cs b/src/Localization/Abstractions/src/IStringLocalizerFactory.cs new file mode 100644 index 000000000000..559fa69c3033 --- /dev/null +++ b/src/Localization/Abstractions/src/IStringLocalizerFactory.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Localization +{ + /// + /// Represents a factory that creates instances. + /// + public interface IStringLocalizerFactory + { + /// + /// Creates an using the and + /// of the specified . + /// + /// The . + /// The . + IStringLocalizer Create(Type resourceSource); + + /// + /// Creates an . + /// + /// The base name of the resource to load strings from. + /// The location to load resources from. + /// The . + IStringLocalizer Create(string baseName, string location); + } +} \ No newline at end of file diff --git a/src/Localization/Abstractions/src/IStringLocalizerOfT.cs b/src/Localization/Abstractions/src/IStringLocalizerOfT.cs new file mode 100644 index 000000000000..bdc2a1c7b72b --- /dev/null +++ b/src/Localization/Abstractions/src/IStringLocalizerOfT.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Localization +{ + /// + /// Represents an that provides strings for . + /// + /// The to provide strings for. + public interface IStringLocalizer : IStringLocalizer + { + + } +} diff --git a/src/Localization/Abstractions/src/LocalizedString.cs b/src/Localization/Abstractions/src/LocalizedString.cs new file mode 100644 index 000000000000..7cac58d16add --- /dev/null +++ b/src/Localization/Abstractions/src/LocalizedString.cs @@ -0,0 +1,94 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Localization +{ + /// + /// A locale specific string. + /// + public class LocalizedString + { + /// + /// Creates a new . + /// + /// The name of the string in the resource it was loaded from. + /// The actual string. + public LocalizedString(string name, string value) + : this(name, value, resourceNotFound: false) + { + } + + /// + /// Creates a new . + /// + /// The name of the string in the resource it was loaded from. + /// The actual string. + /// Whether the string was not found in a resource. Set this to true to indicate an alternate string value was used. + public LocalizedString(string name, string value, bool resourceNotFound) + : this(name, value, resourceNotFound, searchedLocation: null) + { + } + + /// + /// Creates a new . + /// + /// The name of the string in the resource it was loaded from. + /// The actual string. + /// Whether the string was not found in a resource. Set this to true to indicate an alternate string value was used. + /// The location which was searched for a localization value. + public LocalizedString(string name, string value, bool resourceNotFound, string searchedLocation) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + Name = name; + Value = value; + ResourceNotFound = resourceNotFound; + SearchedLocation = searchedLocation; + } + + /// + /// Implicitly converts the to a . + /// + /// The string to be implicitly converted. + public static implicit operator string(LocalizedString localizedString) + { + return localizedString?.Value; + } + + /// + /// The name of the string in the resource it was loaded from. + /// + public string Name { get; } + + /// + /// The actual string. + /// + public string Value { get; } + + /// + /// Whether the string was not found in a resource. If true, an alternate string value was used. + /// + public bool ResourceNotFound { get; } + + /// + /// The location which was searched for a localization value. + /// + public string SearchedLocation { get; } + + /// + /// Returns the actual string. + /// + /// The actual string. + public override string ToString() => Value; + } +} diff --git a/src/Localization/Abstractions/src/Microsoft.Extensions.Localization.Abstractions.csproj b/src/Localization/Abstractions/src/Microsoft.Extensions.Localization.Abstractions.csproj new file mode 100644 index 000000000000..4b8816040148 --- /dev/null +++ b/src/Localization/Abstractions/src/Microsoft.Extensions.Localization.Abstractions.csproj @@ -0,0 +1,18 @@ + + + + Microsoft .NET Extensions + Abstractions of application localization services. +Commonly used types: +Microsoft.Extensions.Localization.IStringLocalizer +Microsoft.Extensions.Localization.IStringLocalizer<T> + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(DefaultNetCoreTargetFramework) + $(NoWarn);CS1591 + true + localization + true + true + + + diff --git a/src/Localization/Abstractions/src/StringLocalizerExtensions.cs b/src/Localization/Abstractions/src/StringLocalizerExtensions.cs new file mode 100644 index 000000000000..2e19db7c18ee --- /dev/null +++ b/src/Localization/Abstractions/src/StringLocalizerExtensions.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Localization +{ + /// + /// Extension methods for operating on instances. + /// + public static class StringLocalizerExtensions + { + /// + /// Gets the string resource with the given name. + /// + /// The . + /// The name of the string resource. + /// The string resource as a . + public static LocalizedString GetString( + this IStringLocalizer stringLocalizer, + string name) + { + if (stringLocalizer == null) + { + throw new ArgumentNullException(nameof(stringLocalizer)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return stringLocalizer[name]; + } + + /// + /// Gets the string resource with the given name and formatted with the supplied arguments. + /// + /// The . + /// The name of the string resource. + /// The values to format the string with. + /// The formatted string resource as a . + public static LocalizedString GetString( + this IStringLocalizer stringLocalizer, + string name, + params object[] arguments) + { + if (stringLocalizer == null) + { + throw new ArgumentNullException(nameof(stringLocalizer)); + } + + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return stringLocalizer[name, arguments]; + } + + /// + /// Gets all string resources including those for parent cultures. + /// + /// The . + /// The string resources. + public static IEnumerable GetAllStrings(this IStringLocalizer stringLocalizer) + { + if (stringLocalizer == null) + { + throw new ArgumentNullException(nameof(stringLocalizer)); + } + + return stringLocalizer.GetAllStrings(includeParentCultures: true); + } + } +} diff --git a/src/Localization/Abstractions/src/StringLocalizerOfT.cs b/src/Localization/Abstractions/src/StringLocalizerOfT.cs new file mode 100644 index 000000000000..0f30ca6c877e --- /dev/null +++ b/src/Localization/Abstractions/src/StringLocalizerOfT.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Microsoft.Extensions.Localization +{ + /// + /// Provides strings for . + /// + /// The to provide strings for. + public class StringLocalizer : IStringLocalizer + { + private IStringLocalizer _localizer; + + /// + /// Creates a new . + /// + /// The to use. + public StringLocalizer(IStringLocalizerFactory factory) + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + + _localizer = factory.Create(typeof(TResourceSource)); + } + + /// + public virtual LocalizedString this[string name] + { + get + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return _localizer[name]; + } + } + + /// + public virtual LocalizedString this[string name, params object[] arguments] + { + get + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + return _localizer[name, arguments]; + } + } + + /// + public IEnumerable GetAllStrings(bool includeParentCultures) => + _localizer.GetAllStrings(includeParentCultures); + } +} diff --git a/src/Localization/Localization/ref/Microsoft.Extensions.Localization.csproj b/src/Localization/Localization/ref/Microsoft.Extensions.Localization.csproj new file mode 100644 index 000000000000..67e8f38f8949 --- /dev/null +++ b/src/Localization/Localization/ref/Microsoft.Extensions.Localization.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + + + + + + + diff --git a/src/Localization/Localization/ref/Microsoft.Extensions.Localization.netcoreapp.cs b/src/Localization/Localization/ref/Microsoft.Extensions.Localization.netcoreapp.cs new file mode 100644 index 000000000000..dea13ee3640d --- /dev/null +++ b/src/Localization/Localization/ref/Microsoft.Extensions.Localization.netcoreapp.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class LocalizationServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddLocalization(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddLocalization(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) { throw null; } + } +} +namespace Microsoft.Extensions.Localization +{ + public partial interface IResourceNamesCache + { + System.Collections.Generic.IList GetOrAdd(string name, System.Func> valueFactory); + } + public partial class LocalizationOptions + { + public LocalizationOptions() { } + public string ResourcesPath { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=false, Inherited=false)] + public partial class ResourceLocationAttribute : System.Attribute + { + public ResourceLocationAttribute(string resourceLocation) { } + public string ResourceLocation { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public partial class ResourceManagerStringLocalizer : Microsoft.Extensions.Localization.IStringLocalizer + { + public ResourceManagerStringLocalizer(System.Resources.ResourceManager resourceManager, Microsoft.Extensions.Localization.Internal.AssemblyWrapper resourceAssemblyWrapper, string baseName, Microsoft.Extensions.Localization.IResourceNamesCache resourceNamesCache, Microsoft.Extensions.Logging.ILogger logger) { } + public ResourceManagerStringLocalizer(System.Resources.ResourceManager resourceManager, Microsoft.Extensions.Localization.Internal.IResourceStringProvider resourceStringProvider, string baseName, Microsoft.Extensions.Localization.IResourceNamesCache resourceNamesCache, Microsoft.Extensions.Logging.ILogger logger) { } + public ResourceManagerStringLocalizer(System.Resources.ResourceManager resourceManager, System.Reflection.Assembly resourceAssembly, string baseName, Microsoft.Extensions.Localization.IResourceNamesCache resourceNamesCache, Microsoft.Extensions.Logging.ILogger logger) { } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name] { get { throw null; } } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name, params object[] arguments] { get { throw null; } } + public virtual System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures) { throw null; } + protected System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures, System.Globalization.CultureInfo culture) { throw null; } + protected string GetStringSafely(string name, System.Globalization.CultureInfo culture) { throw null; } + } + public partial class ResourceManagerStringLocalizerFactory : Microsoft.Extensions.Localization.IStringLocalizerFactory + { + public ResourceManagerStringLocalizerFactory(Microsoft.Extensions.Options.IOptions localizationOptions, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } + public Microsoft.Extensions.Localization.IStringLocalizer Create(string baseName, string location) { throw null; } + public Microsoft.Extensions.Localization.IStringLocalizer Create(System.Type resourceSource) { throw null; } + protected virtual Microsoft.Extensions.Localization.ResourceManagerStringLocalizer CreateResourceManagerStringLocalizer(System.Reflection.Assembly assembly, string baseName) { throw null; } + protected virtual Microsoft.Extensions.Localization.ResourceLocationAttribute GetResourceLocationAttribute(System.Reflection.Assembly assembly) { throw null; } + protected virtual string GetResourcePrefix(System.Reflection.TypeInfo typeInfo) { throw null; } + protected virtual string GetResourcePrefix(System.Reflection.TypeInfo typeInfo, string baseNamespace, string resourcesRelativePath) { throw null; } + protected virtual string GetResourcePrefix(string baseResourceName, string baseNamespace) { throw null; } + protected virtual string GetResourcePrefix(string location, string baseName, string resourceLocation) { throw null; } + protected virtual Microsoft.Extensions.Localization.RootNamespaceAttribute GetRootNamespaceAttribute(System.Reflection.Assembly assembly) { throw null; } + } + public partial class ResourceNamesCache : Microsoft.Extensions.Localization.IResourceNamesCache + { + public ResourceNamesCache() { } + public System.Collections.Generic.IList GetOrAdd(string name, System.Func> valueFactory) { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=false, Inherited=false)] + public partial class RootNamespaceAttribute : System.Attribute + { + public RootNamespaceAttribute(string rootNamespace) { } + public string RootNamespace { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } +} +namespace Microsoft.Extensions.Localization.Internal +{ + public partial class AssemblyWrapper + { + public AssemblyWrapper(System.Reflection.Assembly assembly) { } + public System.Reflection.Assembly Assembly { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public virtual string FullName { get { throw null; } } + public virtual System.IO.Stream GetManifestResourceStream(string name) { throw null; } + } + public partial interface IResourceStringProvider + { + System.Collections.Generic.IList GetAllResourceStrings(System.Globalization.CultureInfo culture, bool throwOnMissing); + } + public partial class ResourceManagerStringProvider : Microsoft.Extensions.Localization.Internal.IResourceStringProvider + { + public ResourceManagerStringProvider(Microsoft.Extensions.Localization.IResourceNamesCache resourceCache, System.Resources.ResourceManager resourceManager, System.Reflection.Assembly assembly, string baseName) { } + public System.Collections.Generic.IList GetAllResourceStrings(System.Globalization.CultureInfo culture, bool throwOnMissing) { throw null; } + } +} diff --git a/src/Localization/Localization/ref/Microsoft.Extensions.Localization.netstandard2.0.cs b/src/Localization/Localization/ref/Microsoft.Extensions.Localization.netstandard2.0.cs new file mode 100644 index 000000000000..dea13ee3640d --- /dev/null +++ b/src/Localization/Localization/ref/Microsoft.Extensions.Localization.netstandard2.0.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class LocalizationServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddLocalization(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddLocalization(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) { throw null; } + } +} +namespace Microsoft.Extensions.Localization +{ + public partial interface IResourceNamesCache + { + System.Collections.Generic.IList GetOrAdd(string name, System.Func> valueFactory); + } + public partial class LocalizationOptions + { + public LocalizationOptions() { } + public string ResourcesPath { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=false, Inherited=false)] + public partial class ResourceLocationAttribute : System.Attribute + { + public ResourceLocationAttribute(string resourceLocation) { } + public string ResourceLocation { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public partial class ResourceManagerStringLocalizer : Microsoft.Extensions.Localization.IStringLocalizer + { + public ResourceManagerStringLocalizer(System.Resources.ResourceManager resourceManager, Microsoft.Extensions.Localization.Internal.AssemblyWrapper resourceAssemblyWrapper, string baseName, Microsoft.Extensions.Localization.IResourceNamesCache resourceNamesCache, Microsoft.Extensions.Logging.ILogger logger) { } + public ResourceManagerStringLocalizer(System.Resources.ResourceManager resourceManager, Microsoft.Extensions.Localization.Internal.IResourceStringProvider resourceStringProvider, string baseName, Microsoft.Extensions.Localization.IResourceNamesCache resourceNamesCache, Microsoft.Extensions.Logging.ILogger logger) { } + public ResourceManagerStringLocalizer(System.Resources.ResourceManager resourceManager, System.Reflection.Assembly resourceAssembly, string baseName, Microsoft.Extensions.Localization.IResourceNamesCache resourceNamesCache, Microsoft.Extensions.Logging.ILogger logger) { } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name] { get { throw null; } } + public virtual Microsoft.Extensions.Localization.LocalizedString this[string name, params object[] arguments] { get { throw null; } } + public virtual System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures) { throw null; } + protected System.Collections.Generic.IEnumerable GetAllStrings(bool includeParentCultures, System.Globalization.CultureInfo culture) { throw null; } + protected string GetStringSafely(string name, System.Globalization.CultureInfo culture) { throw null; } + } + public partial class ResourceManagerStringLocalizerFactory : Microsoft.Extensions.Localization.IStringLocalizerFactory + { + public ResourceManagerStringLocalizerFactory(Microsoft.Extensions.Options.IOptions localizationOptions, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { } + public Microsoft.Extensions.Localization.IStringLocalizer Create(string baseName, string location) { throw null; } + public Microsoft.Extensions.Localization.IStringLocalizer Create(System.Type resourceSource) { throw null; } + protected virtual Microsoft.Extensions.Localization.ResourceManagerStringLocalizer CreateResourceManagerStringLocalizer(System.Reflection.Assembly assembly, string baseName) { throw null; } + protected virtual Microsoft.Extensions.Localization.ResourceLocationAttribute GetResourceLocationAttribute(System.Reflection.Assembly assembly) { throw null; } + protected virtual string GetResourcePrefix(System.Reflection.TypeInfo typeInfo) { throw null; } + protected virtual string GetResourcePrefix(System.Reflection.TypeInfo typeInfo, string baseNamespace, string resourcesRelativePath) { throw null; } + protected virtual string GetResourcePrefix(string baseResourceName, string baseNamespace) { throw null; } + protected virtual string GetResourcePrefix(string location, string baseName, string resourceLocation) { throw null; } + protected virtual Microsoft.Extensions.Localization.RootNamespaceAttribute GetRootNamespaceAttribute(System.Reflection.Assembly assembly) { throw null; } + } + public partial class ResourceNamesCache : Microsoft.Extensions.Localization.IResourceNamesCache + { + public ResourceNamesCache() { } + public System.Collections.Generic.IList GetOrAdd(string name, System.Func> valueFactory) { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=false, Inherited=false)] + public partial class RootNamespaceAttribute : System.Attribute + { + public RootNamespaceAttribute(string rootNamespace) { } + public string RootNamespace { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } +} +namespace Microsoft.Extensions.Localization.Internal +{ + public partial class AssemblyWrapper + { + public AssemblyWrapper(System.Reflection.Assembly assembly) { } + public System.Reflection.Assembly Assembly { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public virtual string FullName { get { throw null; } } + public virtual System.IO.Stream GetManifestResourceStream(string name) { throw null; } + } + public partial interface IResourceStringProvider + { + System.Collections.Generic.IList GetAllResourceStrings(System.Globalization.CultureInfo culture, bool throwOnMissing); + } + public partial class ResourceManagerStringProvider : Microsoft.Extensions.Localization.Internal.IResourceStringProvider + { + public ResourceManagerStringProvider(Microsoft.Extensions.Localization.IResourceNamesCache resourceCache, System.Resources.ResourceManager resourceManager, System.Reflection.Assembly assembly, string baseName) { } + public System.Collections.Generic.IList GetAllResourceStrings(System.Globalization.CultureInfo culture, bool throwOnMissing) { throw null; } + } +} diff --git a/src/Localization/Localization/src/IResourceNamesCache.cs b/src/Localization/Localization/src/IResourceNamesCache.cs new file mode 100644 index 000000000000..90d104aa68ff --- /dev/null +++ b/src/Localization/Localization/src/IResourceNamesCache.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Localization +{ + /// + /// Represents a cache of string names in resources. + /// + public interface IResourceNamesCache + { + /// + /// Adds a set of resource names to the cache by using the specified function, if the name does not already exist. + /// + /// The resource name to add string names for. + /// The function used to generate the string names for the resource. + /// The string names for the resource. + IList GetOrAdd(string name, Func> valueFactory); + } +} diff --git a/src/Localization/Localization/src/Internal/AssemblyWrapper.cs b/src/Localization/Localization/src/Internal/AssemblyWrapper.cs new file mode 100644 index 000000000000..11e118e3269d --- /dev/null +++ b/src/Localization/Localization/src/Internal/AssemblyWrapper.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; + +namespace Microsoft.Extensions.Localization.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class AssemblyWrapper + { + public AssemblyWrapper(Assembly assembly) + { + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + Assembly = assembly; + } + + public Assembly Assembly { get; } + + public virtual string FullName => Assembly.FullName; + + public virtual Stream GetManifestResourceStream(string name) => Assembly.GetManifestResourceStream(name); + } +} diff --git a/src/Localization/Localization/src/Internal/IResourceStringProvider.cs b/src/Localization/Localization/src/Internal/IResourceStringProvider.cs new file mode 100644 index 000000000000..157e8e976e1a --- /dev/null +++ b/src/Localization/Localization/src/Internal/IResourceStringProvider.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Globalization; + +namespace Microsoft.Extensions.Localization.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public interface IResourceStringProvider + { + IList GetAllResourceStrings(CultureInfo culture, bool throwOnMissing); + } +} diff --git a/src/Localization/Localization/src/Internal/ResourceManagerStringLocalizerLoggerExtensions.cs b/src/Localization/Localization/src/Internal/ResourceManagerStringLocalizerLoggerExtensions.cs new file mode 100644 index 000000000000..63f40536ca42 --- /dev/null +++ b/src/Localization/Localization/src/Internal/ResourceManagerStringLocalizerLoggerExtensions.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Localization.Internal +{ + internal static class ResourceManagerStringLocalizerLoggerExtensions + { + private static readonly Action _searchedLocation; + + static ResourceManagerStringLocalizerLoggerExtensions() + { + _searchedLocation = LoggerMessage.Define( + LogLevel.Debug, + new EventId(1, "SearchedLocation"), + $"{nameof(ResourceManagerStringLocalizer)} searched for '{{Key}}' in '{{LocationSearched}}' with culture '{{Culture}}'."); + } + + public static void SearchedLocation(this ILogger logger, string key, string searchedLocation, CultureInfo culture) + { + _searchedLocation(logger, key, searchedLocation, culture, null); + } + } +} diff --git a/src/Localization/Localization/src/Internal/ResourceManagerStringProvider.cs b/src/Localization/Localization/src/Internal/ResourceManagerStringProvider.cs new file mode 100644 index 000000000000..62250938c871 --- /dev/null +++ b/src/Localization/Localization/src/Internal/ResourceManagerStringProvider.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace Microsoft.Extensions.Localization.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class ResourceManagerStringProvider : IResourceStringProvider + { + private readonly IResourceNamesCache _resourceNamesCache; + private readonly ResourceManager _resourceManager; + private readonly Assembly _assembly; + private readonly string _resourceBaseName; + + public ResourceManagerStringProvider( + IResourceNamesCache resourceCache, + ResourceManager resourceManager, + Assembly assembly, + string baseName) + { + _resourceManager = resourceManager; + _resourceNamesCache = resourceCache; + _assembly = assembly; + _resourceBaseName = baseName; + } + + private string GetResourceCacheKey(CultureInfo culture) + { + var resourceName = _resourceManager.BaseName; + + return $"Culture={culture.Name};resourceName={resourceName};Assembly={_assembly.FullName}"; + } + + private string GetResourceName(CultureInfo culture) + { + var resourceStreamName = _resourceBaseName; + if (!string.IsNullOrEmpty(culture.Name)) + { + resourceStreamName += "." + culture.Name; + } + resourceStreamName += ".resources"; + + return resourceStreamName; + } + + public IList GetAllResourceStrings(CultureInfo culture, bool throwOnMissing) + { + var cacheKey = GetResourceCacheKey(culture); + + return _resourceNamesCache.GetOrAdd(cacheKey, _ => + { + // We purposly don't dispose the ResourceSet because it causes an ObjectDisposedException when you try to read the values later. + var resourceSet = _resourceManager.GetResourceSet(culture, createIfNotExists: true, tryParents: false); + if (resourceSet == null) + { + if (throwOnMissing) + { + throw new MissingManifestResourceException(Resources.FormatLocalization_MissingManifest(GetResourceName(culture))); + } + else + { + return null; + } + } + + var names = new List(); + foreach (DictionaryEntry entry in resourceSet) + { + names.Add((string)entry.Key); + } + + return names; + }); + } + } +} diff --git a/src/Localization/Localization/src/LocalizationOptions.cs b/src/Localization/Localization/src/LocalizationOptions.cs new file mode 100644 index 000000000000..1c0b82d21042 --- /dev/null +++ b/src/Localization/Localization/src/LocalizationOptions.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.Localization +{ + /// + /// Provides programmatic configuration for localization. + /// + public class LocalizationOptions + { + /// + /// Creates a new . + /// + public LocalizationOptions() + { } + + /// + /// The relative path under application root where resource files are located. + /// + public string ResourcesPath { get; set; } = string.Empty; + } +} diff --git a/src/Localization/Localization/src/LocalizationServiceCollectionExtensions.cs b/src/Localization/Localization/src/LocalizationServiceCollectionExtensions.cs new file mode 100644 index 000000000000..111c1c40d954 --- /dev/null +++ b/src/Localization/Localization/src/LocalizationServiceCollectionExtensions.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Localization; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for setting up localization services in an . + /// + public static class LocalizationServiceCollectionExtensions + { + /// + /// Adds services required for application localization. + /// + /// The to add the services to. + /// The so that additional calls can be chained. + public static IServiceCollection AddLocalization(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddOptions(); + + AddLocalizationServices(services); + + return services; + } + + /// + /// Adds services required for application localization. + /// + /// The to add the services to. + /// + /// An to configure the . + /// + /// The so that additional calls can be chained. + public static IServiceCollection AddLocalization( + this IServiceCollection services, + Action setupAction) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (setupAction == null) + { + throw new ArgumentNullException(nameof(setupAction)); + } + + AddLocalizationServices(services, setupAction); + + return services; + } + + // To enable unit testing + internal static void AddLocalizationServices(IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddTransient(typeof(IStringLocalizer<>), typeof(StringLocalizer<>)); + } + + internal static void AddLocalizationServices( + IServiceCollection services, + Action setupAction) + { + AddLocalizationServices(services); + services.Configure(setupAction); + } + } +} \ No newline at end of file diff --git a/src/Localization/Localization/src/Microsoft.Extensions.Localization.csproj b/src/Localization/Localization/src/Microsoft.Extensions.Localization.csproj new file mode 100644 index 000000000000..b6b059ce827f --- /dev/null +++ b/src/Localization/Localization/src/Microsoft.Extensions.Localization.csproj @@ -0,0 +1,26 @@ + + + + Microsoft .NET Extensions + Application localization services and default implementation based on ResourceManager to load localized assembly resources. + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(DefaultNetCoreTargetFramework) + $(NoWarn);CS1591 + true + localization + true + true + + + + + + + + + + + + + + diff --git a/src/Localization/Localization/src/ResourceLocationAttribute.cs b/src/Localization/Localization/src/ResourceLocationAttribute.cs new file mode 100644 index 000000000000..5bf281d90e36 --- /dev/null +++ b/src/Localization/Localization/src/ResourceLocationAttribute.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Localization +{ + /// + /// Provides the location of resources for an Assembly. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] + public class ResourceLocationAttribute : Attribute + { + /// + /// Creates a new . + /// + /// The location of resources for this Assembly. + public ResourceLocationAttribute(string resourceLocation) + { + if (string.IsNullOrEmpty(resourceLocation)) + { + throw new ArgumentNullException(nameof(resourceLocation)); + } + + ResourceLocation = resourceLocation; + } + + /// + /// The location of resources for this Assembly. + /// + public string ResourceLocation { get; } + } +} diff --git a/src/Localization/Localization/src/ResourceManagerStringLocalizer.cs b/src/Localization/Localization/src/ResourceManagerStringLocalizer.cs new file mode 100644 index 000000000000..4f13611a47d9 --- /dev/null +++ b/src/Localization/Localization/src/ResourceManagerStringLocalizer.cs @@ -0,0 +1,251 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Resources; +using Microsoft.Extensions.Localization.Internal; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Extensions.Localization +{ + /// + /// An that uses the and + /// to provide localized strings. + /// + /// This type is thread-safe. + public class ResourceManagerStringLocalizer : IStringLocalizer + { + private readonly ConcurrentDictionary _missingManifestCache = new ConcurrentDictionary(); + private readonly IResourceNamesCache _resourceNamesCache; + private readonly ResourceManager _resourceManager; + private readonly IResourceStringProvider _resourceStringProvider; + private readonly string _resourceBaseName; + private readonly ILogger _logger; + + /// + /// Creates a new . + /// + /// The to read strings from. + /// The that contains the strings as embedded resources. + /// The base name of the embedded resource that contains the strings. + /// Cache of the list of strings for a given resource assembly name. + /// The . + public ResourceManagerStringLocalizer( + ResourceManager resourceManager, + Assembly resourceAssembly, + string baseName, + IResourceNamesCache resourceNamesCache, + ILogger logger) + : this( + resourceManager, + new AssemblyWrapper(resourceAssembly), + baseName, + resourceNamesCache, + logger) + { + } + + /// + /// Intended for testing purposes only. + /// + public ResourceManagerStringLocalizer( + ResourceManager resourceManager, + AssemblyWrapper resourceAssemblyWrapper, + string baseName, + IResourceNamesCache resourceNamesCache, + ILogger logger) + : this( + resourceManager, + new ResourceManagerStringProvider( + resourceNamesCache, + resourceManager, + resourceAssemblyWrapper.Assembly, + baseName), + baseName, + resourceNamesCache, + logger) + { + } + + /// + /// Intended for testing purposes only. + /// + public ResourceManagerStringLocalizer( + ResourceManager resourceManager, + IResourceStringProvider resourceStringProvider, + string baseName, + IResourceNamesCache resourceNamesCache, + ILogger logger) + { + if (resourceManager == null) + { + throw new ArgumentNullException(nameof(resourceManager)); + } + + if (resourceStringProvider == null) + { + throw new ArgumentNullException(nameof(resourceStringProvider)); + } + + if (baseName == null) + { + throw new ArgumentNullException(nameof(baseName)); + } + + if (resourceNamesCache == null) + { + throw new ArgumentNullException(nameof(resourceNamesCache)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + _resourceStringProvider = resourceStringProvider; + _resourceManager = resourceManager; + _resourceBaseName = baseName; + _resourceNamesCache = resourceNamesCache; + _logger = logger; + } + + /// + public virtual LocalizedString this[string name] + { + get + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + var value = GetStringSafely(name, null); + + return new LocalizedString(name, value ?? name, resourceNotFound: value == null, searchedLocation: _resourceBaseName); + } + } + + /// + public virtual LocalizedString this[string name, params object[] arguments] + { + get + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + var format = GetStringSafely(name, null); + var value = string.Format(format ?? name, arguments); + + return new LocalizedString(name, value, resourceNotFound: format == null, searchedLocation: _resourceBaseName); + } + } + + /// + public virtual IEnumerable GetAllStrings(bool includeParentCultures) => + GetAllStrings(includeParentCultures, CultureInfo.CurrentUICulture); + + /// + /// Returns all strings in the specified culture. + /// + /// Whether to include parent cultures in the search for a resource. + /// The to get strings for. + /// The strings. + protected IEnumerable GetAllStrings(bool includeParentCultures, CultureInfo culture) + { + if (culture == null) + { + throw new ArgumentNullException(nameof(culture)); + } + + var resourceNames = includeParentCultures + ? GetResourceNamesFromCultureHierarchy(culture) + : _resourceStringProvider.GetAllResourceStrings(culture, true); + + foreach (var name in resourceNames) + { + var value = GetStringSafely(name, culture); + yield return new LocalizedString(name, value ?? name, resourceNotFound: value == null, searchedLocation: _resourceBaseName); + } + } + + /// + /// Gets a resource string from the and returns null instead of + /// throwing exceptions if a match isn't found. + /// + /// The name of the string resource. + /// The to get the string for. + /// The resource string, or null if none was found. + protected string GetStringSafely(string name, CultureInfo culture) + { + if (name == null) + { + throw new ArgumentNullException(nameof(name)); + } + + var keyCulture = culture ?? CultureInfo.CurrentUICulture; + + var cacheKey = $"name={name}&culture={keyCulture.Name}"; + + _logger.SearchedLocation(name, _resourceBaseName, keyCulture); + + if (_missingManifestCache.ContainsKey(cacheKey)) + { + return null; + } + + try + { + return culture == null ? _resourceManager.GetString(name) : _resourceManager.GetString(name, culture); + } + catch (MissingManifestResourceException) + { + _missingManifestCache.TryAdd(cacheKey, null); + return null; + } + } + + private IEnumerable GetResourceNamesFromCultureHierarchy(CultureInfo startingCulture) + { + var currentCulture = startingCulture; + var resourceNames = new HashSet(); + + var hasAnyCultures = false; + + while (true) + { + + var cultureResourceNames = _resourceStringProvider.GetAllResourceStrings(currentCulture, false); + + if (cultureResourceNames != null) + { + foreach (var resourceName in cultureResourceNames) + { + resourceNames.Add(resourceName); + } + hasAnyCultures = true; + } + + if (currentCulture == currentCulture.Parent) + { + // currentCulture begat currentCulture, probably time to leave + break; + } + + currentCulture = currentCulture.Parent; + } + + if (!hasAnyCultures) + { + throw new MissingManifestResourceException(Resources.Localization_MissingManifest_Parent); + } + + return resourceNames; + } + } +} diff --git a/src/Localization/Localization/src/ResourceManagerStringLocalizerFactory.cs b/src/Localization/Localization/src/ResourceManagerStringLocalizerFactory.cs new file mode 100644 index 000000000000..a525f723685d --- /dev/null +++ b/src/Localization/Localization/src/ResourceManagerStringLocalizerFactory.cs @@ -0,0 +1,269 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Reflection; +using System.Resources; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Localization +{ + /// + /// An that creates instances of . + /// + /// + /// offers multiple ways to set the relative path of + /// resources to be used. They are, in order of precedence: + /// -> -> the project root. + /// + public class ResourceManagerStringLocalizerFactory : IStringLocalizerFactory + { + private readonly IResourceNamesCache _resourceNamesCache = new ResourceNamesCache(); + private readonly ConcurrentDictionary _localizerCache = + new ConcurrentDictionary(); + private readonly string _resourcesRelativePath; + private readonly ILoggerFactory _loggerFactory; + + /// + /// Creates a new . + /// + /// The . + /// The . + public ResourceManagerStringLocalizerFactory( + IOptions localizationOptions, + ILoggerFactory loggerFactory) + { + if (localizationOptions == null) + { + throw new ArgumentNullException(nameof(localizationOptions)); + } + + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + _resourcesRelativePath = localizationOptions.Value.ResourcesPath ?? string.Empty; + _loggerFactory = loggerFactory; + + if (!string.IsNullOrEmpty(_resourcesRelativePath)) + { + _resourcesRelativePath = _resourcesRelativePath.Replace(Path.AltDirectorySeparatorChar, '.') + .Replace(Path.DirectorySeparatorChar, '.') + "."; + } + } + + /// + /// Gets the resource prefix used to look up the resource. + /// + /// The type of the resource to be looked up. + /// The prefix for resource lookup. + protected virtual string GetResourcePrefix(TypeInfo typeInfo) + { + if (typeInfo == null) + { + throw new ArgumentNullException(nameof(typeInfo)); + } + + return GetResourcePrefix(typeInfo, GetRootNamespace(typeInfo.Assembly), GetResourcePath(typeInfo.Assembly)); + } + + /// + /// Gets the resource prefix used to look up the resource. + /// + /// The type of the resource to be looked up. + /// The base namespace of the application. + /// The folder containing all resources. + /// The prefix for resource lookup. + /// + /// For the type "Sample.Controllers.Home" if there's a resourceRelativePath return + /// "Sample.Resourcepath.Controllers.Home" if there isn't one then it would return "Sample.Controllers.Home". + /// + protected virtual string GetResourcePrefix(TypeInfo typeInfo, string baseNamespace, string resourcesRelativePath) + { + if (typeInfo == null) + { + throw new ArgumentNullException(nameof(typeInfo)); + } + + if (string.IsNullOrEmpty(baseNamespace)) + { + throw new ArgumentNullException(nameof(baseNamespace)); + } + + if (string.IsNullOrEmpty(resourcesRelativePath)) + { + return typeInfo.FullName; + } + else + { + // This expectation is defined by dotnet's automatic resource storage. + // We have to conform to "{RootNamespace}.{ResourceLocation}.{FullTypeName - RootNamespace}". + return baseNamespace + "." + resourcesRelativePath + TrimPrefix(typeInfo.FullName, baseNamespace + "."); + } + } + + /// + /// Gets the resource prefix used to look up the resource. + /// + /// The name of the resource to be looked up + /// The base namespace of the application. + /// The prefix for resource lookup. + protected virtual string GetResourcePrefix(string baseResourceName, string baseNamespace) + { + if (string.IsNullOrEmpty(baseResourceName)) + { + throw new ArgumentNullException(nameof(baseResourceName)); + } + + if (string.IsNullOrEmpty(baseNamespace)) + { + throw new ArgumentNullException(nameof(baseNamespace)); + } + + var assemblyName = new AssemblyName(baseNamespace); + var assembly = Assembly.Load(assemblyName); + var rootNamespace = GetRootNamespace(assembly); + var resourceLocation = GetResourcePath(assembly); + var locationPath = rootNamespace + "." + resourceLocation; + + baseResourceName = locationPath + TrimPrefix(baseResourceName, baseNamespace + "."); + + return baseResourceName; + } + + /// + /// Creates a using the and + /// of the specified . + /// + /// The . + /// The . + public IStringLocalizer Create(Type resourceSource) + { + if (resourceSource == null) + { + throw new ArgumentNullException(nameof(resourceSource)); + } + + var typeInfo = resourceSource.GetTypeInfo(); + + var baseName = GetResourcePrefix(typeInfo); + + var assembly = typeInfo.Assembly; + + return _localizerCache.GetOrAdd(baseName, _ => CreateResourceManagerStringLocalizer(assembly, baseName)); + } + + /// + /// Creates a . + /// + /// The base name of the resource to load strings from. + /// The location to load resources from. + /// The . + public IStringLocalizer Create(string baseName, string location) + { + if (baseName == null) + { + throw new ArgumentNullException(nameof(baseName)); + } + + if (location == null) + { + throw new ArgumentNullException(nameof(location)); + } + + return _localizerCache.GetOrAdd($"B={baseName},L={location}", _ => + { + var assemblyName = new AssemblyName(location); + var assembly = Assembly.Load(assemblyName); + baseName = GetResourcePrefix(baseName, location); + + return CreateResourceManagerStringLocalizer(assembly, baseName); + }); + } + + /// Creates a for the given input. + /// The assembly to create a for. + /// The base name of the resource to search for. + /// A for the given and . + /// This method is virtual for testing purposes only. + protected virtual ResourceManagerStringLocalizer CreateResourceManagerStringLocalizer( + Assembly assembly, + string baseName) + { + return new ResourceManagerStringLocalizer( + new ResourceManager(baseName, assembly), + assembly, + baseName, + _resourceNamesCache, + _loggerFactory.CreateLogger()); + } + + /// + /// Gets the resource prefix used to look up the resource. + /// + /// The general location of the resource. + /// The base name of the resource. + /// The location of the resource within . + /// The resource prefix used to look up the resource. + protected virtual string GetResourcePrefix(string location, string baseName, string resourceLocation) + { + // Re-root the base name if a resources path is set + return location + "." + resourceLocation + TrimPrefix(baseName, location + "."); + } + + /// Gets a from the provided . + /// The assembly to get a from. + /// The associated with the given . + /// This method is protected and virtual for testing purposes only. + protected virtual ResourceLocationAttribute GetResourceLocationAttribute(Assembly assembly) + { + return assembly.GetCustomAttribute(); + } + + /// Gets a from the provided . + /// The assembly to get a from. + /// The associated with the given . + /// This method is protected and virtual for testing purposes only. + protected virtual RootNamespaceAttribute GetRootNamespaceAttribute(Assembly assembly) + { + return assembly.GetCustomAttribute(); + } + + private string GetRootNamespace(Assembly assembly) + { + var rootNamespaceAttribute = GetRootNamespaceAttribute(assembly); + + return rootNamespaceAttribute?.RootNamespace ?? + new AssemblyName(assembly.FullName).Name; + } + + private string GetResourcePath(Assembly assembly) + { + var resourceLocationAttribute = GetResourceLocationAttribute(assembly); + + // If we don't have an attribute assume all assemblies use the same resource location. + var resourceLocation = resourceLocationAttribute == null + ? _resourcesRelativePath + : resourceLocationAttribute.ResourceLocation + "."; + resourceLocation = resourceLocation + .Replace(Path.DirectorySeparatorChar, '.') + .Replace(Path.AltDirectorySeparatorChar, '.'); + + return resourceLocation; + } + + private static string TrimPrefix(string name, string prefix) + { + if (name.StartsWith(prefix, StringComparison.Ordinal)) + { + return name.Substring(prefix.Length); + } + + return name; + } + } +} diff --git a/src/Localization/Localization/src/ResourceNamesCache.cs b/src/Localization/Localization/src/ResourceNamesCache.cs new file mode 100644 index 000000000000..72b7986d9f5e --- /dev/null +++ b/src/Localization/Localization/src/ResourceNamesCache.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Localization +{ + /// + /// An implementation of backed by a . + /// + public class ResourceNamesCache : IResourceNamesCache + { + private readonly ConcurrentDictionary> _cache = new ConcurrentDictionary>(); + + /// + /// Creates a new + /// + public ResourceNamesCache() + { + } + + /// + public IList GetOrAdd(string name, Func> valueFactory) + { + return _cache.GetOrAdd(name, valueFactory); + } + } +} diff --git a/src/Localization/Localization/src/Resources.resx b/src/Localization/Localization/src/Resources.resx new file mode 100644 index 000000000000..b679f04664c0 --- /dev/null +++ b/src/Localization/Localization/src/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The manifest '{0}' was not found. + + + No manifests exist for the current culture. + + \ No newline at end of file diff --git a/src/Localization/Localization/src/RootNamespaceAttribute.cs b/src/Localization/Localization/src/RootNamespaceAttribute.cs new file mode 100644 index 000000000000..f28b4ea1fd70 --- /dev/null +++ b/src/Localization/Localization/src/RootNamespaceAttribute.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Localization +{ + /// + /// Provides the RootNamespace of an Assembly. The RootNamespace of the assembly is used by Localization to + /// determine the resource name to look for when RootNamespace differs from the AssemblyName. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] + public class RootNamespaceAttribute : Attribute + { + /// + /// Creates a new . + /// + /// The RootNamespace for this Assembly. + public RootNamespaceAttribute(string rootNamespace) + { + if (string.IsNullOrEmpty(rootNamespace)) + { + throw new ArgumentNullException(nameof(rootNamespace)); + } + + RootNamespace = rootNamespace; + } + + /// + /// The RootNamespace of this Assembly. The RootNamespace of the assembly is used by Localization to + /// determine the resource name to look for when RootNamespace differs from the AssemblyName. + /// + public string RootNamespace { get; } + } +} diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/AssemblyInfo.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/AssemblyInfo.cs new file mode 100644 index 000000000000..bc7d005571eb --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Reflection; +using Microsoft.Extensions.Localization; + +[assembly: ResourceLocation("Resources")] +[assembly: RootNamespace("LocalizationTest.Abc")] diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Controllers/ValuesController.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Controllers/ValuesController.cs new file mode 100644 index 000000000000..93dad2460a2d --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Controllers/ValuesController.cs @@ -0,0 +1,9 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace LocalizationTest.Abc.Controllers +{ + public class ValuesController + { + } +} diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Microsoft.Extensions.Localization.RootNamespace.Tests.csproj b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Microsoft.Extensions.Localization.RootNamespace.Tests.csproj new file mode 100644 index 000000000000..3d9153ef330f --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Microsoft.Extensions.Localization.RootNamespace.Tests.csproj @@ -0,0 +1,26 @@ + + + $(DefaultNetCoreTargetFramework);net472 + LocalizationTest.Abc + + + + + + + + + + + + + + + + + + ResXFileCodeGenerator + ValuesController.Designer.cs + + + diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Resources/Controllers/ValuesController.resx b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Resources/Controllers/ValuesController.resx new file mode 100644 index 000000000000..9966ea419e49 --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/Resources/Controllers/ValuesController.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + ValFromResource + + \ No newline at end of file diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/StringLocalizerOfTRootNamespaceTest.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/StringLocalizerOfTRootNamespaceTest.cs new file mode 100644 index 000000000000..7c892e65dd39 --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.RootNamespace.Tests/StringLocalizerOfTRootNamespaceTest.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using LocalizationTest.Abc.Controllers; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Localization.RootNamespace.Tests +{ + public class StringLocalizerOfTRootNamespaceTest + { + [Fact] + public void RootNamespace() + { + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var factory = new ResourceManagerStringLocalizerFactory(options.Object, NullLoggerFactory.Instance); + + var valuesLoc = factory.Create(typeof(ValuesController)); + Assert.Equal("ValFromResource", valuesLoc["String1"]); + } + } +} diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/LocalizationServiceCollectionExtensionsTest.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/LocalizationServiceCollectionExtensionsTest.cs new file mode 100644 index 000000000000..d78581655cdd --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/LocalizationServiceCollectionExtensionsTest.cs @@ -0,0 +1,68 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.DependencyInjection +{ + public class LocalizationServiceCollectionExtensionsTest + { + [Fact] + public void AddLocalization_AddsNeededServices() + { + // Arrange + var collection = new ServiceCollection(); + + // Act + LocalizationServiceCollectionExtensions.AddLocalizationServices(collection); + + // Assert + AssertContainsSingle(collection, typeof(IStringLocalizerFactory), typeof(ResourceManagerStringLocalizerFactory)); + AssertContainsSingle(collection, typeof(IStringLocalizer<>), typeof(StringLocalizer<>)); + } + + [Fact] + public void AddLocalizationWithLocalizationOptions_AddsNeededServices() + { + // Arrange + var collection = new ServiceCollection(); + + // Act + LocalizationServiceCollectionExtensions.AddLocalizationServices( + collection, + options => options.ResourcesPath = "Resources"); + + AssertContainsSingle(collection, typeof(IStringLocalizerFactory), typeof(ResourceManagerStringLocalizerFactory)); + AssertContainsSingle(collection, typeof(IStringLocalizer<>), typeof(StringLocalizer<>)); + } + + private void AssertContainsSingle( + IServiceCollection services, + Type serviceType, + Type implementationType) + { + var matches = services + .Where(sd => + sd.ServiceType == serviceType && + sd.ImplementationType == implementationType) + .ToArray(); + + if (matches.Length == 0) + { + Assert.True( + false, + $"Could not find an instance of {implementationType} registered as {serviceType}"); + } + else if (matches.Length > 1) + { + Assert.True( + false, + $"Found multiple instances of {implementationType} registered as {serviceType}"); + } + } + } +} diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/Microsoft.Extensions.Localization.Tests.csproj b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/Microsoft.Extensions.Localization.Tests.csproj new file mode 100644 index 000000000000..70e7a6de1a9d --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/Microsoft.Extensions.Localization.Tests.csproj @@ -0,0 +1,20 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + + + + + + + + + + + + + + diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/Model.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/Model.cs new file mode 100644 index 000000000000..9d95c370fdbe --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/Model.cs @@ -0,0 +1,8 @@ +// This namespace for test resources with alternative RootNamespace +namespace MyNamespace +{ + public class Model + { + + } +} \ No newline at end of file diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/ResourceManagerStringLocalizerFactoryTest.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/ResourceManagerStringLocalizerFactoryTest.cs new file mode 100644 index 000000000000..86f6e15ccd4c --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/ResourceManagerStringLocalizerFactoryTest.cs @@ -0,0 +1,299 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MyNamespace; +using Moq; +using Xunit; + +// This namespace intentionally matches the default assembly namespace. +namespace Microsoft.Extensions.Localization.Tests +{ + public class TestResourceManagerStringLocalizerFactory : ResourceManagerStringLocalizerFactory + { + private ResourceLocationAttribute _resourceLocationAttribute; + + private RootNamespaceAttribute _rootNamespaceAttribute; + + public Assembly Assembly { get; private set; } + public string BaseName { get; private set; } + + public TestResourceManagerStringLocalizerFactory( + IOptions localizationOptions, + ResourceLocationAttribute resourceLocationAttribute, + RootNamespaceAttribute rootNamespaceAttribute, + ILoggerFactory loggerFactory) + : base(localizationOptions, loggerFactory) + { + _resourceLocationAttribute = resourceLocationAttribute; + _rootNamespaceAttribute = rootNamespaceAttribute; + } + + protected override ResourceLocationAttribute GetResourceLocationAttribute(Assembly assembly) + { + return _resourceLocationAttribute; + } + + protected override RootNamespaceAttribute GetRootNamespaceAttribute(Assembly assembly) + { + return _rootNamespaceAttribute; + } + + protected override ResourceManagerStringLocalizer CreateResourceManagerStringLocalizer(Assembly assembly, string baseName) + { + BaseName = baseName; + Assembly = assembly; + + return base.CreateResourceManagerStringLocalizer(assembly, baseName); + } + } + + public class ResourceManagerStringLocalizerFactoryTest + { + [Fact] + public void Create_OverloadsProduceSameResult() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + + var resourceLocationAttribute = new ResourceLocationAttribute(Path.Combine("My", "Resources")); + var loggerFactory = NullLoggerFactory.Instance; + var typeFactory = new TestResourceManagerStringLocalizerFactory( + options.Object, + resourceLocationAttribute, + rootNamespaceAttribute: null, + loggerFactory: loggerFactory); + var stringFactory = new TestResourceManagerStringLocalizerFactory( + options.Object, + resourceLocationAttribute, + rootNamespaceAttribute: null, + loggerFactory: loggerFactory); + var type = typeof(ResourceManagerStringLocalizerFactoryTest); + var assemblyName = new AssemblyName(type.GetTypeInfo().Assembly.FullName); + + // Act + typeFactory.Create(type); + stringFactory.Create(type.Name, assemblyName.Name); + + // Assert + Assert.Equal(typeFactory.BaseName, stringFactory.BaseName); + Assert.Equal(typeFactory.Assembly.FullName, stringFactory.Assembly.FullName); + } + + [Fact] + public void Create_FromType_ReturnsCachedResultForSameType() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new ResourceManagerStringLocalizerFactory(localizationOptions: options.Object, loggerFactory: loggerFactory); + + // Act + var result1 = factory.Create(typeof(ResourceManagerStringLocalizerFactoryTest)); + var result2 = factory.Create(typeof(ResourceManagerStringLocalizerFactoryTest)); + + // Assert + Assert.Same(result1, result2); + } + + [Fact] + public void Create_FromType_ReturnsNewResultForDifferentType() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new ResourceManagerStringLocalizerFactory(localizationOptions: options.Object, loggerFactory: loggerFactory); + + // Act + var result1 = factory.Create(typeof(ResourceManagerStringLocalizerFactoryTest)); + var result2 = factory.Create(typeof(LocalizationOptions)); + + // Assert + Assert.NotSame(result1, result2); + } + + [Fact] + public void Create_ResourceLocationAttribute_RootNamespaceIgnoredWhenNoLocation() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + + var resourcePath = Path.Combine("My", "Resources"); + var rootNamespace = nameof(MyNamespace); + var rootNamespaceAttribute = new RootNamespaceAttribute(rootNamespace); + + var typeFactory = new TestResourceManagerStringLocalizerFactory( + options.Object, + resourceLocationAttribute: null, + rootNamespaceAttribute: rootNamespaceAttribute, + loggerFactory: loggerFactory); + + var type = typeof(Model); + + // Act + typeFactory.Create(type); + + // Assert + Assert.Equal($"{rootNamespace}.{nameof(Model)}", typeFactory.BaseName); + } + + [Fact] + public void Create_ResourceLocationAttribute_UsesRootNamespace() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + + var resourcePath = Path.Combine("My", "Resources"); + var rootNamespace = nameof(MyNamespace); + var resourceLocationAttribute = new ResourceLocationAttribute(resourcePath); + var rootNamespaceAttribute = new RootNamespaceAttribute(rootNamespace); + + var typeFactory = new TestResourceManagerStringLocalizerFactory( + options.Object, + resourceLocationAttribute, + rootNamespaceAttribute, + loggerFactory); + + var type = typeof(Model); + + // Act + typeFactory.Create(type); + + // Assert + Assert.Equal($"{rootNamespace}.My.Resources.{nameof(Model)}", typeFactory.BaseName); + } + + [Fact] + public void Create_FromType_ResourcesPathDirectorySeperatorToDot() + { + // Arrange + var locOptions = new LocalizationOptions(); + locOptions.ResourcesPath = Path.Combine("My", "Resources"); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new TestResourceManagerStringLocalizerFactory( + options.Object, + resourceLocationAttribute: null, + rootNamespaceAttribute: null, + loggerFactory: loggerFactory); + + // Act + factory.Create(typeof(ResourceManagerStringLocalizerFactoryTest)); + + // Assert + Assert.Equal("Microsoft.Extensions.Localization.Tests.My.Resources." + nameof(ResourceManagerStringLocalizerFactoryTest), factory.BaseName); + } + + [Fact] + public void Create_FromNameLocation_ReturnsCachedResultForSameNameLocation() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new ResourceManagerStringLocalizerFactory(localizationOptions: options.Object, loggerFactory: loggerFactory); + var location = typeof(ResourceManagerStringLocalizer).GetTypeInfo().Assembly.FullName; + + // Act + var result1 = factory.Create("baseName", location); + var result2 = factory.Create("baseName", location); + + // Assert + Assert.Same(result1, result2); + } + + [Fact] + public void Create_FromNameLocation_ReturnsNewResultForDifferentName() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new ResourceManagerStringLocalizerFactory(localizationOptions: options.Object, loggerFactory: loggerFactory); + var location = typeof(ResourceManagerStringLocalizer).GetTypeInfo().Assembly.FullName; + + // Act + var result1 = factory.Create("baseName1", location); + var result2 = factory.Create("baseName2", location); + + // Assert + Assert.NotSame(result1, result2); + } + + [Fact] + public void Create_FromNameLocation_ReturnsNewResultForDifferentLocation() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new ResourceManagerStringLocalizerFactory(localizationOptions: options.Object, loggerFactory: loggerFactory); + var location1 = new AssemblyName(typeof(ResourceManagerStringLocalizer).GetTypeInfo().Assembly.FullName).Name; + var location2 = new AssemblyName(typeof(ResourceManagerStringLocalizerFactoryTest).GetTypeInfo().Assembly.FullName).Name; + + // Act + var result1 = factory.Create("baseName", location1); + var result2 = factory.Create("baseName", location2); + + // Assert + Assert.NotSame(result1, result2); + } + + [Fact] + public void Create_FromNameLocation_ResourcesPathDirectorySeparatorToDot() + { + // Arrange + var locOptions = new LocalizationOptions(); + locOptions.ResourcesPath = Path.Combine("My", "Resources"); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new TestResourceManagerStringLocalizerFactory( + options.Object, + resourceLocationAttribute: null, + rootNamespaceAttribute: null, + loggerFactory: loggerFactory); + + // Act + var result1 = factory.Create("baseName", location: "Microsoft.Extensions.Localization.Tests"); + + // Assert + Assert.Equal("Microsoft.Extensions.Localization.Tests.My.Resources.baseName", factory.BaseName); + } + + [Fact] + public void Create_FromNameLocation_NullLocationThrows() + { + // Arrange + var locOptions = new LocalizationOptions(); + var options = new Mock>(); + options.Setup(o => o.Value).Returns(locOptions); + var loggerFactory = NullLoggerFactory.Instance; + var factory = new ResourceManagerStringLocalizerFactory(localizationOptions: options.Object, loggerFactory: loggerFactory); + + // Act & Assert + Assert.Throws(() => factory.Create("baseName", location: null)); + } + } +} diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/ResourceManagerStringLocalizerTest.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/ResourceManagerStringLocalizerTest.cs new file mode 100644 index 000000000000..a82ce9d1d390 --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/ResourceManagerStringLocalizerTest.cs @@ -0,0 +1,298 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Resources; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Localization.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.Localization +{ + public class ResourceManagerStringLocalizerTest + { + [Fact] + public void EnumeratorCachesCultureWalkForSameAssembly() + { + // Arrange + var resourceNamesCache = new ResourceNamesCache(); + var baseName = "test"; + var resourceAssembly = new TestAssemblyWrapper(); + var resourceManager = new TestResourceManager(baseName, resourceAssembly); + var resourceStreamManager = new TestResourceStringProvider( + resourceNamesCache, + resourceManager, + resourceAssembly.Assembly, + baseName); + var logger = Logger; + var localizer1 = new ResourceManagerStringLocalizer(resourceManager, + resourceStreamManager, + baseName, + resourceNamesCache, + logger); + var localizer2 = new ResourceManagerStringLocalizer(resourceManager, + resourceStreamManager, + baseName, + resourceNamesCache, + logger); + + // Act + for (var i = 0; i < 5; i++) + { + localizer1.GetAllStrings().ToList(); + localizer2.GetAllStrings().ToList(); + } + + // Assert + var expectedCallCount = GetCultureInfoDepth(CultureInfo.CurrentUICulture); + Assert.Equal(expectedCallCount, resourceAssembly.ManifestResourceStreamCallCount); + } + + [Fact] + public void EnumeratorCacheIsScopedByAssembly() + { + // Arrange + var resourceNamesCache = new ResourceNamesCache(); + var baseName = "test"; + var resourceAssembly1 = new TestAssemblyWrapper(typeof(ResourceManagerStringLocalizerTest)); + var resourceAssembly2 = new TestAssemblyWrapper(typeof(ResourceManagerStringLocalizer)); + var resourceManager1 = new TestResourceManager(baseName, resourceAssembly1); + var resourceManager2 = new TestResourceManager(baseName, resourceAssembly2); + var resourceStreamManager1 = new TestResourceStringProvider(resourceNamesCache, resourceManager1, resourceAssembly1.Assembly, baseName); + var resourceStreamManager2 = new TestResourceStringProvider(resourceNamesCache, resourceManager2, resourceAssembly2.Assembly, baseName); + var logger = Logger; + var localizer1 = new ResourceManagerStringLocalizer( + resourceManager1, + resourceStreamManager1, + baseName, + resourceNamesCache, + logger); + var localizer2 = new ResourceManagerStringLocalizer( + resourceManager2, + resourceStreamManager2, + baseName, + resourceNamesCache, + logger); + + // Act + localizer1.GetAllStrings().ToList(); + localizer2.GetAllStrings().ToList(); + + // Assert + var expectedCallCount = GetCultureInfoDepth(CultureInfo.CurrentUICulture); + Assert.Equal(expectedCallCount, resourceAssembly1.ManifestResourceStreamCallCount); + Assert.Equal(expectedCallCount, resourceAssembly2.ManifestResourceStreamCallCount); + } + + [Fact] + public void GetString_PopulatesSearchedLocationOnLocalizedString() + { + // Arrange + var baseName = "Resources.TestResource"; + var resourceNamesCache = new ResourceNamesCache(); + var resourceAssembly = new TestAssemblyWrapper(); + var resourceManager = new TestResourceManager(baseName, resourceAssembly); + var resourceStreamManager = new TestResourceStringProvider(resourceNamesCache, resourceManager, resourceAssembly.Assembly, baseName); + var logger = Logger; + var localizer = new ResourceManagerStringLocalizer( + resourceManager, + resourceStreamManager, + baseName, + resourceNamesCache, + logger); + + // Act + var value = localizer["name"]; + + // Assert + Assert.Equal("Resources.TestResource", value.SearchedLocation); + } + + [Fact] + [ReplaceCulture("en-US", "en-US")] + public void GetString_LogsLocationSearched() + { + // Arrange + var baseName = "Resources.TestResource"; + var resourceNamesCache = new ResourceNamesCache(); + var resourceAssembly = new TestAssemblyWrapper(); + var resourceManager = new TestResourceManager(baseName, resourceAssembly); + var resourceStreamManager = new TestResourceStringProvider(resourceNamesCache, resourceManager, resourceAssembly.Assembly, baseName); + var logger = Logger; + + var localizer = new ResourceManagerStringLocalizer( + resourceManager, + resourceStreamManager, + baseName, + resourceNamesCache, + logger); + + // Act + var value = localizer["a key!"]; + + // Assert + var write = Assert.Single(Sink.Writes); + Assert.Equal("ResourceManagerStringLocalizer searched for 'a key!' in 'Resources.TestResource' with culture 'en-US'.", write.State.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ResourceManagerStringLocalizer_GetAllStrings_ReturnsExpectedValue(bool includeParentCultures) + { + // Arrange + var baseName = "test"; + var resourceNamesCache = new ResourceNamesCache(); + var resourceAssembly = new TestAssemblyWrapper(); + var resourceManager = new TestResourceManager(baseName, resourceAssembly); + var resourceStreamManager = new TestResourceStringProvider(resourceNamesCache, resourceManager, resourceAssembly.Assembly, baseName); + var logger = Logger; + var localizer = new ResourceManagerStringLocalizer( + resourceManager, + resourceStreamManager, + baseName, + resourceNamesCache, + logger); + + // Act + // We have to access the result so it evaluates. + var strings = localizer.GetAllStrings(includeParentCultures).ToList(); + + // Assert + var value = Assert.Single(strings); + Assert.Equal("TestName", value.Value); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ResourceManagerStringLocalizer_GetAllStrings_MissingResourceThrows(bool includeParentCultures) + { + // Arrange + var resourceNamesCache = new ResourceNamesCache(); + var baseName = "testington"; + var resourceAssembly = new TestAssemblyWrapper(); + resourceAssembly.HasResources = false; + var resourceManager = new TestResourceManager(baseName, resourceAssembly); + var logger = Logger; + + var localizer = new ResourceManagerStringLocalizer( + resourceManager, + resourceAssembly.Assembly, + baseName, + resourceNamesCache, + logger); + + // Act & Assert + var exception = Assert.Throws(() => + { + // We have to access the result so it evaluates. + localizer.GetAllStrings(includeParentCultures).ToArray(); + }); + + var expectedTries = includeParentCultures ? 3 : 1; + var expected = includeParentCultures + ? "No manifests exist for the current culture." + : $"The manifest 'testington.{CultureInfo.CurrentCulture}.resources' was not found."; + Assert.Equal(expected, exception.Message); + Assert.Equal(expectedTries, resourceAssembly.ManifestResourceStreamCallCount); + } + + private static Stream MakeResourceStream() + { + var stream = new MemoryStream(); + var resourceWriter = new ResourceWriter(stream); + resourceWriter.AddResource("TestName", "value"); + resourceWriter.Generate(); + stream.Position = 0; + return stream; + } + + private static int GetCultureInfoDepth(CultureInfo culture) + { + var result = 0; + var currentCulture = culture; + + while (true) + { + result++; + + if (currentCulture == currentCulture.Parent) + { + break; + } + + currentCulture = currentCulture.Parent; + } + + return result; + } + + + private TestSink Sink { get; } = new TestSink(); + + private ILogger Logger => new TestLoggerFactory(Sink, enabled: true).CreateLogger(); + + public class TestResourceManager : ResourceManager + { + private AssemblyWrapper _assemblyWrapper; + + public TestResourceManager(string baseName, AssemblyWrapper assemblyWrapper) + : base(baseName, assemblyWrapper.Assembly) + { + _assemblyWrapper = assemblyWrapper; + } + + public override string GetString(string name, CultureInfo culture) => null; + + public override ResourceSet GetResourceSet(CultureInfo culture, bool createIfNotExists, bool tryParents) + { + var resourceStream = _assemblyWrapper.GetManifestResourceStream(BaseName); + + return resourceStream != null ? new ResourceSet(resourceStream) : null; + } + } + + public class TestResourceStringProvider : ResourceManagerStringProvider + { + public TestResourceStringProvider( + IResourceNamesCache resourceCache, + TestResourceManager resourceManager, + Assembly assembly, + string resourceBaseName) + : base(resourceCache, resourceManager, assembly, resourceBaseName) + { + } + } + + public class TestAssemblyWrapper : AssemblyWrapper + { + public TestAssemblyWrapper() + : this(typeof(TestAssemblyWrapper)) + { + } + + public TestAssemblyWrapper(Type type) + : base(type.GetTypeInfo().Assembly) + { + } + + public bool HasResources { get; set; } = true; + + public int ManifestResourceStreamCallCount { get; private set; } + + public override Stream GetManifestResourceStream(string name) + { + ManifestResourceStreamCallCount++; + + return HasResources ? MakeResourceStream() : null; + } + } + } +} diff --git a/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/StringLocalizerOfTTest.cs b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/StringLocalizerOfTTest.cs new file mode 100644 index 000000000000..53d290064e44 --- /dev/null +++ b/src/Localization/Localization/test/Microsoft.Extensions.Localization.Tests/StringLocalizerOfTTest.cs @@ -0,0 +1,137 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using Moq; +using Xunit; + +namespace Microsoft.Extensions.Localization +{ + public class StringLocalizerOfTTest + { + [Fact] + public void Constructor_ThrowsAnExceptionForNullFactory() + { + // Arrange, act and assert + var exception = Assert.Throws( + () => new StringLocalizer(factory: null)); + + Assert.Equal("factory", exception.ParamName); + } + + [Fact] + public void Constructor_ResolvesLocalizerFromFactory() + { + // Arrange + var factory = new Mock(); + + // Act + _ = new StringLocalizer(factory.Object); + + // Assert + factory.Verify(mock => mock.Create(typeof(object)), Times.Once()); + } + + [Fact] + public void Indexer_ThrowsAnExceptionForNullName() + { + // Arrange + var factory = new Mock(); + var innerLocalizer = new Mock(); + factory.Setup(mock => mock.Create(typeof(object))) + .Returns(innerLocalizer.Object); + + var localizer = new StringLocalizer(factory.Object); + + // Act and assert + var exception = Assert.Throws(() => localizer[name: null]); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public void Indexer_InvokesIndexerFromInnerLocalizer() + { + // Arrange + var factory = new Mock(); + var innerLocalizer = new Mock(); + factory.Setup(mock => mock.Create(typeof(object))) + .Returns(innerLocalizer.Object); + + var localizer = new StringLocalizer(factory.Object); + + // Act + _ = localizer["Hello world"]; + + // Assert + innerLocalizer.Verify(mock => mock["Hello world"], Times.Once()); + } + + [Fact] + public void Indexer_ThrowsAnExceptionForNullName_WithArguments() + { + // Arrange + var factory = new Mock(); + var innerLocalizer = new Mock(); + factory.Setup(mock => mock.Create(typeof(object))) + .Returns(innerLocalizer.Object); + + var localizer = new StringLocalizer(factory.Object); + + // Act and assert + var exception = Assert.Throws(() => localizer[name: null]); + + Assert.Equal("name", exception.ParamName); + } + + [Fact] + public void Indexer_InvokesIndexerFromInnerLocalizer_WithArguments() + { + // Arrange + var factory = new Mock(); + var innerLocalizer = new Mock(); + factory.Setup(mock => mock.Create(typeof(object))) + .Returns(innerLocalizer.Object); + + var localizer = new StringLocalizer(factory.Object); + + // Act + _ = localizer["Welcome, {0}", "Bob"]; + + // Assert + innerLocalizer.Verify(mock => mock["Welcome, {0}", "Bob"], Times.Once()); + } + + [Fact] + public void GetAllStrings_InvokesGetAllStringsFromInnerLocalizer() + { + // Arrange + var factory = new Mock(); + var innerLocalizer = new Mock(); + factory.Setup(mock => mock.Create(typeof(object))) + .Returns(innerLocalizer.Object); + + var localizer = new StringLocalizer(factory.Object); + + // Act + localizer.GetAllStrings(includeParentCultures: true); + + // Assert + innerLocalizer.Verify(mock => mock.GetAllStrings(true), Times.Once()); + } + + [Fact] + public void StringLocalizer_CanBeCastToBaseType() + { + // Arrange and act + IStringLocalizer localizer = new StringLocalizer(Mock.Of()); + + // Assert + Assert.NotNull(localizer); + } + + private class BaseType { } + private class DerivedType : BaseType { } + } +} diff --git a/src/Localization/README.md b/src/Localization/README.md new file mode 100644 index 000000000000..503620b3c67b --- /dev/null +++ b/src/Localization/README.md @@ -0,0 +1,6 @@ +Localization +============ + +These projects provide abstractions for localizing resources in .NET applications. + +The ASP.NET Core implementation of localization can be found in https://github.com/dotnet/aspnetcore/tree/master/src/Middleware/Localization. diff --git a/src/ObjectPool/ref/Microsoft.Extensions.ObjectPool.csproj b/src/ObjectPool/ref/Microsoft.Extensions.ObjectPool.csproj new file mode 100644 index 000000000000..1fbb81a9ca62 --- /dev/null +++ b/src/ObjectPool/ref/Microsoft.Extensions.ObjectPool.csproj @@ -0,0 +1,10 @@ + + + + netstandard2.0 + + + + + + diff --git a/src/ObjectPool/ref/Microsoft.Extensions.ObjectPool.netstandard2.0.cs b/src/ObjectPool/ref/Microsoft.Extensions.ObjectPool.netstandard2.0.cs new file mode 100644 index 000000000000..b3b72bec86e3 --- /dev/null +++ b/src/ObjectPool/ref/Microsoft.Extensions.ObjectPool.netstandard2.0.cs @@ -0,0 +1,76 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.ObjectPool +{ + public partial class DefaultObjectPoolProvider : Microsoft.Extensions.ObjectPool.ObjectPoolProvider + { + public DefaultObjectPoolProvider() { } + public int MaximumRetained { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public override Microsoft.Extensions.ObjectPool.ObjectPool Create(Microsoft.Extensions.ObjectPool.IPooledObjectPolicy policy) { throw null; } + } + public partial class DefaultObjectPool : Microsoft.Extensions.ObjectPool.ObjectPool where T : class + { + public DefaultObjectPool(Microsoft.Extensions.ObjectPool.IPooledObjectPolicy policy) { } + public DefaultObjectPool(Microsoft.Extensions.ObjectPool.IPooledObjectPolicy policy, int maximumRetained) { } + public override T Get() { throw null; } + public override void Return(T obj) { } + } + public partial class DefaultPooledObjectPolicy : Microsoft.Extensions.ObjectPool.PooledObjectPolicy where T : class, new() + { + public DefaultPooledObjectPolicy() { } + public override T Create() { throw null; } + public override bool Return(T obj) { throw null; } + } + public partial interface IPooledObjectPolicy + { + T Create(); + bool Return(T obj); + } + public partial class LeakTrackingObjectPoolProvider : Microsoft.Extensions.ObjectPool.ObjectPoolProvider + { + public LeakTrackingObjectPoolProvider(Microsoft.Extensions.ObjectPool.ObjectPoolProvider inner) { } + public override Microsoft.Extensions.ObjectPool.ObjectPool Create(Microsoft.Extensions.ObjectPool.IPooledObjectPolicy policy) { throw null; } + } + public partial class LeakTrackingObjectPool : Microsoft.Extensions.ObjectPool.ObjectPool where T : class + { + public LeakTrackingObjectPool(Microsoft.Extensions.ObjectPool.ObjectPool inner) { } + public override T Get() { throw null; } + public override void Return(T obj) { } + } + public static partial class ObjectPool + { + public static Microsoft.Extensions.ObjectPool.ObjectPool Create(Microsoft.Extensions.ObjectPool.IPooledObjectPolicy policy = null) where T : class, new() { throw null; } + } + public abstract partial class ObjectPoolProvider + { + protected ObjectPoolProvider() { } + public Microsoft.Extensions.ObjectPool.ObjectPool Create() where T : class, new() { throw null; } + public abstract Microsoft.Extensions.ObjectPool.ObjectPool Create(Microsoft.Extensions.ObjectPool.IPooledObjectPolicy policy) where T : class; + } + public static partial class ObjectPoolProviderExtensions + { + public static Microsoft.Extensions.ObjectPool.ObjectPool CreateStringBuilderPool(this Microsoft.Extensions.ObjectPool.ObjectPoolProvider provider) { throw null; } + public static Microsoft.Extensions.ObjectPool.ObjectPool CreateStringBuilderPool(this Microsoft.Extensions.ObjectPool.ObjectPoolProvider provider, int initialCapacity, int maximumRetainedCapacity) { throw null; } + } + public abstract partial class ObjectPool where T : class + { + protected ObjectPool() { } + public abstract T Get(); + public abstract void Return(T obj); + } + public abstract partial class PooledObjectPolicy : Microsoft.Extensions.ObjectPool.IPooledObjectPolicy + { + protected PooledObjectPolicy() { } + public abstract T Create(); + public abstract bool Return(T obj); + } + public partial class StringBuilderPooledObjectPolicy : Microsoft.Extensions.ObjectPool.PooledObjectPolicy + { + public StringBuilderPooledObjectPolicy() { } + public int InitialCapacity { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public int MaximumRetainedCapacity { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public override System.Text.StringBuilder Create() { throw null; } + public override bool Return(System.Text.StringBuilder obj) { throw null; } + } +} diff --git a/src/ObjectPool/src/DefaultObjectPool.cs b/src/ObjectPool/src/DefaultObjectPool.cs new file mode 100644 index 000000000000..f5627b7898bb --- /dev/null +++ b/src/ObjectPool/src/DefaultObjectPool.cs @@ -0,0 +1,103 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Microsoft.Extensions.ObjectPool +{ + /// + /// Default implementation of . + /// + /// The type to pool objects for. + /// This implementation keeps a cache of retained objects. This means that if objects are returned when the pool has already reached "maximumRetained" objects they will be available to be Garbage Collected. + public class DefaultObjectPool : ObjectPool where T : class + { + private protected readonly ObjectWrapper[] _items; + private protected readonly IPooledObjectPolicy _policy; + private protected readonly bool _isDefaultPolicy; + private protected T _firstItem; + + // This class was introduced in 2.1 to avoid the interface call where possible + private protected readonly PooledObjectPolicy _fastPolicy; + + /// + /// Creates an instance of . + /// + /// The pooling policy to use. + public DefaultObjectPool(IPooledObjectPolicy policy) + : this(policy, Environment.ProcessorCount * 2) + { + } + + /// + /// Creates an instance of . + /// + /// The pooling policy to use. + /// The maximum number of objects to retain in the pool. + public DefaultObjectPool(IPooledObjectPolicy policy, int maximumRetained) + { + _policy = policy ?? throw new ArgumentNullException(nameof(policy)); + _fastPolicy = policy as PooledObjectPolicy; + _isDefaultPolicy = IsDefaultPolicy(); + + // -1 due to _firstItem + _items = new ObjectWrapper[maximumRetained - 1]; + + bool IsDefaultPolicy() + { + var type = policy.GetType(); + + return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(DefaultPooledObjectPolicy<>); + } + } + + public override T Get() + { + var item = _firstItem; + if (item == null || Interlocked.CompareExchange(ref _firstItem, null, item) != item) + { + var items = _items; + for (var i = 0; i < items.Length; i++) + { + item = items[i].Element; + if (item != null && Interlocked.CompareExchange(ref items[i].Element, null, item) == item) + { + return item; + } + } + + item = Create(); + } + + return item; + } + + // Non-inline to improve its code quality as uncommon path + [MethodImpl(MethodImplOptions.NoInlining)] + private T Create() => _fastPolicy?.Create() ?? _policy.Create(); + + public override void Return(T obj) + { + if (_isDefaultPolicy || (_fastPolicy?.Return(obj) ?? _policy.Return(obj))) + { + if (_firstItem != null || Interlocked.CompareExchange(ref _firstItem, obj, null) != null) + { + var items = _items; + for (var i = 0; i < items.Length && Interlocked.CompareExchange(ref items[i].Element, obj, null) != null; ++i) + { + } + } + } + } + + // PERF: the struct wrapper avoids array-covariance-checks from the runtime when assigning to elements of the array. + [DebuggerDisplay("{Element}")] + private protected struct ObjectWrapper + { + public T Element; + } + } +} diff --git a/src/ObjectPool/src/DefaultObjectPoolProvider.cs b/src/ObjectPool/src/DefaultObjectPoolProvider.cs new file mode 100644 index 000000000000..b37a946d6d55 --- /dev/null +++ b/src/ObjectPool/src/DefaultObjectPoolProvider.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.ObjectPool +{ + /// + /// The default . + /// + public class DefaultObjectPoolProvider : ObjectPoolProvider + { + /// + /// The maximum number of objects to retain in the pool. + /// + public int MaximumRetained { get; set; } = Environment.ProcessorCount * 2; + + /// + public override ObjectPool Create(IPooledObjectPolicy policy) + { + if (policy == null) + { + throw new ArgumentNullException(nameof(policy)); + } + + if (typeof(IDisposable).IsAssignableFrom(typeof(T))) + { + return new DisposableObjectPool(policy, MaximumRetained); + } + + return new DefaultObjectPool(policy, MaximumRetained); + } + } +} diff --git a/src/ObjectPool/src/DefaultPooledObjectPolicy.cs b/src/ObjectPool/src/DefaultPooledObjectPolicy.cs new file mode 100644 index 000000000000..a7c386ae2a88 --- /dev/null +++ b/src/ObjectPool/src/DefaultPooledObjectPolicy.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.ObjectPool +{ + public class DefaultPooledObjectPolicy : PooledObjectPolicy where T : class, new() + { + public override T Create() + { + return new T(); + } + + // DefaultObjectPool doesn't call 'Return' for the default policy. + // So take care adding any logic to this method, as it might require changes elsewhere. + public override bool Return(T obj) + { + return true; + } + } +} diff --git a/src/ObjectPool/src/DisposableObjectPool.cs b/src/ObjectPool/src/DisposableObjectPool.cs new file mode 100644 index 000000000000..17ada443e508 --- /dev/null +++ b/src/ObjectPool/src/DisposableObjectPool.cs @@ -0,0 +1,93 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace Microsoft.Extensions.ObjectPool +{ + internal sealed class DisposableObjectPool : DefaultObjectPool, IDisposable where T : class + { + private volatile bool _isDisposed; + + public DisposableObjectPool(IPooledObjectPolicy policy) + : base(policy) + { + } + + public DisposableObjectPool(IPooledObjectPolicy policy, int maximumRetained) + : base(policy, maximumRetained) + { + } + + public override T Get() + { + if (_isDisposed) + { + ThrowObjectDisposedException(); + } + + return base.Get(); + + void ThrowObjectDisposedException() + { + throw new ObjectDisposedException(GetType().Name); + } + } + + public override void Return(T obj) + { + // When the pool is disposed or the obj is not returned to the pool, dispose it + if (_isDisposed || !ReturnCore(obj)) + { + DisposeItem(obj); + } + } + + private bool ReturnCore(T obj) + { + bool returnedTooPool = false; + + if (_isDefaultPolicy || (_fastPolicy?.Return(obj) ?? _policy.Return(obj))) + { + if (_firstItem == null && Interlocked.CompareExchange(ref _firstItem, obj, null) == null) + { + returnedTooPool = true; + } + else + { + var items = _items; + for (var i = 0; i < items.Length && !(returnedTooPool = Interlocked.CompareExchange(ref items[i].Element, obj, null) == null); i++) + { + } + } + } + + return returnedTooPool; + } + + public void Dispose() + { + _isDisposed = true; + + DisposeItem(_firstItem); + _firstItem = null; + + ObjectWrapper[] items = _items; + for (var i = 0; i < items.Length; i++) + { + DisposeItem(items[i].Element); + items[i].Element = null; + } + } + + private void DisposeItem(T item) + { + if (item is IDisposable disposable) + { + disposable.Dispose(); + } + } + } +} diff --git a/src/ObjectPool/src/IPooledObjectPolicy.cs b/src/ObjectPool/src/IPooledObjectPolicy.cs new file mode 100644 index 000000000000..458131e8247f --- /dev/null +++ b/src/ObjectPool/src/IPooledObjectPolicy.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.ObjectPool +{ + /// + /// Represents a policy for managing pooled objects. + /// + /// The type of object which is being pooled. + public interface IPooledObjectPolicy + { + /// + /// Create a . + /// + /// The which was created. + T Create(); + + /// + /// Runs some processing when an object was returned to the pool. Can be used to reset the state of an object and indicate if the object should be returned to the pool. + /// + /// The object to return to the pool. + /// if the object should be returned to the pool. if it's not possible/desirable for the pool to keep the object. + bool Return(T obj); + } +} diff --git a/src/ObjectPool/src/LeakTrackingObjectPool.cs b/src/ObjectPool/src/LeakTrackingObjectPool.cs new file mode 100644 index 000000000000..243d44d2daca --- /dev/null +++ b/src/ObjectPool/src/LeakTrackingObjectPool.cs @@ -0,0 +1,69 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Microsoft.Extensions.ObjectPool +{ + public class LeakTrackingObjectPool : ObjectPool where T : class + { + private readonly ConditionalWeakTable _trackers = new ConditionalWeakTable(); + private readonly ObjectPool _inner; + + public LeakTrackingObjectPool(ObjectPool inner) + { + if (inner == null) + { + throw new ArgumentNullException(nameof(inner)); + } + + _inner = inner; + } + + public override T Get() + { + var value = _inner.Get(); + _trackers.Add(value, new Tracker()); + return value; + } + + public override void Return(T obj) + { + Tracker tracker; + if (_trackers.TryGetValue(obj, out tracker)) + { + _trackers.Remove(obj); + tracker.Dispose(); + } + + _inner.Return(obj); + } + + private class Tracker : IDisposable + { + private readonly string _stack; + private bool _disposed; + + public Tracker() + { + _stack = Environment.StackTrace; + } + + public void Dispose() + { + _disposed = true; + GC.SuppressFinalize(this); + } + + ~Tracker() + { + if (!_disposed && !Environment.HasShutdownStarted) + { + Debug.Fail($"{typeof(T).Name} was leaked. Created at: {Environment.NewLine}{_stack}"); + } + } + } + } +} diff --git a/src/ObjectPool/src/LeakTrackingObjectPoolProvider.cs b/src/ObjectPool/src/LeakTrackingObjectPoolProvider.cs new file mode 100644 index 000000000000..134eaf161c31 --- /dev/null +++ b/src/ObjectPool/src/LeakTrackingObjectPoolProvider.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.ObjectPool +{ + public class LeakTrackingObjectPoolProvider : ObjectPoolProvider + { + private readonly ObjectPoolProvider _inner; + + public LeakTrackingObjectPoolProvider(ObjectPoolProvider inner) + { + if (inner == null) + { + throw new ArgumentNullException(nameof(inner)); + } + + _inner = inner; + } + + public override ObjectPool Create(IPooledObjectPolicy policy) + { + var inner = _inner.Create(policy); + return new LeakTrackingObjectPool(inner); + } + } +} diff --git a/src/ObjectPool/src/Microsoft.Extensions.ObjectPool.csproj b/src/ObjectPool/src/Microsoft.Extensions.ObjectPool.csproj new file mode 100644 index 000000000000..c193ad6e1f16 --- /dev/null +++ b/src/ObjectPool/src/Microsoft.Extensions.ObjectPool.csproj @@ -0,0 +1,17 @@ + + + + A simple object pool implementation. + netstandard2.0 + $(NoWarn);CS1591 + true + pooling + true + true + + + + + + + diff --git a/src/ObjectPool/src/ObjectPool.cs b/src/ObjectPool/src/ObjectPool.cs new file mode 100644 index 000000000000..0a82ed6f5392 --- /dev/null +++ b/src/ObjectPool/src/ObjectPool.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.ObjectPool +{ + /// + /// A pool of objects. + /// + /// The type of objects to pool. + public abstract class ObjectPool where T : class + { + /// + /// Gets an object from the pool if one is available, otherwise creates one. + /// + /// A . + public abstract T Get(); + + /// + /// Return an object to the pool. + /// + /// The object to add to the pool. + public abstract void Return(T obj); + } + + /// + /// Methods for creating instances. + /// + public static class ObjectPool + { + /// + public static ObjectPool Create(IPooledObjectPolicy policy = null) where T : class, new() + { + var provider = new DefaultObjectPoolProvider(); + return provider.Create(policy ?? new DefaultPooledObjectPolicy()); + } + } +} diff --git a/src/ObjectPool/src/ObjectPoolProvider.cs b/src/ObjectPool/src/ObjectPoolProvider.cs new file mode 100644 index 000000000000..6b8ca219eac6 --- /dev/null +++ b/src/ObjectPool/src/ObjectPoolProvider.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.ObjectPool +{ + /// + /// A provider of instances. + /// + public abstract class ObjectPoolProvider + { + /// + /// Creates an . + /// + /// The type to create a pool for. + public ObjectPool Create() where T : class, new() + { + return Create(new DefaultPooledObjectPolicy()); + } + + /// + /// Creates an with the given . + /// + /// The type to create a pool for. + public abstract ObjectPool Create(IPooledObjectPolicy policy) where T : class; + } +} diff --git a/src/ObjectPool/src/ObjectPoolProviderExtensions.cs b/src/ObjectPool/src/ObjectPoolProviderExtensions.cs new file mode 100644 index 000000000000..b9e93598894f --- /dev/null +++ b/src/ObjectPool/src/ObjectPoolProviderExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text; + +namespace Microsoft.Extensions.ObjectPool +{ + public static class ObjectPoolProviderExtensions + { + public static ObjectPool CreateStringBuilderPool(this ObjectPoolProvider provider) + { + return provider.Create(new StringBuilderPooledObjectPolicy()); + } + + public static ObjectPool CreateStringBuilderPool( + this ObjectPoolProvider provider, + int initialCapacity, + int maximumRetainedCapacity) + { + var policy = new StringBuilderPooledObjectPolicy() + { + InitialCapacity = initialCapacity, + MaximumRetainedCapacity = maximumRetainedCapacity, + }; + + return provider.Create(policy); + } + } +} diff --git a/src/ObjectPool/src/PooledObjectPolicy.cs b/src/ObjectPool/src/PooledObjectPolicy.cs new file mode 100644 index 000000000000..855b76496778 --- /dev/null +++ b/src/ObjectPool/src/PooledObjectPolicy.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.ObjectPool +{ + public abstract class PooledObjectPolicy : IPooledObjectPolicy + { + public abstract T Create(); + + public abstract bool Return(T obj); + } +} diff --git a/src/ObjectPool/src/StringBuilderPooledObjectPolicy.cs b/src/ObjectPool/src/StringBuilderPooledObjectPolicy.cs new file mode 100644 index 000000000000..94f318729a74 --- /dev/null +++ b/src/ObjectPool/src/StringBuilderPooledObjectPolicy.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text; + +namespace Microsoft.Extensions.ObjectPool +{ + public class StringBuilderPooledObjectPolicy : PooledObjectPolicy + { + public int InitialCapacity { get; set; } = 100; + + public int MaximumRetainedCapacity { get; set; } = 4 * 1024; + + public override StringBuilder Create() + { + return new StringBuilder(InitialCapacity); + } + + public override bool Return(StringBuilder obj) + { + if (obj.Capacity > MaximumRetainedCapacity) + { + // Too big. Discard this one. + return false; + } + + obj.Clear(); + return true; + } + } +} diff --git a/src/ObjectPool/test/DefaultObjectPoolProviderTest.cs b/src/ObjectPool/test/DefaultObjectPoolProviderTest.cs new file mode 100644 index 000000000000..7096b60b3452 --- /dev/null +++ b/src/ObjectPool/test/DefaultObjectPoolProviderTest.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.Extensions.ObjectPool +{ + public class DefaultObjectPoolProviderTest + { + [Fact] + public void DefaultObjectPoolProvider_CreateForObject_DefaultObjectPoolReturned() + { + // Arrange + var provider = new DefaultObjectPoolProvider(); + + // Act + var pool = provider.Create(); + + // Assert + Assert.IsType>(pool); + } + + [Fact] + public void DefaultObjectPoolProvider_CreateForIDisposable_DisposableObjectPoolReturned() + { + // Arrange + var provider = new DefaultObjectPoolProvider(); + + // Act + var pool = provider.Create(); + + // Assert + Assert.IsType>(pool); + } + + private class DisposableObject : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() => IsDisposed = true; + } + } +} diff --git a/src/ObjectPool/test/DefaultObjectPoolTest.cs b/src/ObjectPool/test/DefaultObjectPoolTest.cs new file mode 100644 index 000000000000..9bf84dca03b5 --- /dev/null +++ b/src/ObjectPool/test/DefaultObjectPoolTest.cs @@ -0,0 +1,85 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.ObjectPool +{ + public class DefaultObjectPoolTest + { + [Fact] + public void DefaultObjectPoolWithDefaultPolicy_GetAnd_ReturnObject_SameInstance() + { + // Arrange + var pool = new DefaultObjectPool(new DefaultPooledObjectPolicy()); + + var obj1 = pool.Get(); + pool.Return(obj1); + + // Act + var obj2 = pool.Get(); + + // Assert + Assert.Same(obj1, obj2); + } + + [Fact] + public void DefaultObjectPool_GetAndReturnObject_SameInstance() + { + // Arrange + var pool = new DefaultObjectPool>(new ListPolicy()); + + var list1 = pool.Get(); + pool.Return(list1); + + // Act + var list2 = pool.Get(); + + // Assert + Assert.Same(list1, list2); + } + + [Fact] + public void DefaultObjectPool_CreatedByPolicy() + { + // Arrange + var pool = new DefaultObjectPool>(new ListPolicy()); + + // Act + var list = pool.Get(); + + // Assert + Assert.Equal(17, list.Capacity); + } + + [Fact] + public void DefaultObjectPool_Return_RejectedByPolicy() + { + // Arrange + var pool = new DefaultObjectPool>(new ListPolicy()); + var list1 = pool.Get(); + list1.Capacity = 20; + + // Act + pool.Return(list1); + var list2 = pool.Get(); + + // Assert + Assert.NotSame(list1, list2); + } + + private class ListPolicy : IPooledObjectPolicy> + { + public List Create() + { + return new List(17); + } + + public bool Return(List obj) + { + return obj.Capacity == 17; + } + } + } +} diff --git a/src/ObjectPool/test/DisposableObjectPoolTest.cs b/src/ObjectPool/test/DisposableObjectPoolTest.cs new file mode 100644 index 000000000000..3bcefbaf66a1 --- /dev/null +++ b/src/ObjectPool/test/DisposableObjectPoolTest.cs @@ -0,0 +1,145 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.ObjectPool +{ + public class DisposableObjectPoolTest + { + [Fact] + public void DisposableObjectPoolWithDefaultPolicy_GetAnd_ReturnObject_SameInstance() + { + // Arrange + var pool = new DisposableObjectPool(new DefaultPooledObjectPolicy()); + + var obj1 = pool.Get(); + pool.Return(obj1); + + // Act + var obj2 = pool.Get(); + + // Assert + Assert.Same(obj1, obj2); + } + + [Fact] + public void DisposableObjectPool_GetAndReturnObject_SameInstance() + { + // Arrange + var pool = new DisposableObjectPool>(new ListPolicy()); + + var list1 = pool.Get(); + pool.Return(list1); + + // Act + var list2 = pool.Get(); + + // Assert + Assert.Same(list1, list2); + } + + [Fact] + public void DisposableObjectPool_Return_RejectedByPolicy() + { + // Arrange + var pool = new DisposableObjectPool>(new ListPolicy()); + var list1 = pool.Get(); + list1.Capacity = 20; + + // Act + pool.Return(list1); + var list2 = pool.Get(); + + // Assert + Assert.NotSame(list1, list2); + } + + [Fact] + public void DisposableObjectPoolWithOneElement_Dispose_ObjectDisposed() + { + // Arrange + var pool = new DisposableObjectPool(new DefaultPooledObjectPolicy()); + var obj = pool.Get(); + pool.Return(obj); + + // Act + pool.Dispose(); + + // Assert + Assert.True(obj.IsDisposed); + } + + [Fact] + public void DisposableObjectPoolWithTwoElements_Dispose_ObjectsDisposed() + { + // Arrange + var pool = new DisposableObjectPool(new DefaultPooledObjectPolicy()); + var obj1 = pool.Get(); + var obj2 = pool.Get(); + pool.Return(obj1); + pool.Return(obj2); + + // Act + pool.Dispose(); + + // Assert + Assert.True(obj1.IsDisposed); + Assert.True(obj2.IsDisposed); + } + + [Fact] + public void DisposableObjectPool_DisposeAndGet_ThrowsObjectDisposed() + { + // Arrange + var pool = new DisposableObjectPool(new DefaultPooledObjectPolicy()); + var obj1 = pool.Get(); + var obj2 = pool.Get(); + pool.Return(obj1); + pool.Return(obj2); + + // Act + pool.Dispose(); + + // Assert + Assert.Throws(() => pool.Get()); + } + + [Fact] + public void DisposableObjectPool_DisposeAndReturn_DisposesObject() + { + // Arrange + var pool = new DisposableObjectPool(new DefaultPooledObjectPolicy()); + var obj = pool.Get(); + + // Act + pool.Dispose(); + pool.Return(obj); + + // Assert + Assert.True(obj.IsDisposed); + } + + private class ListPolicy : IPooledObjectPolicy> + { + public List Create() + { + return new List(17); + } + + public bool Return(List obj) + { + return obj.Capacity == 17; + } + } + + private class DisposableObject : IDisposable + { + public bool IsDisposed { get; private set; } + + public void Dispose() => IsDisposed = true; + } + } +} diff --git a/src/ObjectPool/test/Microsoft.Extensions.ObjectPool.Tests.csproj b/src/ObjectPool/test/Microsoft.Extensions.ObjectPool.Tests.csproj new file mode 100644 index 000000000000..cc308fa8a009 --- /dev/null +++ b/src/ObjectPool/test/Microsoft.Extensions.ObjectPool.Tests.csproj @@ -0,0 +1,11 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + + + + + diff --git a/src/ObjectPool/test/ThreadingTest.cs b/src/ObjectPool/test/ThreadingTest.cs new file mode 100644 index 000000000000..dbab7a530133 --- /dev/null +++ b/src/ObjectPool/test/ThreadingTest.cs @@ -0,0 +1,92 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using Xunit; + +namespace Microsoft.Extensions.ObjectPool +{ + public class ThreadingTest + { + private CancellationTokenSource _cts; + private DefaultObjectPool _pool; + private bool _foundError; + + [Fact] + public void DefaultObjectPool_RunThreadingTest() + { + _pool = new DefaultObjectPool(new DefaultPooledObjectPolicy(), 10); + RunThreadingTest(); + } + + [Fact] + public void DisposableObjectPool_RunThreadingTest() + { + _pool = new DisposableObjectPool(new DefaultPooledObjectPolicy(), 10); + RunThreadingTest(); + } + + private void RunThreadingTest() + { + _cts = new CancellationTokenSource(); + + var threads = new Thread[8]; + for (var i = 0; i < threads.Length; i++) + { + threads[i] = new Thread(Run); + } + + for (var i = 0; i < threads.Length; i++) + { + threads[i].Start(); + } + + // Run for 1000ms + _cts.CancelAfter(1000); + + // Wait for all threads to complete + for (var i = 0; i < threads.Length; i++) + { + threads[i].Join(); + } + + Assert.False(_foundError, "Race condition found. An item was shared across threads."); + } + + private void Run() + { + while (!_cts.IsCancellationRequested) + { + var obj = _pool.Get(); + if (obj.i != 0) + { + _foundError = true; + } + obj.i = 123; + + var obj2 = _pool.Get(); + if (obj2.i != 0) + { + _foundError = true; + } + obj2.i = 321; + + obj.Reset(); + _pool.Return(obj); + + obj2.Reset(); + _pool.Return(obj2); + } + } + + private class Item + { + public int i = 0; + + public void Reset() + { + i = 0; + } + } + } +} diff --git a/src/Shared/ActivatorUtilities/ActivatorUtilities.cs b/src/Shared/ActivatorUtilities/ActivatorUtilities.cs new file mode 100644 index 000000000000..4d05ebf58967 --- /dev/null +++ b/src/Shared/ActivatorUtilities/ActivatorUtilities.cs @@ -0,0 +1,432 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.ExceptionServices; + +#if ActivatorUtilities_In_DependencyInjection +using Microsoft.Extensions.Internal; + +namespace Microsoft.Extensions.DependencyInjection +#else +namespace Microsoft.Extensions.Internal +#endif +{ + /// + /// Helper code for the various activator services. + /// + +#if ActivatorUtilities_In_DependencyInjection + public +#else + // Do not take a dependency on this class unless you are explicitly trying to avoid taking a + // dependency on Microsoft.AspNetCore.DependencyInjection.Abstractions. + internal +#endif + static class ActivatorUtilities + { + private static readonly MethodInfo GetServiceInfo = + GetMethodInfo>((sp, t, r, c) => GetService(sp, t, r, c)); + + /// + /// Instantiate a type with constructor arguments provided directly and/or from an . + /// + /// The service provider used to resolve dependencies + /// The type to activate + /// Constructor arguments not provided by the . + /// An activated object of type instanceType + public static object CreateInstance(IServiceProvider provider, Type instanceType, params object[] parameters) + { + int bestLength = -1; + var seenPreferred = false; + + ConstructorMatcher bestMatcher = default; + + if (!instanceType.GetTypeInfo().IsAbstract) + { + foreach (var constructor in instanceType + .GetTypeInfo() + .DeclaredConstructors) + { + if (!constructor.IsStatic && constructor.IsPublic) + { + var matcher = new ConstructorMatcher(constructor); + var isPreferred = constructor.IsDefined(typeof(ActivatorUtilitiesConstructorAttribute), false); + var length = matcher.Match(parameters); + + if (isPreferred) + { + if (seenPreferred) + { + ThrowMultipleCtorsMarkedWithAttributeException(); + } + + if (length == -1) + { + ThrowMarkedCtorDoesNotTakeAllProvidedArguments(); + } + } + + if (isPreferred || bestLength < length) + { + bestLength = length; + bestMatcher = matcher; + } + + seenPreferred |= isPreferred; + } + } + } + + if (bestLength == -1) + { + var message = $"A suitable constructor for type '{instanceType}' could not be located. Ensure the type is concrete and services are registered for all parameters of a public constructor."; + throw new InvalidOperationException(message); + } + + return bestMatcher.CreateInstance(provider); + } + + /// + /// Create a delegate that will instantiate a type with constructor arguments provided directly + /// and/or from an . + /// + /// The type to activate + /// + /// The types of objects, in order, that will be passed to the returned function as its second parameter + /// + /// + /// A factory that will instantiate instanceType using an + /// and an argument array containing objects matching the types defined in argumentTypes + /// + public static ObjectFactory CreateFactory(Type instanceType, Type[] argumentTypes) + { + FindApplicableConstructor(instanceType, argumentTypes, out ConstructorInfo constructor, out int?[] parameterMap); + + var provider = Expression.Parameter(typeof(IServiceProvider), "provider"); + var argumentArray = Expression.Parameter(typeof(object[]), "argumentArray"); + var factoryExpressionBody = BuildFactoryExpression(constructor, parameterMap, provider, argumentArray); + + var factoryLamda = Expression.Lambda>( + factoryExpressionBody, provider, argumentArray); + + var result = factoryLamda.Compile(); + return result.Invoke; + } + + /// + /// Instantiate a type with constructor arguments provided directly and/or from an . + /// + /// The type to activate + /// The service provider used to resolve dependencies + /// Constructor arguments not provided by the . + /// An activated object of type T + public static T CreateInstance(IServiceProvider provider, params object[] parameters) + { + return (T)CreateInstance(provider, typeof(T), parameters); + } + + + /// + /// Retrieve an instance of the given type from the service provider. If one is not found then instantiate it directly. + /// + /// The type of the service + /// The service provider used to resolve dependencies + /// The resolved service or created instance + public static T GetServiceOrCreateInstance(IServiceProvider provider) + { + return (T)GetServiceOrCreateInstance(provider, typeof(T)); + } + + /// + /// Retrieve an instance of the given type from the service provider. If one is not found then instantiate it directly. + /// + /// The service provider + /// The type of the service + /// The resolved service or created instance + public static object GetServiceOrCreateInstance(IServiceProvider provider, Type type) + { + return provider.GetService(type) ?? CreateInstance(provider, type); + } + + private static MethodInfo GetMethodInfo(Expression expr) + { + var mc = (MethodCallExpression)expr.Body; + return mc.Method; + } + + private static object GetService(IServiceProvider sp, Type type, Type requiredBy, bool isDefaultParameterRequired) + { + var service = sp.GetService(type); + if (service == null && !isDefaultParameterRequired) + { + var message = $"Unable to resolve service for type '{type}' while attempting to activate '{requiredBy}'."; + throw new InvalidOperationException(message); + } + return service; + } + + private static Expression BuildFactoryExpression( + ConstructorInfo constructor, + int?[] parameterMap, + Expression serviceProvider, + Expression factoryArgumentArray) + { + var constructorParameters = constructor.GetParameters(); + var constructorArguments = new Expression[constructorParameters.Length]; + + for (var i = 0; i < constructorParameters.Length; i++) + { + var constructorParameter = constructorParameters[i]; + var parameterType = constructorParameter.ParameterType; + var hasDefaultValue = ParameterDefaultValue.TryGetDefaultValue(constructorParameter, out var defaultValue); + + if (parameterMap[i] != null) + { + constructorArguments[i] = Expression.ArrayAccess(factoryArgumentArray, Expression.Constant(parameterMap[i])); + } + else + { + var parameterTypeExpression = new Expression[] { serviceProvider, + Expression.Constant(parameterType, typeof(Type)), + Expression.Constant(constructor.DeclaringType, typeof(Type)), + Expression.Constant(hasDefaultValue) }; + constructorArguments[i] = Expression.Call(GetServiceInfo, parameterTypeExpression); + } + + // Support optional constructor arguments by passing in the default value + // when the argument would otherwise be null. + if (hasDefaultValue) + { + var defaultValueExpression = Expression.Constant(defaultValue); + constructorArguments[i] = Expression.Coalesce(constructorArguments[i], defaultValueExpression); + } + + constructorArguments[i] = Expression.Convert(constructorArguments[i], parameterType); + } + + return Expression.New(constructor, constructorArguments); + } + + private static void FindApplicableConstructor( + Type instanceType, + Type[] argumentTypes, + out ConstructorInfo matchingConstructor, + out int?[] parameterMap) + { + matchingConstructor = null; + parameterMap = null; + + if (!TryFindPreferredConstructor(instanceType, argumentTypes, ref matchingConstructor, ref parameterMap) && + !TryFindMatchingConstructor(instanceType, argumentTypes, ref matchingConstructor, ref parameterMap)) + { + var message = $"A suitable constructor for type '{instanceType}' could not be located. Ensure the type is concrete and services are registered for all parameters of a public constructor."; + throw new InvalidOperationException(message); + } + } + + // Tries to find constructor based on provided argument types + private static bool TryFindMatchingConstructor( + Type instanceType, + Type[] argumentTypes, + ref ConstructorInfo matchingConstructor, + ref int?[] parameterMap) + { + foreach (var constructor in instanceType.GetTypeInfo().DeclaredConstructors) + { + if (constructor.IsStatic || !constructor.IsPublic) + { + continue; + } + + if (TryCreateParameterMap(constructor.GetParameters(), argumentTypes, out int?[] tempParameterMap)) + { + if (matchingConstructor != null) + { + throw new InvalidOperationException($"Multiple constructors accepting all given argument types have been found in type '{instanceType}'. There should only be one applicable constructor."); + } + + matchingConstructor = constructor; + parameterMap = tempParameterMap; + } + } + + return matchingConstructor != null; + } + + // Tries to find constructor marked with ActivatorUtilitiesConstructorAttribute + private static bool TryFindPreferredConstructor( + Type instanceType, + Type[] argumentTypes, + ref ConstructorInfo matchingConstructor, + ref int?[] parameterMap) + { + var seenPreferred = false; + foreach (var constructor in instanceType.GetTypeInfo().DeclaredConstructors) + { + if (constructor.IsStatic || !constructor.IsPublic) + { + continue; + } + + if (constructor.IsDefined(typeof(ActivatorUtilitiesConstructorAttribute), false)) + { + if (seenPreferred) + { + ThrowMultipleCtorsMarkedWithAttributeException(); + } + + if (!TryCreateParameterMap(constructor.GetParameters(), argumentTypes, out int?[] tempParameterMap)) + { + ThrowMarkedCtorDoesNotTakeAllProvidedArguments(); + } + + matchingConstructor = constructor; + parameterMap = tempParameterMap; + seenPreferred = true; + } + } + + return matchingConstructor != null; + } + + // Creates an injective parameterMap from givenParameterTypes to assignable constructorParameters. + // Returns true if each given parameter type is assignable to a unique; otherwise, false. + private static bool TryCreateParameterMap(ParameterInfo[] constructorParameters, Type[] argumentTypes, out int?[] parameterMap) + { + parameterMap = new int?[constructorParameters.Length]; + + for (var i = 0; i < argumentTypes.Length; i++) + { + var foundMatch = false; + var givenParameter = argumentTypes[i].GetTypeInfo(); + + for (var j = 0; j < constructorParameters.Length; j++) + { + if (parameterMap[j] != null) + { + // This ctor parameter has already been matched + continue; + } + + if (constructorParameters[j].ParameterType.GetTypeInfo().IsAssignableFrom(givenParameter)) + { + foundMatch = true; + parameterMap[j] = i; + break; + } + } + + if (!foundMatch) + { + return false; + } + } + + return true; + } + + private struct ConstructorMatcher + { + private readonly ConstructorInfo _constructor; + private readonly ParameterInfo[] _parameters; + private readonly object[] _parameterValues; + + public ConstructorMatcher(ConstructorInfo constructor) + { + _constructor = constructor; + _parameters = _constructor.GetParameters(); + _parameterValues = new object[_parameters.Length]; + } + + public int Match(object[] givenParameters) + { + var applyIndexStart = 0; + var applyExactLength = 0; + for (var givenIndex = 0; givenIndex != givenParameters.Length; givenIndex++) + { + var givenType = givenParameters[givenIndex]?.GetType().GetTypeInfo(); + var givenMatched = false; + + for (var applyIndex = applyIndexStart; givenMatched == false && applyIndex != _parameters.Length; ++applyIndex) + { + if (_parameterValues[applyIndex] == null && + _parameters[applyIndex].ParameterType.GetTypeInfo().IsAssignableFrom(givenType)) + { + givenMatched = true; + _parameterValues[applyIndex] = givenParameters[givenIndex]; + if (applyIndexStart == applyIndex) + { + applyIndexStart++; + if (applyIndex == givenIndex) + { + applyExactLength = applyIndex; + } + } + } + } + + if (givenMatched == false) + { + return -1; + } + } + return applyExactLength; + } + + public object CreateInstance(IServiceProvider provider) + { + for (var index = 0; index != _parameters.Length; index++) + { + if (_parameterValues[index] == null) + { + var value = provider.GetService(_parameters[index].ParameterType); + if (value == null) + { + if (!ParameterDefaultValue.TryGetDefaultValue(_parameters[index], out var defaultValue)) + { + throw new InvalidOperationException($"Unable to resolve service for type '{_parameters[index].ParameterType}' while attempting to activate '{_constructor.DeclaringType}'."); + } + else + { + _parameterValues[index] = defaultValue; + } + } + else + { + _parameterValues[index] = value; + } + } + } + +#if NETCOREAPP + return _constructor.Invoke(BindingFlags.DoNotWrapExceptions, binder: null, parameters: _parameterValues, culture: null); +#else + try + { + return _constructor.Invoke(_parameterValues); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + // The above line will always throw, but the compiler requires we throw explicitly. + throw; + } +#endif + } + } + + private static void ThrowMultipleCtorsMarkedWithAttributeException() + { + throw new InvalidOperationException($"Multiple constructors were marked with {nameof(ActivatorUtilitiesConstructorAttribute)}."); + } + + private static void ThrowMarkedCtorDoesNotTakeAllProvidedArguments() + { + throw new InvalidOperationException($"Constructor marked with {nameof(ActivatorUtilitiesConstructorAttribute)} does not accept all given argument types."); + } + } +} diff --git a/src/Shared/ActivatorUtilities/ActivatorUtilitiesConstructorAttribute.cs b/src/Shared/ActivatorUtilities/ActivatorUtilitiesConstructorAttribute.cs new file mode 100644 index 000000000000..67ffa13f6fc0 --- /dev/null +++ b/src/Shared/ActivatorUtilities/ActivatorUtilitiesConstructorAttribute.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +#if ActivatorUtilities_In_DependencyInjection +namespace Microsoft.Extensions.DependencyInjection +#else +namespace Microsoft.Extensions.Internal +#endif +{ + /// + /// Marks the constructor to be used when activating type using . + /// + +#if ActivatorUtilities_In_DependencyInjection + public +#else + // Do not take a dependency on this class unless you are explicitly trying to avoid taking a + // dependency on Microsoft.AspNetCore.DependencyInjection.Abstractions. + internal +#endif + class ActivatorUtilitiesConstructorAttribute: Attribute + { + } +} diff --git a/src/Shared/ActivatorUtilities/ObjectFactory.cs b/src/Shared/ActivatorUtilities/ObjectFactory.cs new file mode 100644 index 000000000000..517247811ec7 --- /dev/null +++ b/src/Shared/ActivatorUtilities/ObjectFactory.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +#if ActivatorUtilities_In_DependencyInjection +namespace Microsoft.Extensions.DependencyInjection +#else +namespace Microsoft.Extensions.Internal +#endif +{ + + /// + /// The result of . + /// + /// The to get service arguments from. + /// Additional constructor arguments. + /// The instantiated type. +#if ActivatorUtilities_In_DependencyInjection + public +#else + internal +#endif + delegate object ObjectFactory(IServiceProvider serviceProvider, object[] arguments); +} \ No newline at end of file diff --git a/src/Shared/BenchmarkRunner/AspNetCoreBenchmarkAttribute.cs b/src/Shared/BenchmarkRunner/AspNetCoreBenchmarkAttribute.cs new file mode 100644 index 000000000000..d16493a738e6 --- /dev/null +++ b/src/Shared/BenchmarkRunner/AspNetCoreBenchmarkAttribute.cs @@ -0,0 +1,73 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using BenchmarkDotNet.Configs; + +namespace BenchmarkDotNet.Attributes +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)] + internal class AspNetCoreBenchmarkAttribute : Attribute, IConfigSource + { + public AspNetCoreBenchmarkAttribute() + : this(typeof(DefaultCoreConfig)) + { + } + + public AspNetCoreBenchmarkAttribute(Type configType) + : this(configType, typeof(DefaultCoreValidationConfig)) + { + } + + public AspNetCoreBenchmarkAttribute(Type configType, Type validationConfigType) + { + ConfigTypes = new Dictionary() + { + { NamedConfiguration.Default, typeof(DefaultCoreConfig) }, + { NamedConfiguration.Validation, typeof(DefaultCoreValidationConfig) }, + { NamedConfiguration.Profile, typeof(DefaultCoreProfileConfig) }, + { NamedConfiguration.Debug, typeof(DefaultCoreDebugConfig) }, + { NamedConfiguration.PerfLab, typeof(DefaultCorePerfLabConfig) }, + }; + + if (configType != null) + { + ConfigTypes[NamedConfiguration.Default] = configType; + } + + if (validationConfigType != null) + { + ConfigTypes[NamedConfiguration.Validation] = validationConfigType; + } + } + + public IConfig Config + { + get + { + if (!ConfigTypes.TryGetValue(ConfigName ?? NamedConfiguration.Default, out var configType)) + { + var message = $"Could not find a configuration matching {ConfigName}. " + + $"Known configurations: {string.Join(", ", ConfigTypes.Keys)}"; + throw new InvalidOperationException(message); + } + + return (IConfig)Activator.CreateInstance(configType, Array.Empty()); + } + } + + public Dictionary ConfigTypes { get; } + + public static string ConfigName { get; set; } = NamedConfiguration.Default; + + public static class NamedConfiguration + { + public static readonly string Default = "default"; + public static readonly string Validation = "validation"; + public static readonly string Profile = "profile"; + public static readonly string Debug = "debug"; + public static readonly string PerfLab = "perflab"; + } + } +} diff --git a/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs new file mode 100644 index 000000000000..0691936c3542 --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCoreConfig.cs @@ -0,0 +1,46 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Toolchains.CsProj; +using BenchmarkDotNet.Toolchains.DotNetCli; +using BenchmarkDotNet.Validators; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCoreConfig : ManualConfig + { + public DefaultCoreConfig() + { + Add(ConsoleLogger.Default); + Add(MarkdownExporter.GitHub); + + Add(MemoryDiagnoser.Default); + Add(StatisticColumn.OperationsPerSecond); + Add(DefaultColumnProviders.Instance); + + Add(JitOptimizationsValidator.FailOnError); + + Add(Job.Core +#if NETCOREAPP2_1 + .With(CsProjCoreToolchain.From(NetCoreAppSettings.NetCoreApp21)) +#elif NETCOREAPP3_0 + .With(CsProjCoreToolchain.From(new NetCoreAppSettings("netcoreapp3.0", null, ".NET Core 3.0"))) +#elif NETCOREAPP3_1 + .With(CsProjCoreToolchain.From(new NetCoreAppSettings("netcoreapp3.1", null, ".NET Core 3.1"))) +#elif NETCOREAPP5_0 + .With(CsProjCoreToolchain.From(new NetCoreAppSettings("netcoreapp5.0", null, ".NET Core 5.0"))) +#else +#error Target frameworks need to be updated. +#endif + .With(new GcMode { Server = true }) + .With(RunStrategy.Throughput)); + } + } +} diff --git a/src/Shared/BenchmarkRunner/DefaultCoreDebugConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreDebugConfig.cs new file mode 100644 index 000000000000..f51bed2db997 --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCoreDebugConfig.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Validators; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCoreDebugConfig : ManualConfig + { + public DefaultCoreDebugConfig() + { + Add(ConsoleLogger.Default); + Add(JitOptimizationsValidator.DontFailOnError); + + Add(Job.InProcess + .With(RunStrategy.Throughput)); + } + } +} diff --git a/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs b/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs new file mode 100644 index 000000000000..5cc809e16613 --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCorePerfLabConfig.cs @@ -0,0 +1,48 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Exporters.Csv; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Validators; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCorePerfLabConfig : ManualConfig + { + public DefaultCorePerfLabConfig() + { + Add(ConsoleLogger.Default); + + Add(MemoryDiagnoser.Default); + Add(StatisticColumn.OperationsPerSecond); + Add(new ParamsSummaryColumn()); + Add(DefaultColumnProviders.Statistics, DefaultColumnProviders.Diagnosers, DefaultColumnProviders.Target); + + // TODO: When upgrading to BDN 0.11.1, use Add(DefaultColumnProviders.Descriptor); + // DefaultColumnProviders.Target is deprecated + + Add(JitOptimizationsValidator.FailOnError); + + Add(Job.InProcess + .With(RunStrategy.Throughput)); + + Add(MarkdownExporter.GitHub); + + Add(new CsvExporter( + CsvSeparator.Comma, + new Reports.SummaryStyle + { + PrintUnitsInHeader = true, + PrintUnitsInContent = false, + TimeUnit = Horology.TimeUnit.Microsecond, + SizeUnit = SizeUnit.KB + })); + } + } +} diff --git a/src/Shared/BenchmarkRunner/DefaultCoreProfileConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreProfileConfig.cs new file mode 100644 index 000000000000..1b59cb89c54c --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCoreProfileConfig.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Engines; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Validators; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCoreProfileConfig : ManualConfig + { + public DefaultCoreProfileConfig() + { + Add(ConsoleLogger.Default); + Add(MarkdownExporter.GitHub); + + Add(MemoryDiagnoser.Default); + Add(StatisticColumn.OperationsPerSecond); + Add(DefaultColumnProviders.Instance); + + Add(JitOptimizationsValidator.FailOnError); + + Add(Job.InProcess + .With(RunStrategy.Throughput)); + } + } +} diff --git a/src/Shared/BenchmarkRunner/DefaultCoreValidationConfig.cs b/src/Shared/BenchmarkRunner/DefaultCoreValidationConfig.cs new file mode 100644 index 000000000000..5a90929cff8c --- /dev/null +++ b/src/Shared/BenchmarkRunner/DefaultCoreValidationConfig.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Toolchains.InProcess; + +namespace BenchmarkDotNet.Attributes +{ + internal class DefaultCoreValidationConfig : ManualConfig + { + public DefaultCoreValidationConfig() + { + Add(ConsoleLogger.Default); + + Add(Job.Dry.With(InProcessToolchain.Instance)); + } + } +} diff --git a/src/Shared/BenchmarkRunner/ParameterizedJobConfigAttribute.cs b/src/Shared/BenchmarkRunner/ParameterizedJobConfigAttribute.cs new file mode 100644 index 000000000000..9e0f947dc75d --- /dev/null +++ b/src/Shared/BenchmarkRunner/ParameterizedJobConfigAttribute.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace BenchmarkDotNet.Attributes +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly)] + internal class ParameterizedJobConfigAttribute: AspNetCoreBenchmarkAttribute + { + public ParameterizedJobConfigAttribute(Type configType) : base(configType) + { + } + } +} diff --git a/src/Shared/BenchmarkRunner/ParamsDisplayInfoColumn.cs b/src/Shared/BenchmarkRunner/ParamsDisplayInfoColumn.cs new file mode 100644 index 000000000000..b246e21c2e29 --- /dev/null +++ b/src/Shared/BenchmarkRunner/ParamsDisplayInfoColumn.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; + +namespace BenchmarkDotNet.Attributes +{ + public class ParamsSummaryColumn : IColumn + { + public string Id => nameof(ParamsSummaryColumn); + public string ColumnName { get; } = "Params"; + public bool IsDefault(Summary summary, Benchmark benchmark) => false; + public string GetValue(Summary summary, Benchmark benchmark) => benchmark.Parameters.DisplayInfo; + public bool IsAvailable(Summary summary) => true; + public bool AlwaysShow => true; + public ColumnCategory Category => ColumnCategory.Params; + public int PriorityInCategory => 0; + public override string ToString() => ColumnName; + public bool IsNumeric => false; + public UnitType UnitType => UnitType.Dimensionless; + public string GetValue(Summary summary, Benchmark benchmark, ISummaryStyle style) => GetValue(summary, benchmark); + public string Legend => $"Summary of all parameter values"; + } +} \ No newline at end of file diff --git a/src/Shared/BenchmarkRunner/Program.cs b/src/Shared/BenchmarkRunner/Program.cs new file mode 100644 index 000000000000..a1db1a50e83a --- /dev/null +++ b/src/Shared/BenchmarkRunner/Program.cs @@ -0,0 +1,106 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Running; + +namespace Microsoft.AspNetCore.BenchmarkDotNet.Runner +{ + partial class Program + { + private static TextWriter _standardOutput; + private static StringBuilder _standardOutputText; + + static partial void BeforeMain(string[] args); + + private static int Main(string[] args) + { + BeforeMain(args); + + AssignConfiguration(ref args); + var summaries = BenchmarkSwitcher.FromAssembly(typeof(Program).GetTypeInfo().Assembly) + .Run(args, ManualConfig.CreateEmpty()); + + foreach (var summary in summaries) + { + if (summary.HasCriticalValidationErrors) + { + return Fail(summary, nameof(summary.HasCriticalValidationErrors)); + } + + foreach (var report in summary.Reports) + { + if (!report.BuildResult.IsGenerateSuccess) + { + return Fail(report, nameof(report.BuildResult.IsGenerateSuccess)); + } + + if (!report.BuildResult.IsBuildSuccess) + { + return Fail(report, nameof(report.BuildResult.IsBuildSuccess)); + } + + if (!report.AllMeasurements.Any()) + { + return Fail(report, nameof(report.AllMeasurements)); + } + } + } + + return 0; + } + + private static int Fail(object o, string message) + { + _standardOutput?.WriteLine(_standardOutputText.ToString()); + + Console.Error.WriteLine("'{0}' failed, reason: '{1}'", o, message); + return 1; + } + + private static void AssignConfiguration(ref string[] args) + { + var argsList = args.ToList(); + if (argsList.Remove("--validate") || argsList.Remove("--validate-fast")) + { + // Compat: support the old style of passing a config that is used by our build system. + SuppressConsole(); + AspNetCoreBenchmarkAttribute.ConfigName = AspNetCoreBenchmarkAttribute.NamedConfiguration.Validation; + args = argsList.ToArray(); + return; + } + + var index = argsList.IndexOf("--config"); + if (index >= 0 && index < argsList.Count -1) + { + AspNetCoreBenchmarkAttribute.ConfigName = argsList[index + 1]; + argsList.RemoveAt(index + 1); + argsList.RemoveAt(index); + args = argsList.ToArray(); + return; + } + + if (Debugger.IsAttached) + { + Console.WriteLine("Using the debug config since you are debugging. I hope that's OK!"); + Console.WriteLine("Specify a configuration with --config to override"); + AspNetCoreBenchmarkAttribute.ConfigName = AspNetCoreBenchmarkAttribute.NamedConfiguration.Debug; + return; + } + } + + private static void SuppressConsole() + { + _standardOutput = Console.Out; + _standardOutputText = new StringBuilder(); + Console.SetOut(new StringWriter(_standardOutputText)); + } + } +} diff --git a/src/Shared/CommandLineUtils/CommandLine/AnsiConsole.cs b/src/Shared/CommandLineUtils/CommandLine/AnsiConsole.cs new file mode 100644 index 000000000000..379235f27425 --- /dev/null +++ b/src/Shared/CommandLineUtils/CommandLine/AnsiConsole.cs @@ -0,0 +1,143 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.Extensions.CommandLineUtils +{ + internal class AnsiConsole + { + private AnsiConsole(TextWriter writer, bool useConsoleColor) + { + Writer = writer; + + _useConsoleColor = useConsoleColor; + if (_useConsoleColor) + { + OriginalForegroundColor = Console.ForegroundColor; + } + } + + private int _boldRecursion; + private bool _useConsoleColor; + + public static AnsiConsole GetOutput(bool useConsoleColor) + { + return new AnsiConsole(Console.Out, useConsoleColor); + } + + public static AnsiConsole GetError(bool useConsoleColor) + { + return new AnsiConsole(Console.Error, useConsoleColor); + } + + public TextWriter Writer { get; } + + public ConsoleColor OriginalForegroundColor { get; } + + private void SetColor(ConsoleColor color) + { + Console.ForegroundColor = (ConsoleColor)(((int)Console.ForegroundColor & 0x08) | ((int)color & 0x07)); + } + + private void SetBold(bool bold) + { + _boldRecursion += bold ? 1 : -1; + if (_boldRecursion > 1 || (_boldRecursion == 1 && !bold)) + { + return; + } + + Console.ForegroundColor = (ConsoleColor)((int)Console.ForegroundColor ^ 0x08); + } + + public void WriteLine(string message) + { + if (!_useConsoleColor) + { + Writer.WriteLine(message); + return; + } + + var escapeScan = 0; + for (; ;) + { + var escapeIndex = message.IndexOf("\x1b[", escapeScan); + if (escapeIndex == -1) + { + var text = message.Substring(escapeScan); + Writer.Write(text); + break; + } + else + { + var startIndex = escapeIndex + 2; + var endIndex = startIndex; + while (endIndex != message.Length && + message[endIndex] >= 0x20 && + message[endIndex] <= 0x3f) + { + endIndex += 1; + } + + var text = message.Substring(escapeScan, escapeIndex - escapeScan); + Writer.Write(text); + if (endIndex == message.Length) + { + break; + } + + switch (message[endIndex]) + { + case 'm': + int value; + if (int.TryParse(message.Substring(startIndex, endIndex - startIndex), out value)) + { + switch (value) + { + case 1: + SetBold(true); + break; + case 22: + SetBold(false); + break; + case 30: + SetColor(ConsoleColor.Black); + break; + case 31: + SetColor(ConsoleColor.Red); + break; + case 32: + SetColor(ConsoleColor.Green); + break; + case 33: + SetColor(ConsoleColor.Yellow); + break; + case 34: + SetColor(ConsoleColor.Blue); + break; + case 35: + SetColor(ConsoleColor.Magenta); + break; + case 36: + SetColor(ConsoleColor.Cyan); + break; + case 37: + SetColor(ConsoleColor.Gray); + break; + case 39: + SetColor(OriginalForegroundColor); + break; + } + } + break; + } + + escapeScan = endIndex + 1; + } + } + Writer.WriteLine(); + } + } +} diff --git a/src/Shared/CommandLineUtils/CommandLine/CommandArgument.cs b/src/Shared/CommandLineUtils/CommandLine/CommandArgument.cs new file mode 100644 index 000000000000..4eac95982c20 --- /dev/null +++ b/src/Shared/CommandLineUtils/CommandLine/CommandArgument.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.CommandLineUtils +{ + internal class CommandArgument + { + public CommandArgument() + { + Values = new List(); + } + + public string Name { get; set; } + public bool ShowInHelpText { get; set; } = true; + public string Description { get; set; } + public List Values { get; private set; } + public bool MultipleValues { get; set; } + public string Value + { + get + { + return Values.FirstOrDefault(); + } + } + } +} diff --git a/src/Shared/CommandLineUtils/CommandLine/CommandLineApplication.cs b/src/Shared/CommandLineUtils/CommandLine/CommandLineApplication.cs new file mode 100644 index 000000000000..ce608f65bc76 --- /dev/null +++ b/src/Shared/CommandLineUtils/CommandLine/CommandLineApplication.cs @@ -0,0 +1,644 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.CommandLineUtils +{ + internal class CommandLineApplication + { + // Indicates whether the parser should throw an exception when it runs into an unexpected argument. If this is + // set to true (the default), the parser will throw on the first unexpected argument. Otherwise, all unexpected + // arguments (including the first) are added to RemainingArguments. + private readonly bool _throwOnUnexpectedArg; + + // Indicates whether the parser should check remaining arguments for command or option matches after + // encountering an unexpected argument. Ignored if _throwOnUnexpectedArg is true (the default). If + // _throwOnUnexpectedArg and this are both false, the first unexpected argument and all remaining arguments are + // added to RemainingArguments. If _throwOnUnexpectedArg is false and this is true, only unexpected arguments + // are added to RemainingArguments -- allowing a mix of expected and unexpected arguments, commands and + // options. + private readonly bool _continueAfterUnexpectedArg; + + private readonly bool _treatUnmatchedOptionsAsArguments; + + public CommandLineApplication(bool throwOnUnexpectedArg = true, bool continueAfterUnexpectedArg = false, bool treatUnmatchedOptionsAsArguments = false) + { + _throwOnUnexpectedArg = throwOnUnexpectedArg; + _continueAfterUnexpectedArg = continueAfterUnexpectedArg; + _treatUnmatchedOptionsAsArguments = treatUnmatchedOptionsAsArguments; + Options = new List(); + Arguments = new List(); + Commands = new List(); + RemainingArguments = new List(); + Invoke = () => 0; + } + + public CommandLineApplication Parent { get; set; } + public string Name { get; set; } + public string FullName { get; set; } + public string Syntax { get; set; } + public string Description { get; set; } + public bool ShowInHelpText { get; set; } = true; + public string ExtendedHelpText { get; set; } + public readonly List Options; + public CommandOption OptionHelp { get; private set; } + public CommandOption OptionVersion { get; private set; } + public readonly List Arguments; + public readonly List RemainingArguments; + public bool IsShowingInformation { get; protected set; } // Is showing help or version? + public Func Invoke { get; set; } + public Func LongVersionGetter { get; set; } + public Func ShortVersionGetter { get; set; } + public readonly List Commands; + public bool AllowArgumentSeparator { get; set; } + public TextWriter Out { get; set; } = Console.Out; + public TextWriter Error { get; set; } = Console.Error; + + public IEnumerable GetOptions() + { + var expr = Options.AsEnumerable(); + var rootNode = this; + while (rootNode.Parent != null) + { + rootNode = rootNode.Parent; + expr = expr.Concat(rootNode.Options.Where(o => o.Inherited)); + } + + return expr; + } + + public CommandLineApplication Command(string name, Action configuration, + bool throwOnUnexpectedArg = true) + { + var command = new CommandLineApplication(throwOnUnexpectedArg) { Name = name, Parent = this }; + Commands.Add(command); + configuration(command); + return command; + } + + public CommandOption Option(string template, string description, CommandOptionType optionType) + => Option(template, description, optionType, _ => { }, inherited: false); + + public CommandOption Option(string template, string description, CommandOptionType optionType, bool inherited) + => Option(template, description, optionType, _ => { }, inherited); + + public CommandOption Option(string template, string description, CommandOptionType optionType, Action configuration) + => Option(template, description, optionType, configuration, inherited: false); + + public CommandOption Option(string template, string description, CommandOptionType optionType, Action configuration, bool inherited) + { + var option = new CommandOption(template, optionType) + { + Description = description, + Inherited = inherited + }; + Options.Add(option); + configuration(option); + return option; + } + + public CommandArgument Argument(string name, string description, bool multipleValues = false) + { + return Argument(name, description, _ => { }, multipleValues); + } + + public CommandArgument Argument(string name, string description, Action configuration, bool multipleValues = false) + { + var lastArg = Arguments.LastOrDefault(); + if (lastArg != null && lastArg.MultipleValues) + { + var message = string.Format("The last argument '{0}' accepts multiple values. No more argument can be added.", + lastArg.Name); + throw new InvalidOperationException(message); + } + + var argument = new CommandArgument { Name = name, Description = description, MultipleValues = multipleValues }; + Arguments.Add(argument); + configuration(argument); + return argument; + } + + public void OnExecute(Func invoke) + { + Invoke = invoke; + } + + public void OnExecute(Func> invoke) + { + Invoke = () => invoke().Result; + } + public int Execute(params string[] args) + { + CommandLineApplication command = this; + CommandOption option = null; + IEnumerator arguments = null; + var argumentsAssigned = false; + + for (var index = 0; index < args.Length; index++) + { + var arg = args[index]; + var processed = false; + if (!processed && option == null) + { + string[] longOption = null; + string[] shortOption = null; + + if (arg.StartsWith("--")) + { + longOption = arg.Substring(2).Split(new[] { ':', '=' }, 2); + } + else if (arg.StartsWith("-")) + { + shortOption = arg.Substring(1).Split(new[] { ':', '=' }, 2); + } + + if (longOption != null) + { + processed = true; + var longOptionName = longOption[0]; + option = command.GetOptions().SingleOrDefault(opt => string.Equals(opt.LongName, longOptionName, StringComparison.Ordinal)); + + if (option == null && _treatUnmatchedOptionsAsArguments) + { + if (arguments == null) + { + arguments = new CommandArgumentEnumerator(command.Arguments.GetEnumerator()); + } + if (arguments.MoveNext()) + { + processed = true; + arguments.Current.Values.Add(arg); + argumentsAssigned = true; + continue; + } + //else + //{ + // argumentsAssigned = false; + //} + } + + if (option == null) + { + var ignoreContinueAfterUnexpectedArg = false; + if (string.IsNullOrEmpty(longOptionName) && + !command._throwOnUnexpectedArg && + AllowArgumentSeparator) + { + // Skip over the '--' argument separator then consume all remaining arguments. All + // remaining arguments are unconditionally stored for further use. + index++; + ignoreContinueAfterUnexpectedArg = true; + } + + if (HandleUnexpectedArg( + command, + args, + index, + argTypeName: "option", + ignoreContinueAfterUnexpectedArg)) + { + continue; + } + + break; + } + + // If we find a help/version option, show information and stop parsing + if (command.OptionHelp == option) + { + command.ShowHelp(); + return 0; + } + else if (command.OptionVersion == option) + { + command.ShowVersion(); + return 0; + } + + if (longOption.Length == 2) + { + if (!option.TryParse(longOption[1])) + { + command.ShowHint(); + throw new CommandParsingException(command, $"Unexpected value '{longOption[1]}' for option '{option.LongName}'"); + } + option = null; + } + else if (option.OptionType == CommandOptionType.NoValue) + { + // No value is needed for this option + option.TryParse(null); + option = null; + } + } + + if (shortOption != null) + { + processed = true; + option = command.GetOptions().SingleOrDefault(opt => string.Equals(opt.ShortName, shortOption[0], StringComparison.Ordinal)); + + if (option == null && _treatUnmatchedOptionsAsArguments) + { + if (arguments == null) + { + arguments = new CommandArgumentEnumerator(command.Arguments.GetEnumerator()); + } + if (arguments.MoveNext()) + { + processed = true; + arguments.Current.Values.Add(arg); + argumentsAssigned = true; + continue; + } + //else + //{ + // argumentsAssigned = false; + //} + } + + // If not a short option, try symbol option + if (option == null) + { + option = command.GetOptions().SingleOrDefault(opt => string.Equals(opt.SymbolName, shortOption[0], StringComparison.Ordinal)); + } + + if (option == null) + { + if (HandleUnexpectedArg(command, args, index, argTypeName: "option")) + { + continue; + } + + break; + } + + // If we find a help/version option, show information and stop parsing + if (command.OptionHelp == option) + { + command.ShowHelp(); + return 0; + } + else if (command.OptionVersion == option) + { + command.ShowVersion(); + return 0; + } + + if (shortOption.Length == 2) + { + if (!option.TryParse(shortOption[1])) + { + command.ShowHint(); + throw new CommandParsingException(command, $"Unexpected value '{shortOption[1]}' for option '{option.LongName}'"); + } + option = null; + } + else if (option.OptionType == CommandOptionType.NoValue) + { + // No value is needed for this option + option.TryParse(null); + option = null; + } + } + } + + if (!processed && option != null) + { + processed = true; + if (!option.TryParse(arg)) + { + command.ShowHint(); + throw new CommandParsingException(command, $"Unexpected value '{arg}' for option '{option.LongName}'"); + } + option = null; + } + + if (!processed && !argumentsAssigned) + { + var currentCommand = command; + foreach (var subcommand in command.Commands) + { + if (string.Equals(subcommand.Name, arg, StringComparison.OrdinalIgnoreCase)) + { + processed = true; + command = subcommand; + break; + } + } + + // If we detect a subcommand + if (command != currentCommand) + { + processed = true; + } + } + + if (!processed) + { + if (arguments == null) + { + arguments = new CommandArgumentEnumerator(command.Arguments.GetEnumerator()); + } + if (arguments.MoveNext()) + { + processed = true; + arguments.Current.Values.Add(arg); + } + } + + if (!processed) + { + if (HandleUnexpectedArg(command, args, index, argTypeName: "command or argument")) + { + continue; + } + + break; + } + } + + if (option != null) + { + command.ShowHint(); + throw new CommandParsingException(command, $"Missing value for option '{option.LongName}'"); + } + + return command.Invoke(); + } + + // Helper method that adds a help option + public CommandOption HelpOption(string template) + { + // Help option is special because we stop parsing once we see it + // So we store it separately for further use + OptionHelp = Option(template, "Show help information", CommandOptionType.NoValue); + + return OptionHelp; + } + + public CommandOption VersionOption(string template, + string shortFormVersion, + string longFormVersion = null) + { + if (longFormVersion == null) + { + return VersionOption(template, () => shortFormVersion); + } + else + { + return VersionOption(template, () => shortFormVersion, () => longFormVersion); + } + } + + // Helper method that adds a version option + public CommandOption VersionOption(string template, + Func shortFormVersionGetter, + Func longFormVersionGetter = null) + { + // Version option is special because we stop parsing once we see it + // So we store it separately for further use + OptionVersion = Option(template, "Show version information", CommandOptionType.NoValue); + ShortVersionGetter = shortFormVersionGetter; + LongVersionGetter = longFormVersionGetter ?? shortFormVersionGetter; + + return OptionVersion; + } + + // Show short hint that reminds users to use help option + public void ShowHint() + { + if (OptionHelp != null) + { + Out.WriteLine(string.Format("Specify --{0} for a list of available options and commands.", OptionHelp.LongName)); + } + } + + // Show full help + public void ShowHelp(string commandName = null) + { + for (var cmd = this; cmd != null; cmd = cmd.Parent) + { + cmd.IsShowingInformation = true; + } + + Out.WriteLine(GetHelpText(commandName)); + } + + public virtual string GetHelpText(string commandName = null) + { + var headerBuilder = new StringBuilder("Usage:"); + for (var cmd = this; cmd != null; cmd = cmd.Parent) + { + headerBuilder.Insert(6, string.Format(" {0}", cmd.Name)); + } + + CommandLineApplication target; + + if (commandName == null || string.Equals(Name, commandName, StringComparison.OrdinalIgnoreCase)) + { + target = this; + } + else + { + target = Commands.SingleOrDefault(cmd => string.Equals(cmd.Name, commandName, StringComparison.OrdinalIgnoreCase)); + + if (target != null) + { + headerBuilder.AppendFormat(" {0}", commandName); + } + else + { + // The command name is invalid so don't try to show help for something that doesn't exist + target = this; + } + + } + + var optionsBuilder = new StringBuilder(); + var commandsBuilder = new StringBuilder(); + var argumentsBuilder = new StringBuilder(); + + var arguments = target.Arguments.Where(a => a.ShowInHelpText).ToList(); + if (arguments.Any()) + { + headerBuilder.Append(" [arguments]"); + + argumentsBuilder.AppendLine(); + argumentsBuilder.AppendLine("Arguments:"); + var maxArgLen = arguments.Max(a => a.Name.Length); + var outputFormat = string.Format(" {{0, -{0}}}{{1}}", maxArgLen + 2); + foreach (var arg in arguments) + { + argumentsBuilder.AppendFormat(outputFormat, arg.Name, arg.Description); + argumentsBuilder.AppendLine(); + } + } + + var options = target.GetOptions().Where(o => o.ShowInHelpText).ToList(); + if (options.Any()) + { + headerBuilder.Append(" [options]"); + + optionsBuilder.AppendLine(); + optionsBuilder.AppendLine("Options:"); + var maxOptLen = options.Max(o => o.Template.Length); + var outputFormat = string.Format(" {{0, -{0}}}{{1}}", maxOptLen + 2); + foreach (var opt in options) + { + optionsBuilder.AppendFormat(outputFormat, opt.Template, opt.Description); + optionsBuilder.AppendLine(); + } + } + + var commands = target.Commands.Where(c => c.ShowInHelpText).ToList(); + if (commands.Any()) + { + headerBuilder.Append(" [command]"); + + commandsBuilder.AppendLine(); + commandsBuilder.AppendLine("Commands:"); + var maxCmdLen = commands.Max(c => c.Name.Length); + var outputFormat = string.Format(" {{0, -{0}}}{{1}}", maxCmdLen + 2); + foreach (var cmd in commands.OrderBy(c => c.Name)) + { + commandsBuilder.AppendFormat(outputFormat, cmd.Name, cmd.Description); + commandsBuilder.AppendLine(); + } + + if (OptionHelp != null) + { + commandsBuilder.AppendLine(); + commandsBuilder.AppendFormat($"Use \"{target.Name} [command] --{OptionHelp.LongName}\" for more information about a command."); + commandsBuilder.AppendLine(); + } + } + + if (target.AllowArgumentSeparator) + { + headerBuilder.Append(" [[--] ...]"); + } + + headerBuilder.AppendLine(); + + var nameAndVersion = new StringBuilder(); + nameAndVersion.AppendLine(GetFullNameAndVersion()); + nameAndVersion.AppendLine(); + + return nameAndVersion.ToString() + + headerBuilder.ToString() + + argumentsBuilder.ToString() + + optionsBuilder.ToString() + + commandsBuilder.ToString() + + target.ExtendedHelpText; + } + + public void ShowVersion() + { + for (var cmd = this; cmd != null; cmd = cmd.Parent) + { + cmd.IsShowingInformation = true; + } + + Out.WriteLine(FullName); + Out.WriteLine(LongVersionGetter()); + } + + public string GetFullNameAndVersion() + { + return ShortVersionGetter == null ? FullName : string.Format("{0} {1}", FullName, ShortVersionGetter()); + } + + public void ShowRootCommandFullNameAndVersion() + { + var rootCmd = this; + while (rootCmd.Parent != null) + { + rootCmd = rootCmd.Parent; + } + + Out.WriteLine(rootCmd.GetFullNameAndVersion()); + Out.WriteLine(); + } + + private bool HandleUnexpectedArg( + CommandLineApplication command, + string[] args, + int index, + string argTypeName, + bool ignoreContinueAfterUnexpectedArg = false) + { + if (command._throwOnUnexpectedArg) + { + command.ShowHint(); + throw new CommandParsingException(command, $"Unrecognized {argTypeName} '{args[index]}'"); + } + else if (_continueAfterUnexpectedArg && !ignoreContinueAfterUnexpectedArg) + { + // Store argument for further use. + command.RemainingArguments.Add(args[index]); + return true; + } + else + { + // Store all remaining arguments for later use. + command.RemainingArguments.AddRange(new ArraySegment(args, index, args.Length - index)); + return false; + } + } + + private class CommandArgumentEnumerator : IEnumerator + { + private readonly IEnumerator _enumerator; + + public CommandArgumentEnumerator(IEnumerator enumerator) + { + _enumerator = enumerator; + } + + public CommandArgument Current + { + get + { + return _enumerator.Current; + } + } + + object IEnumerator.Current + { + get + { + return Current; + } + } + + public void Dispose() + { + _enumerator.Dispose(); + } + + public bool MoveNext() + { + if (Current == null || !Current.MultipleValues) + { + return _enumerator.MoveNext(); + } + + // If current argument allows multiple values, we don't move forward and + // all later values will be added to current CommandArgument.Values + return true; + } + + public void Reset() + { + _enumerator.Reset(); + } + } + } +} diff --git a/src/Shared/CommandLineUtils/CommandLine/CommandOption.cs b/src/Shared/CommandLineUtils/CommandLine/CommandOption.cs new file mode 100644 index 000000000000..4e663773cc38 --- /dev/null +++ b/src/Shared/CommandLineUtils/CommandLine/CommandOption.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Extensions.CommandLineUtils +{ + internal class CommandOption + { + public CommandOption(string template, CommandOptionType optionType) + { + Template = template; + OptionType = optionType; + Values = new List(); + + foreach (var part in Template.Split(new[] { ' ', '|' }, StringSplitOptions.RemoveEmptyEntries)) + { + if (part.StartsWith("--")) + { + LongName = part.Substring(2); + } + else if (part.StartsWith("-")) + { + var optName = part.Substring(1); + + // If there is only one char and it is not an English letter, it is a symbol option (e.g. "-?") + if (optName.Length == 1 && !IsEnglishLetter(optName[0])) + { + SymbolName = optName; + } + else + { + ShortName = optName; + } + } + else if (part.StartsWith("<") && part.EndsWith(">")) + { + ValueName = part.Substring(1, part.Length - 2); + } + else + { + throw new ArgumentException($"Invalid template pattern '{template}'", nameof(template)); + } + } + + if (string.IsNullOrEmpty(LongName) && string.IsNullOrEmpty(ShortName) && string.IsNullOrEmpty(SymbolName)) + { + throw new ArgumentException($"Invalid template pattern '{template}'", nameof(template)); + } + } + + public string Template { get; set; } + public string ShortName { get; set; } + public string LongName { get; set; } + public string SymbolName { get; set; } + public string ValueName { get; set; } + public string Description { get; set; } + public List Values { get; private set; } + public CommandOptionType OptionType { get; private set; } + public bool ShowInHelpText { get; set; } = true; + public bool Inherited { get; set; } + + public bool TryParse(string value) + { + switch (OptionType) + { + case CommandOptionType.MultipleValue: + Values.Add(value); + break; + case CommandOptionType.SingleValue: + if (Values.Any()) + { + return false; + } + Values.Add(value); + break; + case CommandOptionType.NoValue: + if (value != null) + { + return false; + } + // Add a value to indicate that this option was specified + Values.Add("on"); + break; + default: + break; + } + return true; + } + + public bool HasValue() + { + return Values.Any(); + } + + public string Value() + { + return HasValue() ? Values[0] : null; + } + + private bool IsEnglishLetter(char c) + { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + } +} diff --git a/src/Shared/CommandLineUtils/CommandLine/CommandOptionType.cs b/src/Shared/CommandLineUtils/CommandLine/CommandOptionType.cs new file mode 100644 index 000000000000..76fdf38f5e29 --- /dev/null +++ b/src/Shared/CommandLineUtils/CommandLine/CommandOptionType.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + + +namespace Microsoft.Extensions.CommandLineUtils +{ + internal enum CommandOptionType + { + MultipleValue, + SingleValue, + NoValue + } +} diff --git a/src/Shared/CommandLineUtils/CommandLine/CommandParsingException.cs b/src/Shared/CommandLineUtils/CommandLine/CommandParsingException.cs new file mode 100644 index 000000000000..2be62b87faad --- /dev/null +++ b/src/Shared/CommandLineUtils/CommandLine/CommandParsingException.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.CommandLineUtils +{ + internal class CommandParsingException : Exception + { + public CommandParsingException(CommandLineApplication command, string message) + : base(message) + { + Command = command; + } + + public CommandLineApplication Command { get; } + } +} diff --git a/src/Shared/CommandLineUtils/Utilities/ArgumentEscaper.cs b/src/Shared/CommandLineUtils/Utilities/ArgumentEscaper.cs new file mode 100644 index 000000000000..92543e7f237e --- /dev/null +++ b/src/Shared/CommandLineUtils/Utilities/ArgumentEscaper.cs @@ -0,0 +1,109 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.Extensions.CommandLineUtils +{ + /// + /// A utility for escaping arguments for new processes. + /// + internal static class ArgumentEscaper + { + /// + /// Undo the processing which took place to create string[] args in Main, so that the next process will + /// receive the same string[] args. + /// + /// + /// See https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + /// + /// The arguments to concatenate. + /// The escaped arguments, concatenated. + public static string EscapeAndConcatenate(IEnumerable args) + => string.Join(" ", args.Select(EscapeSingleArg)); + + private static string EscapeSingleArg(string arg) + { + var sb = new StringBuilder(); + + var needsQuotes = ShouldSurroundWithQuotes(arg); + var isQuoted = needsQuotes || IsSurroundedWithQuotes(arg); + + if (needsQuotes) + { + sb.Append('"'); + } + + for (int i = 0; i < arg.Length; ++i) + { + var backslashes = 0; + + // Consume all backslashes + while (i < arg.Length && arg[i] == '\\') + { + backslashes++; + i++; + } + + if (i == arg.Length && isQuoted) + { + // Escape any backslashes at the end of the arg when the argument is also quoted. + // This ensures the outside quote is interpreted as an argument delimiter + sb.Append('\\', 2 * backslashes); + } + else if (i == arg.Length) + { + // At then end of the arg, which isn't quoted, + // just add the backslashes, no need to escape + sb.Append('\\', backslashes); + } + else if (arg[i] == '"') + { + // Escape any preceding backslashes and the quote + sb.Append('\\', (2 * backslashes) + 1); + sb.Append('"'); + } + else + { + // Output any consumed backslashes and the character + sb.Append('\\', backslashes); + sb.Append(arg[i]); + } + } + + if (needsQuotes) + { + sb.Append('"'); + } + + return sb.ToString(); + } + + private static bool ShouldSurroundWithQuotes(string argument) + { + // Don't quote already quoted strings + if (IsSurroundedWithQuotes(argument)) + { + return false; + } + + // Only quote if whitespace exists in the string + return ContainsWhitespace(argument); + } + + private static bool IsSurroundedWithQuotes(string argument) + { + if (argument.Length <= 1) + { + return false; + } + + return argument[0] == '"' && argument[argument.Length - 1] == '"'; + } + + private static bool ContainsWhitespace(string argument) + => argument.IndexOfAny(new[] { ' ', '\t', '\n' }) >= 0; + } +} diff --git a/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs b/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs new file mode 100644 index 000000000000..52c98b5eb251 --- /dev/null +++ b/src/Shared/CommandLineUtils/Utilities/DotNetMuxer.cs @@ -0,0 +1,58 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +// System.AppContext.GetData is not available in these frameworks +#if !NET451 && !NET452 && !NET46 && !NET461 + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.Extensions.CommandLineUtils +{ + /// + /// Utilities for finding the "dotnet.exe" file from the currently running .NET Core application + /// + internal static class DotNetMuxer + { + private const string MuxerName = "dotnet"; + + static DotNetMuxer() + { + MuxerPath = TryFindMuxerPath(); + } + + /// + /// The full filepath to the .NET Core muxer. + /// + public static string MuxerPath { get; } + + /// + /// Finds the full filepath to the .NET Core muxer, + /// or returns a string containing the default name of the .NET Core muxer ('dotnet'). + /// + /// The path or a string named 'dotnet'. + public static string MuxerPathOrDefault() + => MuxerPath ?? MuxerName; + + private static string TryFindMuxerPath() + { + var fileName = MuxerName; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + fileName += ".exe"; + } + + var mainModule = Process.GetCurrentProcess().MainModule; + if (!string.IsNullOrEmpty(mainModule?.FileName) + && Path.GetFileName(mainModule.FileName).Equals(fileName, StringComparison.OrdinalIgnoreCase)) + { + return mainModule.FileName; + } + + return null; + } + } +} +#endif diff --git a/src/Shared/HashCodeCombiner/HashCodeCombiner.cs b/src/Shared/HashCodeCombiner/HashCodeCombiner.cs new file mode 100644 index 000000000000..4df8b46b058f --- /dev/null +++ b/src/Shared/HashCodeCombiner/HashCodeCombiner.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Microsoft.Extensions.Internal +{ + internal struct HashCodeCombiner + { + private long _combinedHash64; + + public int CombinedHash + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get { return _combinedHash64.GetHashCode(); } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private HashCodeCombiner(long seed) + { + _combinedHash64 = seed; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Add(IEnumerable e) + { + if (e == null) + { + Add(0); + } + else + { + var count = 0; + foreach (object o in e) + { + Add(o); + count++; + } + Add(count); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static implicit operator int(HashCodeCombiner self) + { + return self.CombinedHash; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Add(int i) + { + _combinedHash64 = ((_combinedHash64 << 5) + _combinedHash64) ^ i; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Add(string s) + { + var hashCode = (s != null) ? s.GetHashCode() : 0; + Add(hashCode); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Add(object o) + { + var hashCode = (o != null) ? o.GetHashCode() : 0; + Add(hashCode); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Add(TValue value, IEqualityComparer comparer) + { + var hashCode = value != null ? comparer.GetHashCode(value) : 0; + Add(hashCode); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static HashCodeCombiner Start() + { + return new HashCodeCombiner(0x1505L); + } + } +} diff --git a/src/Shared/HostFactoryResolver/HostFactoryResolver.cs b/src/Shared/HostFactoryResolver/HostFactoryResolver.cs new file mode 100644 index 000000000000..cb9f81123795 --- /dev/null +++ b/src/Shared/HostFactoryResolver/HostFactoryResolver.cs @@ -0,0 +1,112 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; + +namespace Microsoft.Extensions.Hosting +{ + internal class HostFactoryResolver + { + public static readonly string BuildWebHost = nameof(BuildWebHost); + public static readonly string CreateWebHostBuilder = nameof(CreateWebHostBuilder); + public static readonly string CreateHostBuilder = nameof(CreateHostBuilder); + + public static Func ResolveWebHostFactory(Assembly assembly) + { + return ResolveFactory(assembly, BuildWebHost); + } + + public static Func ResolveWebHostBuilderFactory(Assembly assembly) + { + return ResolveFactory(assembly, CreateWebHostBuilder); + } + + public static Func ResolveHostBuilderFactory(Assembly assembly) + { + return ResolveFactory(assembly, CreateHostBuilder); + } + + private static Func ResolveFactory(Assembly assembly, string name) + { + var programType = assembly?.EntryPoint?.DeclaringType; + if (programType == null) + { + return null; + } + + var factory = programType.GetTypeInfo().GetDeclaredMethod(name); + if (!IsFactory(factory)) + { + return null; + } + + return args => (T)factory.Invoke(null, new object[] { args }); + } + + // TReturn Factory(string[] args); + private static bool IsFactory(MethodInfo factory) + { + return factory != null + && typeof(TReturn).IsAssignableFrom(factory.ReturnType) + && factory.GetParameters().Length == 1 + && typeof(string[]).Equals(factory.GetParameters()[0].ParameterType); + } + + // Used by EF tooling without any Hosting references. Looses some return type safety checks. + public static Func ResolveServiceProviderFactory(Assembly assembly) + { + // Prefer the older patterns by default for back compat. + var webHostFactory = ResolveWebHostFactory(assembly); + if (webHostFactory != null) + { + return args => + { + var webHost = webHostFactory(args); + return GetServiceProvider(webHost); + }; + } + + var webHostBuilderFactory = ResolveWebHostBuilderFactory(assembly); + if (webHostBuilderFactory != null) + { + return args => + { + var webHostBuilder = webHostBuilderFactory(args); + var webHost = Build(webHostBuilder); + return GetServiceProvider(webHost); + }; + } + + var hostBuilderFactory = ResolveHostBuilderFactory(assembly); + if (hostBuilderFactory != null) + { + return args => + { + var hostBuilder = hostBuilderFactory(args); + var host = Build(hostBuilder); + return GetServiceProvider(host); + }; + } + + return null; + } + + private static object Build(object builder) + { + var buildMethod = builder.GetType().GetMethod("Build"); + return buildMethod?.Invoke(builder, Array.Empty()); + } + + private static IServiceProvider GetServiceProvider(object host) + { + if (host == null) + { + return null; + } + var hostType = host.GetType(); + var servicesProperty = hostType.GetTypeInfo().GetDeclaredProperty("Services"); + return (IServiceProvider)servicesProperty.GetValue(host); + } + } +} diff --git a/src/Shared/NonCapturingTimer/NonCapturingTimer.cs b/src/Shared/NonCapturingTimer/NonCapturingTimer.cs new file mode 100644 index 000000000000..6f54b2db47c0 --- /dev/null +++ b/src/Shared/NonCapturingTimer/NonCapturingTimer.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; + +namespace Microsoft.Extensions.Internal +{ + // A convenience API for interacting with System.Threading.Timer in a way + // that doesn't capture the ExecutionContext. We should be using this (or equivalent) + // everywhere we use timers to avoid rooting any values stored in asynclocals. + internal static class NonCapturingTimer + { + public static Timer Create(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period) + { + if (callback == null) + { + throw new ArgumentNullException(nameof(callback)); + } + + // Don't capture the current ExecutionContext and its AsyncLocals onto the timer + bool restoreFlow = false; + try + { + if (!ExecutionContext.IsFlowSuppressed()) + { + ExecutionContext.SuppressFlow(); + restoreFlow = true; + } + + return new Timer(callback, state, dueTime, period); + } + finally + { + // Restore the current ExecutionContext + if (restoreFlow) + { + ExecutionContext.RestoreFlow(); + } + } + } + } +} diff --git a/src/Shared/ParameterDefaultValue/ParameterDefaultValue.cs b/src/Shared/ParameterDefaultValue/ParameterDefaultValue.cs new file mode 100644 index 000000000000..dc635bb78997 --- /dev/null +++ b/src/Shared/ParameterDefaultValue/ParameterDefaultValue.cs @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; + +namespace Microsoft.Extensions.Internal +{ + internal class ParameterDefaultValue + { + private static readonly Type _nullable = typeof(Nullable<>); + + public static bool TryGetDefaultValue(ParameterInfo parameter, out object defaultValue) + { + bool hasDefaultValue; + var tryToGetDefaultValue = true; + defaultValue = null; + + try + { + hasDefaultValue = parameter.HasDefaultValue; + } + catch (FormatException) when (parameter.ParameterType == typeof(DateTime)) + { + // Workaround for https://github.com/dotnet/corefx/issues/12338 + // If HasDefaultValue throws FormatException for DateTime + // we expect it to have default value + hasDefaultValue = true; + tryToGetDefaultValue = false; + } + + if (hasDefaultValue) + { + if (tryToGetDefaultValue) + { + defaultValue = parameter.DefaultValue; + } + + // Workaround for https://github.com/dotnet/corefx/issues/11797 + if (defaultValue == null && parameter.ParameterType.IsValueType) + { + defaultValue = Activator.CreateInstance(parameter.ParameterType); + } + + // Handle nullable enums + if (defaultValue != null && + parameter.ParameterType.IsGenericType && + parameter.ParameterType.GetGenericTypeDefinition() == _nullable + ) + { + var underlyingType = Nullable.GetUnderlyingType(parameter.ParameterType); + if (underlyingType != null && underlyingType.IsEnum) + { + defaultValue = Enum.ToObject(underlyingType, defaultValue); + } + } + } + + return hasDefaultValue; + } + } +} diff --git a/src/Shared/TypeNameHelper/TypeNameHelper.cs b/src/Shared/TypeNameHelper/TypeNameHelper.cs new file mode 100644 index 000000000000..d08b9b043946 --- /dev/null +++ b/src/Shared/TypeNameHelper/TypeNameHelper.cs @@ -0,0 +1,179 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Internal +{ + internal static class TypeNameHelper + { + private const char DefaultNestedTypeDelimiter = '+'; + + private static readonly Dictionary _builtInTypeNames = new Dictionary + { + { typeof(void), "void" }, + { typeof(bool), "bool" }, + { typeof(byte), "byte" }, + { typeof(char), "char" }, + { typeof(decimal), "decimal" }, + { typeof(double), "double" }, + { typeof(float), "float" }, + { typeof(int), "int" }, + { typeof(long), "long" }, + { typeof(object), "object" }, + { typeof(sbyte), "sbyte" }, + { typeof(short), "short" }, + { typeof(string), "string" }, + { typeof(uint), "uint" }, + { typeof(ulong), "ulong" }, + { typeof(ushort), "ushort" } + }; + + public static string GetTypeDisplayName(object item, bool fullName = true) + { + return item == null ? null : GetTypeDisplayName(item.GetType(), fullName); + } + + /// + /// Pretty print a type name. + /// + /// The . + /// true to print a fully qualified name. + /// true to include generic parameter names. + /// true to include generic parameters. + /// Character to use as a delimiter in nested type names + /// The pretty printed type name. + public static string GetTypeDisplayName(Type type, bool fullName = true, bool includeGenericParameterNames = false, bool includeGenericParameters = true, char nestedTypeDelimiter = DefaultNestedTypeDelimiter) + { + var builder = new StringBuilder(); + ProcessType(builder, type, new DisplayNameOptions(fullName, includeGenericParameterNames, includeGenericParameters, nestedTypeDelimiter)); + return builder.ToString(); + } + + private static void ProcessType(StringBuilder builder, Type type, in DisplayNameOptions options) + { + if (type.IsGenericType) + { + var genericArguments = type.GetGenericArguments(); + ProcessGenericType(builder, type, genericArguments, genericArguments.Length, options); + } + else if (type.IsArray) + { + ProcessArrayType(builder, type, options); + } + else if (_builtInTypeNames.TryGetValue(type, out var builtInName)) + { + builder.Append(builtInName); + } + else if (type.IsGenericParameter) + { + if (options.IncludeGenericParameterNames) + { + builder.Append(type.Name); + } + } + else + { + var name = options.FullName ? type.FullName : type.Name; + builder.Append(name); + + if (options.NestedTypeDelimiter != DefaultNestedTypeDelimiter) + { + builder.Replace(DefaultNestedTypeDelimiter, options.NestedTypeDelimiter, builder.Length - name.Length, name.Length); + } + } + } + + private static void ProcessArrayType(StringBuilder builder, Type type, in DisplayNameOptions options) + { + var innerType = type; + while (innerType.IsArray) + { + innerType = innerType.GetElementType(); + } + + ProcessType(builder, innerType, options); + + while (type.IsArray) + { + builder.Append('['); + builder.Append(',', type.GetArrayRank() - 1); + builder.Append(']'); + type = type.GetElementType(); + } + } + + private static void ProcessGenericType(StringBuilder builder, Type type, Type[] genericArguments, int length, in DisplayNameOptions options) + { + var offset = 0; + if (type.IsNested) + { + offset = type.DeclaringType.GetGenericArguments().Length; + } + + if (options.FullName) + { + if (type.IsNested) + { + ProcessGenericType(builder, type.DeclaringType, genericArguments, offset, options); + builder.Append(options.NestedTypeDelimiter); + } + else if (!string.IsNullOrEmpty(type.Namespace)) + { + builder.Append(type.Namespace); + builder.Append('.'); + } + } + + var genericPartIndex = type.Name.IndexOf('`'); + if (genericPartIndex <= 0) + { + builder.Append(type.Name); + return; + } + + builder.Append(type.Name, 0, genericPartIndex); + + if (options.IncludeGenericParameters) + { + builder.Append('<'); + for (var i = offset; i < length; i++) + { + ProcessType(builder, genericArguments[i], options); + if (i + 1 == length) + { + continue; + } + + builder.Append(','); + if (options.IncludeGenericParameterNames || !genericArguments[i + 1].IsGenericParameter) + { + builder.Append(' '); + } + } + builder.Append('>'); + } + } + + private readonly struct DisplayNameOptions + { + public DisplayNameOptions(bool fullName, bool includeGenericParameterNames, bool includeGenericParameters, char nestedTypeDelimiter) + { + FullName = fullName; + IncludeGenericParameters = includeGenericParameters; + IncludeGenericParameterNames = includeGenericParameterNames; + NestedTypeDelimiter = nestedTypeDelimiter; + } + + public bool FullName { get; } + + public bool IncludeGenericParameters { get; } + + public bool IncludeGenericParameterNames { get; } + + public char NestedTypeDelimiter { get; } + } + } +} diff --git a/src/Shared/ValueStopwatch/ValueStopwatch.cs b/src/Shared/ValueStopwatch/ValueStopwatch.cs new file mode 100644 index 000000000000..f99a084aebe0 --- /dev/null +++ b/src/Shared/ValueStopwatch/ValueStopwatch.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; + +namespace Microsoft.Extensions.Internal +{ + internal struct ValueStopwatch + { + private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; + + private long _startTimestamp; + + public bool IsActive => _startTimestamp != 0; + + private ValueStopwatch(long startTimestamp) + { + _startTimestamp = startTimestamp; + } + + public static ValueStopwatch StartNew() => new ValueStopwatch(Stopwatch.GetTimestamp()); + + public TimeSpan GetElapsedTime() + { + // Start timestamp can't be zero in an initialized ValueStopwatch. It would have to be literally the first thing executed when the machine boots to be 0. + // So it being 0 is a clear indication of default(ValueStopwatch) + if (!IsActive) + { + throw new InvalidOperationException("An uninitialized, or 'default', ValueStopwatch cannot be used to get elapsed time."); + } + + var end = Stopwatch.GetTimestamp(); + var timestampDelta = end - _startTimestamp; + var ticks = (long)(TimestampToTicks * timestampDelta); + return new TimeSpan(ticks); + } + } +} diff --git a/src/Shared/test/Shared.Tests/ArgumentEscaperTests.cs b/src/Shared/test/Shared.Tests/ArgumentEscaperTests.cs new file mode 100644 index 000000000000..a706b05bfcb9 --- /dev/null +++ b/src/Shared/test/Shared.Tests/ArgumentEscaperTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.Extensions.CommandLineUtils +{ + public class ArgumentEscaperTests + { + [Theory] + [InlineData(new[] { "one", "two", "three" }, "one two three")] + [InlineData(new[] { "line1\nline2", "word1\tword2" }, "\"line1\nline2\" \"word1\tword2\"")] + [InlineData(new[] { "with spaces" }, "\"with spaces\"")] + [InlineData(new[] { @"with\backslash" }, @"with\backslash")] + [InlineData(new[] { @"""quotedwith\backslash""" }, @"\""quotedwith\backslash\""")] + [InlineData(new[] { @"C:\Users\" }, @"C:\Users\")] + [InlineData(new[] { @"C:\Program Files\dotnet\" }, @"""C:\Program Files\dotnet\\""")] + [InlineData(new[] { @"backslash\""preceedingquote" }, @"backslash\\\""preceedingquote")] + public void EscapesArguments(string[] args, string expected) + { + Assert.Equal(expected, ArgumentEscaper.EscapeAndConcatenate(args)); + } + } +} diff --git a/src/Shared/test/Shared.Tests/CommandLineApplicationTests.cs b/src/Shared/test/Shared.Tests/CommandLineApplicationTests.cs new file mode 100644 index 000000000000..0bdc4a8f1dfd --- /dev/null +++ b/src/Shared/test/Shared.Tests/CommandLineApplicationTests.cs @@ -0,0 +1,1224 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using Microsoft.Extensions.CommandLineUtils; +using Xunit; + +namespace Microsoft.Extensions.Internal +{ + public class CommandLineApplicationTests + { + [Fact] + public void CommandNameCanBeMatched() + { + var called = false; + + var app = new CommandLineApplication(); + app.Command("test", c => + { + c.OnExecute(() => + { + called = true; + return 5; + }); + }); + + var result = app.Execute("test"); + Assert.Equal(5, result); + Assert.True(called); + } + + [Fact] + public void RemainingArgsArePassed() + { + CommandArgument first = null; + CommandArgument second = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + first = c.Argument("first", "First argument"); + second = c.Argument("second", "Second argument"); + c.OnExecute(() => 0); + }); + + app.Execute("test", "one", "two"); + + Assert.Equal("one", first.Value); + Assert.Equal("two", second.Value); + } + + [Fact] + public void ExtraArgumentCausesException() + { + CommandArgument first = null; + CommandArgument second = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + first = c.Argument("first", "First argument"); + second = c.Argument("second", "Second argument"); + c.OnExecute(() => 0); + }); + + var ex = Assert.Throws(() => app.Execute("test", "one", "two", "three")); + + Assert.Contains("three", ex.Message); + } + + [Fact] + public void ExtraArgumentAddedToRemaining() + { + CommandArgument first = null; + CommandArgument second = null; + + var app = new CommandLineApplication(); + + var testCommand = app.Command("test", c => + { + first = c.Argument("first", "First argument"); + second = c.Argument("second", "Second argument"); + c.OnExecute(() => 0); + }, + throwOnUnexpectedArg: false); + + app.Execute("test", "one", "two", "three"); + + Assert.Equal("one", first.Value); + Assert.Equal("two", second.Value); + var remaining = Assert.Single(testCommand.RemainingArguments); + Assert.Equal("three", remaining); + } + + [Fact] + public void UnknownCommandCausesException() + { + var app = new CommandLineApplication(); + + app.Command("test", c => + { + c.Argument("first", "First argument"); + c.Argument("second", "Second argument"); + c.OnExecute(() => 0); + }); + + var ex = Assert.Throws(() => app.Execute("test2", "one", "two", "three")); + + Assert.Contains("test2", ex.Message); + } + + [Fact] + public void MultipleValuesArgumentConsumesAllArgumentValues() + { + CommandArgument argument = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + argument = c.Argument("arg", "Argument that allows multiple values", multipleValues: true); + c.OnExecute(() => 0); + }); + + app.Execute("test", "one", "two", "three", "four", "five"); + + Assert.Equal(new[] { "one", "two", "three", "four", "five" }, argument.Values); + } + + [Fact] + public void MultipleValuesArgumentConsumesAllRemainingArgumentValues() + { + CommandArgument first = null; + CommandArgument second = null; + CommandArgument third = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + first = c.Argument("first", "First argument"); + second = c.Argument("second", "Second argument"); + third = c.Argument("third", "Third argument that allows multiple values", multipleValues: true); + c.OnExecute(() => 0); + }); + + app.Execute("test", "one", "two", "three", "four", "five"); + + Assert.Equal("one", first.Value); + Assert.Equal("two", second.Value); + Assert.Equal(new[] { "three", "four", "five" }, third.Values); + } + + [Fact] + public void MultipleValuesArgumentMustBeTheLastArgument() + { + var app = new CommandLineApplication(); + app.Argument("first", "First argument", multipleValues: true); + var ex = Assert.Throws(() => app.Argument("second", "Second argument")); + + Assert.Contains($"The last argument 'first' accepts multiple values. No more argument can be added.", + ex.Message); + } + + [Fact] + public void OptionSwitchMayBeProvided() + { + CommandOption first = null; + CommandOption second = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + first = c.Option("--first ", "First argument", CommandOptionType.SingleValue); + second = c.Option("--second ", "Second argument", CommandOptionType.SingleValue); + c.OnExecute(() => 0); + }); + + app.Execute("test", "--first", "one", "--second", "two"); + + Assert.Equal("one", first.Values[0]); + Assert.Equal("two", second.Values[0]); + } + + [Fact] + public void OptionValueMustBeProvided() + { + CommandOption first = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + first = c.Option("--first ", "First argument", CommandOptionType.SingleValue); + c.OnExecute(() => 0); + }); + + var ex = Assert.Throws(() => app.Execute("test", "--first")); + + Assert.Contains($"Missing value for option '{first.LongName}'", ex.Message); + } + + [Fact] + public void ValuesMayBeAttachedToSwitch() + { + CommandOption first = null; + CommandOption second = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + first = c.Option("--first ", "First argument", CommandOptionType.SingleValue); + second = c.Option("--second ", "Second argument", CommandOptionType.SingleValue); + c.OnExecute(() => 0); + }); + + app.Execute("test", "--first=one", "--second:two"); + + Assert.Equal("one", first.Values[0]); + Assert.Equal("two", second.Values[0]); + } + + [Fact] + public void ShortNamesMayBeDefined() + { + CommandOption first = null; + CommandOption second = null; + + var app = new CommandLineApplication(); + + app.Command("test", c => + { + first = c.Option("-1 --first ", "First argument", CommandOptionType.SingleValue); + second = c.Option("-2 --second ", "Second argument", CommandOptionType.SingleValue); + c.OnExecute(() => 0); + }); + + app.Execute("test", "-1=one", "-2", "two"); + + Assert.Equal("one", first.Values[0]); + Assert.Equal("two", second.Values[0]); + } + + [Fact] + public void ThrowsExceptionOnUnexpectedCommandOrArgumentByDefault() + { + var unexpectedArg = "UnexpectedArg"; + var app = new CommandLineApplication(); + + app.Command("test", c => + { + c.OnExecute(() => 0); + }); + + var exception = Assert.Throws(() => app.Execute("test", unexpectedArg)); + Assert.Equal($"Unrecognized command or argument '{unexpectedArg}'", exception.Message); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedArgument() + { + var unexpectedArg = "UnexpectedArg"; + var app = new CommandLineApplication(); + + var testCmd = app.Command("test", c => + { + c.OnExecute(() => 0); + }, + throwOnUnexpectedArg: false); + + // (does not throw) + app.Execute("test", unexpectedArg); + var arg = Assert.Single(testCmd.RemainingArguments); + Assert.Equal(unexpectedArg, arg); + } + + [Fact] + public void AllowArgumentBeforeNoValueOption() + { + var app = new CommandLineApplication(); + var argument = app.Argument("first", "first argument"); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + app.Execute("one", "--first"); + + Assert.Equal("one", argument.Value); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowArgumentAfterNoValueOption() + { + var app = new CommandLineApplication(); + var argument = app.Argument("first", "first argument"); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + app.Execute("--first", "one"); + + Assert.Equal("one", argument.Value); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowArgumentBeforeSingleValueOption() + { + var app = new CommandLineApplication(); + var argument = app.Argument("first", "first argument"); + var option = app.Option("--first ", "first option", CommandOptionType.SingleValue); + + app.Execute("one", "--first", "two"); + + Assert.Equal("one", argument.Value); + Assert.Equal("two", option.Value()); + } + + [Fact] + public void AllowArgumentAfterSingleValueOption() + { + var app = new CommandLineApplication(); + var argument = app.Argument("first", "first argument"); + var option = app.Option("--first ", "first option", CommandOptionType.SingleValue); + + app.Execute("--first", "one", "two"); + + Assert.Equal("two", argument.Value); + Assert.Equal("one", option.Value()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedArgumentBeforeNoValueOption_Default() + { + var arguments = new[] { "UnexpectedArg", "--first" }; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute(arguments); + + Assert.Equal(arguments, app.RemainingArguments.ToArray()); + Assert.False(option.HasValue()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedArgumentBeforeNoValueOption_Continue() + { + var unexpectedArg = "UnexpectedArg"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute(unexpectedArg, "--first"); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedArg, arg); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedArgumentAfterNoValueOption() + { + var unexpectedArg = "UnexpectedArg"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute("--first", unexpectedArg); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedArg, arg); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedArgumentBeforeSingleValueOption_Default() + { + var arguments = new[] { "UnexpectedArg", "--first", "one" }; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute(arguments); + + Assert.Equal(arguments, app.RemainingArguments.ToArray()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedArgumentBeforeSingleValueOption_Continue() + { + var unexpectedArg = "UnexpectedArg"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var option = app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute(unexpectedArg, "--first", "one"); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedArg, arg); + Assert.Equal("one", option.Value()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedArgumentAfterSingleValueOption() + { + var unexpectedArg = "UnexpectedArg"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var option = app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute("--first", "one", unexpectedArg); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedArg, arg); + Assert.Equal("one", option.Value()); + } + + [Fact] + public void ThrowsExceptionOnUnexpectedLongOptionByDefault() + { + var unexpectedOption = "--UnexpectedOption"; + var app = new CommandLineApplication(); + + app.Command("test", c => + { + c.OnExecute(() => 0); + }); + + var exception = Assert.Throws(() => app.Execute("test", unexpectedOption)); + Assert.Equal($"Unrecognized option '{unexpectedOption}'", exception.Message); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOption() + { + var unexpectedOption = "--UnexpectedOption"; + var app = new CommandLineApplication(); + + var testCmd = app.Command("test", c => + { + c.OnExecute(() => 0); + }, + throwOnUnexpectedArg: false); + + // (does not throw) + app.Execute("test", unexpectedOption); + var arg = Assert.Single(testCmd.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionBeforeNoValueOption_Default() + { + var arguments = new[] { "--unexpected", "--first" }; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute(arguments); + + Assert.Equal(arguments, app.RemainingArguments.ToArray()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionBeforeNoValueOption_Continue() + { + var unexpectedOption = "--unexpected"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute(unexpectedOption, "--first"); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionAfterNoValueOption() + { + var unexpectedOption = "--unexpected"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute("--first", unexpectedOption); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionBeforeSingleValueOption_Default() + { + var arguments = new[] { "--unexpected", "--first", "one" }; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute(arguments); + + Assert.Equal(arguments, app.RemainingArguments.ToArray()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionBeforeSingleValueOption_Continue() + { + var unexpectedOption = "--unexpected"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var option = app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute(unexpectedOption, "--first", "one"); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + Assert.Equal("one", option.Value()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionAfterSingleValueOption() + { + var unexpectedOption = "--unexpected"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var option = app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute("--first", "one", unexpectedOption); + + var arg = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + Assert.Equal("one", option.Value()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionWithValueBeforeNoValueOption_Default() + { + var arguments = new[] { "--unexpected", "value", "--first" }; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute(arguments); + + Assert.Equal(arguments, app.RemainingArguments.ToArray()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionWithValueBeforeNoValueOption_Continue() + { + var unexpectedOption = "--unexpected"; + var unexpectedValue = "value"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute(unexpectedOption, unexpectedValue, "--first"); + + Assert.Equal(new[] { unexpectedOption, unexpectedValue }, app.RemainingArguments.ToArray()); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionWithValueAfterNoValueOption() + { + var unexpectedOption = "--unexpected"; + var unexpectedValue = "value"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var option = app.Option("--first", "first option", CommandOptionType.NoValue); + + // (does not throw) + app.Execute("--first", unexpectedOption, unexpectedValue); + + Assert.Equal(new[] { unexpectedOption, unexpectedValue }, app.RemainingArguments.ToArray()); + Assert.True(option.HasValue()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionWithValueBeforeSingleValueOption_Default() + { + var unexpectedOption = "--unexpected"; + var unexpectedValue = "value"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute(unexpectedOption, unexpectedValue, "--first", "one"); + + Assert.Equal( + new[] { unexpectedOption, unexpectedValue, "--first", "one" }, + app.RemainingArguments.ToArray()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionWithValueBeforeSingleValueOption_Continue() + { + var unexpectedOption = "--unexpected"; + var unexpectedValue = "value"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var option = app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute(unexpectedOption, unexpectedValue, "--first", "one"); + + Assert.Equal( + new[] { unexpectedOption, unexpectedValue }, + app.RemainingArguments.ToArray()); + Assert.Equal("one", option.Value()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedLongOptionWithValueAfterSingleValueOption() + { + var unexpectedOption = "--unexpected"; + var unexpectedValue = "value"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var option = app.Option("--first", "first option", CommandOptionType.SingleValue); + + // (does not throw) + app.Execute("--first", "one", unexpectedOption, unexpectedValue); + + Assert.Equal(new[] { unexpectedOption, unexpectedValue }, app.RemainingArguments.ToArray()); + Assert.Equal("one", option.Value()); + } + + [Fact] + public void ThrowsExceptionOnUnexpectedShortOptionByDefault() + { + var unexpectedOption = "-uexp"; + var app = new CommandLineApplication(); + + app.Command("test", c => + { + c.OnExecute(() => 0); + }); + + var exception = Assert.Throws(() => app.Execute("test", unexpectedOption)); + Assert.Equal($"Unrecognized option '{unexpectedOption}'", exception.Message); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedShortOption() + { + var unexpectedOption = "-uexp"; + var app = new CommandLineApplication(); + + var testCmd = app.Command("test", c => + { + c.OnExecute(() => 0); + }, + throwOnUnexpectedArg: false); + + // (does not throw) + app.Execute("test", unexpectedOption); + var arg = Assert.Single(testCmd.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + } + + [Fact] + public void ThrowsExceptionOnUnexpectedSymbolOptionByDefault() + { + var unexpectedOption = "-?"; + var app = new CommandLineApplication(); + + app.Command("test", c => + { + c.OnExecute(() => 0); + }); + + var exception = Assert.Throws(() => app.Execute("test", unexpectedOption)); + Assert.Equal($"Unrecognized option '{unexpectedOption}'", exception.Message); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedSymbolOption() + { + var unexpectedOption = "-?"; + var app = new CommandLineApplication(); + + var testCmd = app.Command("test", c => + { + c.OnExecute(() => 0); + }, + throwOnUnexpectedArg: false); + + // (does not throw) + app.Execute("test", unexpectedOption); + var arg = Assert.Single(testCmd.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + } + + [Fact] + public void ThrowsExceptionOnUnexpectedOptionBeforeValidSubcommandByDefault() + { + var unexpectedOption = "--unexpected"; + CommandLineApplication subCmd = null; + var app = new CommandLineApplication(); + + app.Command("k", c => + { + subCmd = c.Command("run", _ => { }); + c.OnExecute(() => 0); + }); + + var exception = Assert.Throws(() => app.Execute("k", unexpectedOption, "run")); + Assert.Equal($"Unrecognized option '{unexpectedOption}'", exception.Message); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedOptionBeforeSubcommand() + { + var unexpectedOption = "--unexpected"; + var app = new CommandLineApplication(); + + CommandLineApplication subCmd = null; + var testCmd = app.Command("k", c => + { + subCmd = c.Command("run", _ => { }); + c.OnExecute(() => 0); + }, + throwOnUnexpectedArg: false); + + // (does not throw) + app.Execute("k", unexpectedOption, "run"); + + Assert.Empty(app.RemainingArguments); + Assert.Equal(new[] { unexpectedOption, "run" }, testCmd.RemainingArguments.ToArray()); + Assert.Empty(subCmd.RemainingArguments); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedOptionAfterSubcommand() + { + var unexpectedOption = "--unexpected"; + var app = new CommandLineApplication(); + + CommandLineApplication subCmd = null; + var testCmd = app.Command("k", c => + { + subCmd = c.Command("run", _ => { }, throwOnUnexpectedArg: false); + c.OnExecute(() => 0); + }); + + // (does not throw) + app.Execute("k", "run", unexpectedOption); + + Assert.Empty(app.RemainingArguments); + Assert.Empty(testCmd.RemainingArguments); + var arg = Assert.Single(subCmd.RemainingArguments); + Assert.Equal(unexpectedOption, arg); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedOptionBeforeValidCommand_Default() + { + var arguments = new[] { "--unexpected", "run" }; + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var commandRan = false; + app.Command("run", c => c.OnExecute(() => { commandRan = true; return 0; })); + app.OnExecute(() => 0); + + app.Execute(arguments); + + Assert.False(commandRan); + Assert.Equal(arguments, app.RemainingArguments.ToArray()); + } + + [Fact] + public void AllowNoThrowBehaviorOnUnexpectedOptionBeforeValidCommand_Continue() + { + var unexpectedOption = "--unexpected"; + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var commandRan = false; + app.Command("run", c => c.OnExecute(() => { commandRan = true; return 0; })); + app.OnExecute(() => 0); + + app.Execute(unexpectedOption, "run"); + + Assert.True(commandRan); + var remaining = Assert.Single(app.RemainingArguments); + Assert.Equal(unexpectedOption, remaining); + } + + [Fact] + public void OptionsCanBeInherited() + { + var app = new CommandLineApplication(); + var optionA = app.Option("-a|--option-a", "", CommandOptionType.SingleValue, inherited: true); + string optionAValue = null; + + var optionB = app.Option("-b", "", CommandOptionType.SingleValue, inherited: false); + + var subcmd = app.Command("subcmd", c => + { + c.OnExecute(() => + { + optionAValue = optionA.Value(); + return 0; + }); + }); + + Assert.Equal(2, app.GetOptions().Count()); + Assert.Single(subcmd.GetOptions()); + + app.Execute("-a", "A1", "subcmd"); + Assert.Equal("A1", optionAValue); + + Assert.Throws(() => app.Execute("subcmd", "-b", "B")); + + Assert.Contains("-a|--option-a", subcmd.GetHelpText()); + } + + [Fact] + public void NestedOptionConflictThrows() + { + var app = new CommandLineApplication(); + app.Option("-a|--always", "Top-level", CommandOptionType.SingleValue, inherited: true); + app.Command("subcmd", c => + { + c.Option("-a|--ask", "Nested", CommandOptionType.SingleValue); + }); + + Assert.Throws(() => app.Execute("subcmd", "-a", "b")); + } + + [Fact] + public void OptionsWithSameName() + { + var app = new CommandLineApplication(); + var top = app.Option("-a|--always", "Top-level", CommandOptionType.SingleValue, inherited: false); + CommandOption nested = null; + app.Command("subcmd", c => + { + nested = c.Option("-a|--ask", "Nested", CommandOptionType.SingleValue); + }); + + app.Execute("-a", "top"); + Assert.Equal("top", top.Value()); + Assert.Null(nested.Value()); + + top.Values.Clear(); + + app.Execute("subcmd", "-a", "nested"); + Assert.Null(top.Value()); + Assert.Equal("nested", nested.Value()); + } + + [Fact] + public void NestedInheritedOptions() + { + string globalOptionValue = null, nest1OptionValue = null, nest2OptionValue = null; + + var app = new CommandLineApplication(); + CommandLineApplication subcmd2 = null; + var g = app.Option("-g|--global", "Global option", CommandOptionType.SingleValue, inherited: true); + var subcmd1 = app.Command("lvl1", s1 => + { + var n1 = s1.Option("--nest1", "Nested one level down", CommandOptionType.SingleValue, inherited: true); + subcmd2 = s1.Command("lvl2", s2 => + { + var n2 = s2.Option("--nest2", "Nested one level down", CommandOptionType.SingleValue, inherited: true); + s2.HelpOption("-h|--help"); + s2.OnExecute(() => + { + globalOptionValue = g.Value(); + nest1OptionValue = n1.Value(); + nest2OptionValue = n2.Value(); + return 0; + }); + }); + }); + + Assert.DoesNotContain(app.GetOptions(), o => o.LongName == "nest2"); + Assert.DoesNotContain(app.GetOptions(), o => o.LongName == "nest1"); + Assert.Contains(app.GetOptions(), o => o.LongName == "global"); + + Assert.DoesNotContain(subcmd1.GetOptions(), o => o.LongName == "nest2"); + Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "nest1"); + Assert.Contains(subcmd1.GetOptions(), o => o.LongName == "global"); + + Assert.Contains(subcmd2.GetOptions(), o => o.LongName == "nest2"); + Assert.Contains(subcmd2.GetOptions(), o => o.LongName == "nest1"); + Assert.Contains(subcmd2.GetOptions(), o => o.LongName == "global"); + + Assert.Throws(() => app.Execute("--nest2", "N2", "--nest1", "N1", "-g", "G")); + Assert.Throws(() => app.Execute("lvl1", "--nest2", "N2", "--nest1", "N1", "-g", "G")); + + app.Execute("lvl1", "lvl2", "--nest2", "N2", "-g", "G", "--nest1", "N1"); + Assert.Equal("G", globalOptionValue); + Assert.Equal("N1", nest1OptionValue); + Assert.Equal("N2", nest2OptionValue); + } + + [Theory] + [InlineData(new string[0], new string[0], null)] + [InlineData(new[] { "--" }, new string[0], null)] + [InlineData(new[] { "-t", "val" }, new string[0], "val")] + [InlineData(new[] { "-t", "val", "--" }, new string[0], "val")] + [InlineData(new[] { "--top", "val", "--", "a" }, new[] { "a" }, "val")] + [InlineData(new[] { "--", "a", "--top", "val" }, new[] { "a", "--top", "val" }, null)] + [InlineData(new[] { "-t", "val", "--", "a", "--", "b" }, new[] { "a", "--", "b" }, "val")] + [InlineData(new[] { "--", "--help" }, new[] { "--help" }, null)] + [InlineData(new[] { "--", "--version" }, new[] { "--version" }, null)] + public void ArgumentSeparator(string[] input, string[] expectedRemaining, string topLevelValue) + { + var app = new CommandLineApplication(throwOnUnexpectedArg: false) + { + AllowArgumentSeparator = true + }; + var optHelp = app.HelpOption("--help"); + var optVersion = app.VersionOption("--version", "1", "1.0"); + var optTop = app.Option("-t|--top ", "arg for command", CommandOptionType.SingleValue); + app.Execute(input); + + Assert.Equal(topLevelValue, optTop.Value()); + Assert.False(optHelp.HasValue()); + Assert.False(optVersion.HasValue()); + Assert.Equal(expectedRemaining, app.RemainingArguments.ToArray()); + } + + [Theory] + [InlineData(new string[0], new string[0], null, false)] + [InlineData(new[] { "--" }, new[] { "--" }, null, false)] + [InlineData(new[] { "-t", "val" }, new string[0], "val", false)] + [InlineData(new[] { "-t", "val", "--" }, new[] { "--" }, "val", false)] + [InlineData(new[] { "--top", "val", "--", "a" }, new[] { "--", "a" }, "val", false)] + [InlineData(new[] { "-t", "val", "--", "a", "--", "b" }, new[] { "--", "a", "--", "b" }, "val", false)] + [InlineData(new[] { "--help", "--" }, new string[0], null, true)] + [InlineData(new[] { "--version", "--" }, new string[0], null, true)] + public void ArgumentSeparator_TreatedAsUexpected( + string[] input, + string[] expectedRemaining, + string topLevelValue, + bool isShowingInformation) + { + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var optHelp = app.HelpOption("--help"); + var optVersion = app.VersionOption("--version", "1", "1.0"); + var optTop = app.Option("-t|--top ", "arg for command", CommandOptionType.SingleValue); + + app.Execute(input); + + Assert.Equal(topLevelValue, optTop.Value()); + Assert.Equal(expectedRemaining, app.RemainingArguments.ToArray()); + Assert.Equal(isShowingInformation, app.IsShowingInformation); + + // Help and Version options never get values; parsing ends when encountered. + Assert.False(optHelp.HasValue()); + Assert.False(optVersion.HasValue()); + } + + [Theory] + [InlineData(new[] { "--", "a", "--top", "val" }, new[] { "--", "a", "--top", "val" }, null, false)] + [InlineData(new[] { "--", "--help" }, new[] { "--", "--help" }, null, false)] + [InlineData(new[] { "--", "--version" }, new[] { "--", "--version" }, null, false)] + [InlineData(new[] { "unexpected", "--", "--version" }, new[] { "unexpected", "--", "--version" }, null, false)] + public void ArgumentSeparator_TreatedAsUexpected_Default( + string[] input, + string[] expectedRemaining, + string topLevelValue, + bool isShowingInformation) + { + var app = new CommandLineApplication(throwOnUnexpectedArg: false); + var optHelp = app.HelpOption("--help"); + var optVersion = app.VersionOption("--version", "1", "1.0"); + var optTop = app.Option("-t|--top ", "arg for command", CommandOptionType.SingleValue); + + app.Execute(input); + + Assert.Equal(topLevelValue, optTop.Value()); + Assert.Equal(expectedRemaining, app.RemainingArguments.ToArray()); + Assert.Equal(isShowingInformation, app.IsShowingInformation); + + // Help and Version options never get values; parsing ends when encountered. + Assert.False(optHelp.HasValue()); + Assert.False(optVersion.HasValue()); + } + + [Theory] + [InlineData(new[] { "--", "a", "--top", "val" }, new[] { "--", "a" }, "val", false)] + [InlineData(new[] { "--", "--help" }, new[] { "--" }, null, true)] + [InlineData(new[] { "--", "--version" }, new[] { "--" }, null, true)] + [InlineData(new[] { "unexpected", "--", "--version" }, new[] { "unexpected", "--" }, null, true)] + public void ArgumentSeparator_TreatedAsUexpected_Continue( + string[] input, + string[] expectedRemaining, + string topLevelValue, + bool isShowingInformation) + { + var app = new CommandLineApplication(throwOnUnexpectedArg: false, continueAfterUnexpectedArg: true); + var optHelp = app.HelpOption("--help"); + var optVersion = app.VersionOption("--version", "1", "1.0"); + var optTop = app.Option("-t|--top ", "arg for command", CommandOptionType.SingleValue); + + app.Execute(input); + + Assert.Equal(topLevelValue, optTop.Value()); + Assert.Equal(expectedRemaining, app.RemainingArguments.ToArray()); + Assert.Equal(isShowingInformation, app.IsShowingInformation); + + // Help and Version options never get values; parsing ends when encountered. + Assert.False(optHelp.HasValue()); + Assert.False(optVersion.HasValue()); + } + + [Fact] + public void HelpTextIgnoresHiddenItems() + { + var app = new CommandLineApplication() + { + Name = "ninja-app", + Description = "You can't see it until it is too late" + }; + + app.Command("star", c => + { + c.Option("--points

", "How many", CommandOptionType.MultipleValue); + c.ShowInHelpText = false; + }); + app.Option("--smile", "Be a nice ninja", CommandOptionType.NoValue, o => { o.ShowInHelpText = false; }); + + var a = app.Argument("name", "Pseudonym, of course"); + a.ShowInHelpText = false; + + var help = app.GetHelpText(); + + Assert.Contains("ninja-app", help); + Assert.DoesNotContain("--points", help); + Assert.DoesNotContain("--smile", help); + Assert.DoesNotContain("name", help); + } + + [Fact] + public void HelpTextUsesHelpOptionName() + { + var app = new CommandLineApplication + { + Name = "superhombre" + }; + + app.HelpOption("--ayuda-me"); + var help = app.GetHelpText(); + Assert.Contains("--ayuda-me", help); + } + + [Fact] + public void HelpTextShowsArgSeparator() + { + var app = new CommandLineApplication(throwOnUnexpectedArg: false) + { + Name = "proxy-command", + AllowArgumentSeparator = true + }; + app.HelpOption("-h|--help"); + Assert.Contains("Usage: proxy-command [options] [[--] ...]", app.GetHelpText()); + } + + [Fact] + public void HelpTextShowsExtendedHelp() + { + var app = new CommandLineApplication() + { + Name = "befuddle", + ExtendedHelpText = @" +Remarks: + This command is so confusing that I want to include examples in the help text. + +Examples: + dotnet befuddle -- I Can Haz Confusion Arguments +" + }; + + Assert.Contains(app.ExtendedHelpText, app.GetHelpText()); + } + + [Theory] + [InlineData(new[] { "--version", "--flag" }, "1.0")] + [InlineData(new[] { "-V", "-f" }, "1.0")] + [InlineData(new[] { "--help", "--flag" }, "some flag")] + [InlineData(new[] { "-h", "-f" }, "some flag")] + public void HelpAndVersionOptionStopProcessing(string[] input, string expectedOutData) + { + using var outWriter = new StringWriter(); + var app = new CommandLineApplication { Out = outWriter }; + app.HelpOption("-h --help"); + app.VersionOption("-V --version", "1", "1.0"); + var optFlag = app.Option("-f |--flag", "some flag", CommandOptionType.NoValue); + + app.Execute(input); + + outWriter.Flush(); + var outData = outWriter.ToString(); + Assert.Contains(expectedOutData, outData); + Assert.False(optFlag.HasValue()); + } + + // disable inaccurate analyzer error https://github.com/xunit/xunit/issues/1274 +#pragma warning disable xUnit1010 +#pragma warning disable xUnit1011 + [Theory] + [InlineData("-f:File1", "-f:File2")] + [InlineData("--file:File1", "--file:File2")] + [InlineData("--file", "File1", "--file", "File2")] +#pragma warning restore xUnit1010 +#pragma warning restore xUnit1011 + public void ThrowsExceptionOnSingleValueOptionHavingTwoValues(params string[] inputOptions) + { + var app = new CommandLineApplication(); + app.Option("-f |--file", "some file", CommandOptionType.SingleValue); + + var exception = Assert.Throws(() => app.Execute(inputOptions)); + + Assert.Equal("Unexpected value 'File2' for option 'file'", exception.Message); + } + + [Theory] + [InlineData("-v")] + [InlineData("--verbose")] + public void NoValueOptionCanBeSet(string input) + { + var app = new CommandLineApplication(); + var optVerbose = app.Option("-v |--verbose", "be verbose", CommandOptionType.NoValue); + + app.Execute(input); + + Assert.True(optVerbose.HasValue()); + } + + [Theory] + [InlineData("-v:true")] + [InlineData("--verbose:true")] + public void ThrowsExceptionOnNoValueOptionHavingValue(string inputOption) + { + var app = new CommandLineApplication(); + app.Option("-v |--verbose", "be verbose", CommandOptionType.NoValue); + + var exception = Assert.Throws(() => app.Execute(inputOption)); + + Assert.Equal("Unexpected value 'true' for option 'verbose'", exception.Message); + } + + [Fact] + public void ThrowsExceptionOnEmptyCommandOrArgument() + { + var inputOption = String.Empty; + var app = new CommandLineApplication(); + + var exception = Assert.Throws(() => app.Execute(inputOption)); + + Assert.Equal($"Unrecognized command or argument '{inputOption}'", exception.Message); + } + + [Fact] + public void ThrowsExceptionOnInvalidOption() + { + var inputOption = "-"; + var app = new CommandLineApplication(); + + var exception = Assert.Throws(() => app.Execute(inputOption)); + + Assert.Equal($"Unrecognized option '{inputOption}'", exception.Message); + } + + [Fact] + public void TreatUnmatchedOptionsAsArguments() + { + CommandArgument first = null; + CommandArgument second = null; + + CommandOption firstOption = null; + CommandOption secondOption = null; + + var firstUnmatchedOption = "-firstUnmatchedOption"; + var firstActualOption = "-firstActualOption"; + var seconUnmatchedOption = "--secondUnmatchedOption"; + var secondActualOption = "--secondActualOption"; + + var app = new CommandLineApplication(treatUnmatchedOptionsAsArguments: true); + + app.Command("test", c => + { + firstOption = c.Option("-firstActualOption", "first option", CommandOptionType.NoValue); + secondOption = c.Option("--secondActualOption", "second option", CommandOptionType.NoValue); + + first = c.Argument("first", "First argument"); + second = c.Argument("second", "Second argument"); + c.OnExecute(() => 0); + }); + + app.Execute("test", firstUnmatchedOption, firstActualOption, seconUnmatchedOption, secondActualOption); + + Assert.Equal(firstUnmatchedOption, first.Value); + Assert.Equal(seconUnmatchedOption, second.Value); + + Assert.Equal(firstActualOption, firstOption.Template); + Assert.Equal(secondActualOption, secondOption.Template); + } + + [Fact] + public void ThrowExceptionWhenUnmatchedOptionAndTreatUnmatchedOptionsAsArgumentsIsFalse() + { + CommandArgument first = null; + + var firstOption = "-firstUnmatchedOption"; + + var app = new CommandLineApplication(treatUnmatchedOptionsAsArguments: false); + app.Command("test", c => + { + first = c.Argument("first", "First argument"); + c.OnExecute(() => 0); + }); + + var exception = Assert.Throws(() => app.Execute("test", firstOption)); + + Assert.Equal($"Unrecognized option '{firstOption}'", exception.Message); + } + } +} diff --git a/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs b/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs new file mode 100644 index 000000000000..8840d87bb84b --- /dev/null +++ b/src/Shared/test/Shared.Tests/DotNetMuxerTests.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +#if NETCOREAPP +using System.IO; +using System.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.Extensions.CommandLineUtils +{ + public class DotNetMuxerTests + { + [Fact] + public void FindsTheMuxer() + { + var muxerPath = DotNetMuxer.MuxerPath; + Assert.NotNull(muxerPath); + Assert.True(File.Exists(muxerPath), "The file did not exist"); + Assert.True(Path.IsPathRooted(muxerPath), "The path should be rooted"); + Assert.Equal("dotnet", Path.GetFileNameWithoutExtension(muxerPath), ignoreCase: true); + } + } +} +#endif diff --git a/src/Shared/test/Shared.Tests/HashCodeCombinerTest.cs b/src/Shared/test/Shared.Tests/HashCodeCombinerTest.cs new file mode 100644 index 000000000000..fab3e309791e --- /dev/null +++ b/src/Shared/test/Shared.Tests/HashCodeCombinerTest.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.Extensions.Internal +{ + public class HashCodeCombinerTest + { + [Fact] + public void GivenTheSameInputs_ItProducesTheSameOutput() + { + var hashCode1 = new HashCodeCombiner(); + var hashCode2 = new HashCodeCombiner(); + + hashCode1.Add(42); + hashCode1.Add("foo"); + hashCode2.Add(42); + hashCode2.Add("foo"); + + Assert.Equal(hashCode1.CombinedHash, hashCode2.CombinedHash); + } + + [Fact] + public void HashCode_Is_OrderSensitive() + { + var hashCode1 = HashCodeCombiner.Start(); + var hashCode2 = HashCodeCombiner.Start(); + + hashCode1.Add(42); + hashCode1.Add("foo"); + + hashCode2.Add("foo"); + hashCode2.Add(42); + + Assert.NotEqual(hashCode1.CombinedHash, hashCode2.CombinedHash); + } + } +} diff --git a/src/Shared/test/Shared.Tests/HostFactoryResolverTests.cs b/src/Shared/test/Shared.Tests/HostFactoryResolverTests.cs new file mode 100644 index 000000000000..a26fb7b1332d --- /dev/null +++ b/src/Shared/test/Shared.Tests/HostFactoryResolverTests.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MockHostTypes; +using System; +using Xunit; + +namespace Microsoft.Extensions.Hosting.Tests +{ + public class HostFactoryResolverTests + { + [Fact] + public void BuildWebHostPattern_CanFindWebHost() + { + var factory = HostFactoryResolver.ResolveWebHostFactory(typeof(BuildWebHostPatternTestSite.Program).Assembly); + + Assert.NotNull(factory); + Assert.IsAssignableFrom(factory(Array.Empty())); + } + + [Fact] + public void BuildWebHostPattern_CanFindServiceProvider() + { + var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(BuildWebHostPatternTestSite.Program).Assembly); + + Assert.NotNull(factory); + Assert.IsAssignableFrom(factory(Array.Empty())); + } + + [Fact] + public void BuildWebHostPattern__Invalid_CantFindWebHost() + { + var factory = HostFactoryResolver.ResolveWebHostFactory(typeof(BuildWebHostInvalidSignature.Program).Assembly); + + Assert.Null(factory); + } + + [Fact] + public void BuildWebHostPattern__Invalid_CantFindServiceProvider() + { + var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(BuildWebHostInvalidSignature.Program).Assembly); + + Assert.Null(factory); + } + + [Fact] + public void CreateWebHostBuilderPattern_CanFindWebHostBuilder() + { + var factory = HostFactoryResolver.ResolveWebHostBuilderFactory(typeof(CreateWebHostBuilderPatternTestSite.Program).Assembly); + + Assert.NotNull(factory); + Assert.IsAssignableFrom(factory(Array.Empty())); + } + + [Fact] + public void CreateWebHostBuilderPattern_CanFindServiceProvider() + { + var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(CreateWebHostBuilderPatternTestSite.Program).Assembly); + + Assert.NotNull(factory); + Assert.IsAssignableFrom(factory(Array.Empty())); + } + + [Fact] + public void CreateWebHostBuilderPattern__Invalid_CantFindWebHostBuilder() + { + var factory = HostFactoryResolver.ResolveWebHostBuilderFactory(typeof(CreateWebHostBuilderInvalidSignature.Program).Assembly); + + Assert.Null(factory); + } + + [Fact] + public void CreateWebHostBuilderPattern__InvalidReturnType_CanFindServiceProvider() + { + var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(CreateWebHostBuilderInvalidSignature.Program).Assembly); + + Assert.NotNull(factory); + Assert.Null(factory(Array.Empty())); + + } + + [Fact] + public void CreateHostBuilderPattern_CanFindHostBuilder() + { + var factory = HostFactoryResolver.ResolveHostBuilderFactory(typeof(CreateHostBuilderPatternTestSite.Program).Assembly); + + Assert.NotNull(factory); + Assert.IsAssignableFrom(factory(Array.Empty())); + } + + [Fact] + public void CreateHostBuilderPattern_CanFindServiceProvider() + { + var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(CreateHostBuilderPatternTestSite.Program).Assembly); + + Assert.NotNull(factory); + Assert.IsAssignableFrom(factory(Array.Empty())); + } + + [Fact] + public void CreateHostBuilderPattern__Invalid_CantFindHostBuilder() + { + var factory = HostFactoryResolver.ResolveHostBuilderFactory(typeof(CreateHostBuilderInvalidSignature.Program).Assembly); + + Assert.Null(factory); + } + + [Fact] + public void CreateHostBuilderPattern__Invalid_CantFindServiceProvider() + { + var factory = HostFactoryResolver.ResolveServiceProviderFactory(typeof(CreateHostBuilderInvalidSignature.Program).Assembly); + + Assert.Null(factory); + } + } +} diff --git a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj index f11a9c61c006..76051ab18149 100644 --- a/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj +++ b/src/Shared/test/Shared.Tests/Microsoft.AspNetCore.Shared.Tests.csproj @@ -8,21 +8,31 @@ + + + + + + + + + + @@ -31,6 +41,13 @@ + + + + + + + System.Net.Http.SR diff --git a/src/Shared/test/Shared.Tests/NonCapturingTimerTest.cs b/src/Shared/test/Shared.Tests/NonCapturingTimerTest.cs new file mode 100644 index 000000000000..ef21ce5f3b01 --- /dev/null +++ b/src/Shared/test/Shared.Tests/NonCapturingTimerTest.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.Internal +{ + public class NonCapturingTimerTest + { + [Fact] + public async Task NonCapturingTimer_DoesntCaptureExecutionContext() + { + // Arrange + var message = new AsyncLocal(); + message.Value = "Hey, this is a value stored in the execuion context"; + + var tcs = new TaskCompletionSource(); + + // Act + var timer = NonCapturingTimer.Create((_) => + { + // Observe the value based on the current execution context + tcs.SetResult(message.Value); + }, state: null, dueTime: TimeSpan.FromMilliseconds(1), Timeout.InfiniteTimeSpan); + + // Assert + var messageFromTimer = await tcs.Task; + timer.Dispose(); + + // ExecutionContext didn't flow to timer callback + Assert.Null(messageFromTimer); + + // ExecutionContext was restored + Assert.NotNull(await Task.Run(() => message.Value)); + } + } +} diff --git a/src/Shared/test/Shared.Tests/SingleThreadedSynchronizationContext.cs b/src/Shared/test/Shared.Tests/SingleThreadedSynchronizationContext.cs new file mode 100644 index 000000000000..77312e0a0544 --- /dev/null +++ b/src/Shared/test/Shared.Tests/SingleThreadedSynchronizationContext.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Concurrent; +using System.Threading; + +namespace Microsoft.Extensions.Internal +{ + internal class SingleThreadedSynchronizationContext : SynchronizationContext + { + private readonly BlockingCollection<(SendOrPostCallback Callback, object State)> _queue = new BlockingCollection<(SendOrPostCallback Callback, object State)>(); + + public override void Send(SendOrPostCallback d, object state) // Sync operations + { + throw new NotSupportedException($"{nameof(SingleThreadedSynchronizationContext)} does not support synchronous operations."); + } + + public override void Post(SendOrPostCallback d, object state) // Async operations + { + _queue.Add((d, state)); + } + + public static void Run(Action action) + { + var previous = Current; + var context = new SingleThreadedSynchronizationContext(); + SetSynchronizationContext(context); + try + { + action(); + + while (context._queue.TryTake(out var item)) + { + item.Callback(item.State); + } + } + finally + { + context._queue.CompleteAdding(); + SetSynchronizationContext(previous); + } + } + } +} diff --git a/src/Shared/test/Shared.Tests/TypeNameHelperTest.cs b/src/Shared/test/Shared.Tests/TypeNameHelperTest.cs new file mode 100644 index 000000000000..bd29f647d10b --- /dev/null +++ b/src/Shared/test/Shared.Tests/TypeNameHelperTest.cs @@ -0,0 +1,304 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.Extensions.Internal +{ + public class TypeNameHelperTest + { + [Theory] + // Predefined Types + [InlineData(typeof(int), "int")] + [InlineData(typeof(List), "System.Collections.Generic.List")] + [InlineData(typeof(Dictionary), "System.Collections.Generic.Dictionary")] + [InlineData(typeof(Dictionary>), "System.Collections.Generic.Dictionary>")] + [InlineData(typeof(List>), "System.Collections.Generic.List>")] + // Classes inside NonGeneric class + [InlineData(typeof(A), + "Microsoft.Extensions.Internal.TypeNameHelperTest+A")] + [InlineData(typeof(B), + "Microsoft.Extensions.Internal.TypeNameHelperTest+B")] + [InlineData(typeof(C), + "Microsoft.Extensions.Internal.TypeNameHelperTest+C")] + [InlineData(typeof(B>), + "Microsoft.Extensions.Internal.TypeNameHelperTest+B>")] + [InlineData(typeof(C>), + "Microsoft.Extensions.Internal.TypeNameHelperTest+C>")] + // Classes inside Generic class + [InlineData(typeof(Outer.D), + "Microsoft.Extensions.Internal.TypeNameHelperTest+Outer+D")] + [InlineData(typeof(Outer.E), + "Microsoft.Extensions.Internal.TypeNameHelperTest+Outer+E")] + [InlineData(typeof(Outer.F), + "Microsoft.Extensions.Internal.TypeNameHelperTest+Outer+F")] + [InlineData(typeof(Level1.Level2.Level3), + "Microsoft.Extensions.Internal.TypeNameHelperTest+Level1+Level2+Level3")] + [InlineData(typeof(Outer.E.E>), + "Microsoft.Extensions.Internal.TypeNameHelperTest+Outer+E+E>")] + [InlineData(typeof(Outer.F.E>), + "Microsoft.Extensions.Internal.TypeNameHelperTest+Outer+F+E>")] + [InlineData(typeof(OuterGeneric.InnerNonGeneric.InnerGeneric.InnerGenericLeafNode), + "Microsoft.Extensions.Internal.TypeNameHelperTest+OuterGeneric+InnerNonGeneric+InnerGeneric+InnerGenericLeafNode")] + public void Can_pretty_print_CLR_full_name(Type type, string expected) + { + Assert.Equal(expected, TypeNameHelper.GetTypeDisplayName(type)); + } + + [Fact] + public void DoesNotPrintNamespace_ForGenericTypes_IfNullOrEmpty() + { + // Arrange + var type = typeof(ClassInGlobalNamespace); + + // Act & Assert + Assert.Equal("ClassInGlobalNamespace", TypeNameHelper.GetTypeDisplayName(type)); + } + + [Theory] + // Predefined Types + [InlineData(typeof(int), "int")] + [InlineData(typeof(List), "List")] + [InlineData(typeof(Dictionary), "Dictionary")] + [InlineData(typeof(Dictionary>), "Dictionary>")] + [InlineData(typeof(List>), "List>")] + // Classes inside NonGeneric class + [InlineData(typeof(A), "A")] + [InlineData(typeof(B), "B")] + [InlineData(typeof(C), "C")] + [InlineData(typeof(C>), "C>")] + [InlineData(typeof(B>), "B>")] + // Classes inside Generic class + [InlineData(typeof(Outer.D), "D")] + [InlineData(typeof(Outer.E), "E")] + [InlineData(typeof(Outer.F), "F")] + [InlineData(typeof(Outer.F.E>), "F>")] + [InlineData(typeof(Outer.E.E>), "E>")] + [InlineData(typeof(OuterGeneric.InnerNonGeneric.InnerGeneric.InnerGenericLeafNode), "InnerGenericLeafNode")] + public void Can_pretty_print_CLR_name(Type type, string expected) + { + Assert.Equal(expected, TypeNameHelper.GetTypeDisplayName(type, false)); + } + + [Theory] + [InlineData(typeof(void), "void")] + [InlineData(typeof(bool), "bool")] + [InlineData(typeof(byte), "byte")] + [InlineData(typeof(char), "char")] + [InlineData(typeof(decimal), "decimal")] + [InlineData(typeof(double), "double")] + [InlineData(typeof(float), "float")] + [InlineData(typeof(int), "int")] + [InlineData(typeof(long), "long")] + [InlineData(typeof(object), "object")] + [InlineData(typeof(sbyte), "sbyte")] + [InlineData(typeof(short), "short")] + [InlineData(typeof(string), "string")] + [InlineData(typeof(uint), "uint")] + [InlineData(typeof(ulong), "ulong")] + [InlineData(typeof(ushort), "ushort")] + public void Returns_common_name_for_built_in_types(Type type, string expected) + { + Assert.Equal(expected, TypeNameHelper.GetTypeDisplayName(type)); + } + + [Theory] + [InlineData(typeof(int[]), true, "int[]")] + [InlineData(typeof(string[][]), true, "string[][]")] + [InlineData(typeof(int[,]), true, "int[,]")] + [InlineData(typeof(bool[,,,]), true, "bool[,,,]")] + [InlineData(typeof(A[,][,,]), true, "Microsoft.Extensions.Internal.TypeNameHelperTest+A[,][,,]")] + [InlineData(typeof(List), true, "System.Collections.Generic.List")] + [InlineData(typeof(List[,][,,]), false, "List[,][,,]")] + public void Can_pretty_print_array_name(Type type, bool fullName, string expected) + { + Assert.Equal(expected, TypeNameHelper.GetTypeDisplayName(type, fullName)); + } + + public static TheoryData GetOpenGenericsTestData() + { + var openDictionaryType = typeof(Dictionary<,>); + var genArgsDictionary = openDictionaryType.GetGenericArguments(); + genArgsDictionary[0] = typeof(B<>); + var closedDictionaryType = openDictionaryType.MakeGenericType(genArgsDictionary); + + var openLevelType = typeof(Level1<>.Level2<>.Level3<>); + var genArgsLevel = openLevelType.GetGenericArguments(); + genArgsLevel[1] = typeof(string); + var closedLevelType = openLevelType.MakeGenericType(genArgsLevel); + + var openInnerType = typeof(OuterGeneric<>.InnerNonGeneric.InnerGeneric<,>.InnerGenericLeafNode<>); + var genArgsInnerType = openInnerType.GetGenericArguments(); + genArgsInnerType[3] = typeof(bool); + var closedInnerType = openInnerType.MakeGenericType(genArgsInnerType); + + return new TheoryData + { + { typeof(List<>), false, "List<>" }, + { typeof(Dictionary<,>), false , "Dictionary<,>" }, + { typeof(List<>), true , "System.Collections.Generic.List<>" }, + { typeof(Dictionary<,>), true , "System.Collections.Generic.Dictionary<,>" }, + { typeof(Level1<>.Level2<>.Level3<>), true, "Microsoft.Extensions.Internal.TypeNameHelperTest+Level1<>+Level2<>+Level3<>" }, + { + typeof(PartiallyClosedGeneric<>).BaseType, + true, + "Microsoft.Extensions.Internal.TypeNameHelperTest+C<, int>" + }, + { + typeof(OuterGeneric<>.InnerNonGeneric.InnerGeneric<,>.InnerGenericLeafNode<>), + true, + "Microsoft.Extensions.Internal.TypeNameHelperTest+OuterGeneric<>+InnerNonGeneric+InnerGeneric<,>+InnerGenericLeafNode<>" + }, + { + closedDictionaryType, + true, + "System.Collections.Generic.Dictionary,>" + }, + { + closedLevelType, + true, + "Microsoft.Extensions.Internal.TypeNameHelperTest+Level1<>+Level2+Level3<>" + }, + { + closedInnerType, + true, + "Microsoft.Extensions.Internal.TypeNameHelperTest+OuterGeneric<>+InnerNonGeneric+InnerGeneric<,>+InnerGenericLeafNode" + } + }; + } + + [Theory] + [MemberData(nameof(GetOpenGenericsTestData))] + public void Can_pretty_print_open_generics(Type type, bool fullName, string expected) + { + Assert.Equal(expected, TypeNameHelper.GetTypeDisplayName(type, fullName)); + } + + public static TheoryData GetTypeDisplayName_IncludesGenericParameterNamesWhenOptionIsSetData => + new TheoryData + { + { typeof(B<>),"Microsoft.Extensions.Internal.TypeNameHelperTest+B" }, + { typeof(C<,>),"Microsoft.Extensions.Internal.TypeNameHelperTest+C" }, + { typeof(PartiallyClosedGeneric<>).BaseType,"Microsoft.Extensions.Internal.TypeNameHelperTest+C" }, + { typeof(Level1<>.Level2<>),"Microsoft.Extensions.Internal.TypeNameHelperTest+Level1+Level2" }, + }; + + [Theory] + [MemberData(nameof(GetTypeDisplayName_IncludesGenericParameterNamesWhenOptionIsSetData))] + public void GetTypeDisplayName_IncludesGenericParameterNamesWhenOptionIsSet(Type type, string expected) + { + // Arrange & Act + var actual = TypeNameHelper.GetTypeDisplayName(type, fullName: true, includeGenericParameterNames: true); + + // Assert + Assert.Equal(expected, actual); + } + + public static TheoryData GetTypeDisplayName_WithoutFullName_IncludesGenericParameterNamesWhenOptionIsSetData => + new TheoryData + { + { typeof(B<>),"B" }, + { typeof(C<,>),"C" }, + { typeof(PartiallyClosedGeneric<>).BaseType,"C" }, + { typeof(Level1<>.Level2<>),"Level2" }, + }; + + [Theory] + [MemberData(nameof(GetTypeDisplayName_WithoutFullName_IncludesGenericParameterNamesWhenOptionIsSetData))] + public void GetTypeDisplayName_WithoutFullName_IncludesGenericParameterNamesWhenOptionIsSet(Type type, string expected) + { + // Arrange & Act + var actual = TypeNameHelper.GetTypeDisplayName(type, fullName: false, includeGenericParameterNames: true); + + // Assert + Assert.Equal(expected, actual); + } + + public static TheoryData FullTypeNameData + { + get + { + return new TheoryData + { + // Predefined Types + { typeof(int), "int" }, + { typeof(List), "System.Collections.Generic.List" }, + { typeof(Dictionary), "System.Collections.Generic.Dictionary" }, + { typeof(Dictionary>), "System.Collections.Generic.Dictionary" }, + { typeof(List>), "System.Collections.Generic.List" }, + + // Classes inside NonGeneric class + { typeof(A), "Microsoft.Extensions.Internal.TypeNameHelperTest.A" }, + { typeof(B), "Microsoft.Extensions.Internal.TypeNameHelperTest.B" }, + { typeof(C), "Microsoft.Extensions.Internal.TypeNameHelperTest.C" }, + { typeof(C>), "Microsoft.Extensions.Internal.TypeNameHelperTest.C" }, + { typeof(B>), "Microsoft.Extensions.Internal.TypeNameHelperTest.B" }, + + // Classes inside Generic class + { typeof(Outer.D), "Microsoft.Extensions.Internal.TypeNameHelperTest.Outer.D" }, + { typeof(Outer.E), "Microsoft.Extensions.Internal.TypeNameHelperTest.Outer.E" }, + { typeof(Outer.F), "Microsoft.Extensions.Internal.TypeNameHelperTest.Outer.F" }, + { typeof(Outer.F.E>),"Microsoft.Extensions.Internal.TypeNameHelperTest.Outer.F" }, + { typeof(Outer.E.E>), "Microsoft.Extensions.Internal.TypeNameHelperTest.Outer.E" } + }; + } + } + + [Theory] + [MemberData(nameof(FullTypeNameData))] + public void Can_PrettyPrint_FullTypeName_WithoutGenericParametersAndNestedTypeDelimiter(Type type, string expectedTypeName) + { + // Arrange & Act + var displayName = TypeNameHelper.GetTypeDisplayName(type, fullName: true, includeGenericParameters: false, nestedTypeDelimiter: '.'); + + // Assert + Assert.Equal(expectedTypeName, displayName); + } + + private class A { } + + private class B { } + + private class C { } + + private class PartiallyClosedGeneric : C { } + + private class Outer + { + public class D { } + + public class E { } + + public class F { } + } + + private class OuterGeneric + { + public class InnerNonGeneric + { + public class InnerGeneric + { + public class InnerGenericLeafNode { } + + public class InnerLeafNode { } + } + } + } + + private class Level1 + { + public class Level2 + { + public class Level3 + { + } + } + } + } +} + +internal class ClassInGlobalNamespace +{ +} diff --git a/src/Shared/test/Shared.Tests/ValueStopwatchTest.cs b/src/Shared/test/Shared.Tests/ValueStopwatchTest.cs new file mode 100644 index 000000000000..fffc2c6656ef --- /dev/null +++ b/src/Shared/test/Shared.Tests/ValueStopwatchTest.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.Internal.Test +{ + public class ValueStopwatchTest + { + [Fact] + public void IsActiveIsFalseForDefaultValueStopwatch() + { + Assert.False(default(ValueStopwatch).IsActive); + } + + [Fact] + public void IsActiveIsTrueWhenValueStopwatchStartedWithStartNew() + { + Assert.True(ValueStopwatch.StartNew().IsActive); + } + + [Fact] + public void GetElapsedTimeThrowsIfValueStopwatchIsDefaultValue() + { + var stopwatch = default(ValueStopwatch); + Assert.Throws(() => stopwatch.GetElapsedTime()); + } + + [Fact] + public async Task GetElapsedTimeReturnsTimeElapsedSinceStart() + { + var stopwatch = ValueStopwatch.StartNew(); + await Task.Delay(200); + Assert.True(stopwatch.GetElapsedTime().TotalMilliseconds > 0); + } + } +} diff --git a/src/Shared/test/testassets/BuildWebHostInvalidSignature/BuildWebHostInvalidSignature.csproj b/src/Shared/test/testassets/BuildWebHostInvalidSignature/BuildWebHostInvalidSignature.csproj new file mode 100644 index 000000000000..05ca293b6eae --- /dev/null +++ b/src/Shared/test/testassets/BuildWebHostInvalidSignature/BuildWebHostInvalidSignature.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + Exe + + + + + + + diff --git a/src/Shared/test/testassets/BuildWebHostInvalidSignature/Program.cs b/src/Shared/test/testassets/BuildWebHostInvalidSignature/Program.cs new file mode 100644 index 000000000000..ba9e3dab6a9c --- /dev/null +++ b/src/Shared/test/testassets/BuildWebHostInvalidSignature/Program.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MockHostTypes; + +namespace BuildWebHostInvalidSignature +{ + public class Program + { + static void Main(string[] args) + { + } + + // Missing string[] args + public static IWebHost BuildWebHost() => null; + } +} diff --git a/src/Shared/test/testassets/BuildWebHostPatternTestSite/BuildWebHostPatternTestSite.csproj b/src/Shared/test/testassets/BuildWebHostPatternTestSite/BuildWebHostPatternTestSite.csproj new file mode 100644 index 000000000000..05ca293b6eae --- /dev/null +++ b/src/Shared/test/testassets/BuildWebHostPatternTestSite/BuildWebHostPatternTestSite.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + Exe + + + + + + + diff --git a/src/Shared/test/testassets/BuildWebHostPatternTestSite/Program.cs b/src/Shared/test/testassets/BuildWebHostPatternTestSite/Program.cs new file mode 100644 index 000000000000..b1d0655e4dea --- /dev/null +++ b/src/Shared/test/testassets/BuildWebHostPatternTestSite/Program.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MockHostTypes; + +namespace BuildWebHostPatternTestSite +{ + public class Program + { + static void Main(string[] args) + { + } + + public static IWebHost BuildWebHost(string[] args) => new WebHost(); + } +} diff --git a/src/Shared/test/testassets/CreateHostBuilderInvalidSignature/CreateHostBuilderInvalidSignature.csproj b/src/Shared/test/testassets/CreateHostBuilderInvalidSignature/CreateHostBuilderInvalidSignature.csproj new file mode 100644 index 000000000000..05ca293b6eae --- /dev/null +++ b/src/Shared/test/testassets/CreateHostBuilderInvalidSignature/CreateHostBuilderInvalidSignature.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + Exe + + + + + + + diff --git a/src/Shared/test/testassets/CreateHostBuilderInvalidSignature/Program.cs b/src/Shared/test/testassets/CreateHostBuilderInvalidSignature/Program.cs new file mode 100644 index 000000000000..8451301a2007 --- /dev/null +++ b/src/Shared/test/testassets/CreateHostBuilderInvalidSignature/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MockHostTypes; + +namespace CreateHostBuilderInvalidSignature +{ + public class Program + { + public static void Main(string[] args) + { + var webHost = CreateHostBuilder(null, args).Build(); + } + + // Extra parameter + private static IHostBuilder CreateHostBuilder(object extraParam, string[] args) => null; + } +} diff --git a/src/Shared/test/testassets/CreateHostBuilderPatternTestSite/CreateHostBuilderPatternTestSite.csproj b/src/Shared/test/testassets/CreateHostBuilderPatternTestSite/CreateHostBuilderPatternTestSite.csproj new file mode 100644 index 000000000000..05ca293b6eae --- /dev/null +++ b/src/Shared/test/testassets/CreateHostBuilderPatternTestSite/CreateHostBuilderPatternTestSite.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + Exe + + + + + + + diff --git a/src/Shared/test/testassets/CreateHostBuilderPatternTestSite/Program.cs b/src/Shared/test/testassets/CreateHostBuilderPatternTestSite/Program.cs new file mode 100644 index 000000000000..70edf1609766 --- /dev/null +++ b/src/Shared/test/testassets/CreateHostBuilderPatternTestSite/Program.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MockHostTypes; + +namespace CreateHostBuilderPatternTestSite +{ + public class Program + { + public static void Main(string[] args) + { + var webHost = CreateHostBuilder(args).Build(); + } + + // Do not change the signature of this method. It's used for tests. + private static HostBuilder CreateHostBuilder(string[] args) => + new HostBuilder(); + } +} diff --git a/src/Shared/test/testassets/CreateWebHostBuilderInvalidSignature/CreateWebHostBuilderInvalidSignature.csproj b/src/Shared/test/testassets/CreateWebHostBuilderInvalidSignature/CreateWebHostBuilderInvalidSignature.csproj new file mode 100644 index 000000000000..05ca293b6eae --- /dev/null +++ b/src/Shared/test/testassets/CreateWebHostBuilderInvalidSignature/CreateWebHostBuilderInvalidSignature.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + Exe + + + + + + + diff --git a/src/Shared/test/testassets/CreateWebHostBuilderInvalidSignature/Program.cs b/src/Shared/test/testassets/CreateWebHostBuilderInvalidSignature/Program.cs new file mode 100644 index 000000000000..1533acbf5783 --- /dev/null +++ b/src/Shared/test/testassets/CreateWebHostBuilderInvalidSignature/Program.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MockHostTypes; + +namespace CreateWebHostBuilderInvalidSignature +{ + public class Program + { + static void Main(string[] args) + { + } + + // Wrong return type + public static IWebHost CreateWebHostBuilder(string[] args) => new WebHost(); + } +} diff --git a/src/Shared/test/testassets/CreateWebHostBuilderPatternTestSite/CreateWebHostBuilderPatternTestSite.csproj b/src/Shared/test/testassets/CreateWebHostBuilderPatternTestSite/CreateWebHostBuilderPatternTestSite.csproj new file mode 100644 index 000000000000..05ca293b6eae --- /dev/null +++ b/src/Shared/test/testassets/CreateWebHostBuilderPatternTestSite/CreateWebHostBuilderPatternTestSite.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + Exe + + + + + + + diff --git a/src/Shared/test/testassets/CreateWebHostBuilderPatternTestSite/Program.cs b/src/Shared/test/testassets/CreateWebHostBuilderPatternTestSite/Program.cs new file mode 100644 index 000000000000..caab3cb22490 --- /dev/null +++ b/src/Shared/test/testassets/CreateWebHostBuilderPatternTestSite/Program.cs @@ -0,0 +1,19 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using MockHostTypes; + +namespace CreateWebHostBuilderPatternTestSite +{ + public class Program + { + public static void Main(string[] args) + { + var webHost = CreateWebHostBuilder(args).Build(); + } + + // Do not change the signature of this method. It's used for tests. + private static IWebHostBuilder CreateWebHostBuilder(string[] args) => + new WebHostBuilder(); + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/Host.cs b/src/Shared/test/testassets/MockHostTypes/Host.cs new file mode 100644 index 000000000000..412ab63ef3eb --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/Host.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace MockHostTypes +{ + public class Host : IHost + { + public IServiceProvider Services { get; } = new ServiceProvider(); + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/HostBuilder.cs b/src/Shared/test/testassets/MockHostTypes/HostBuilder.cs new file mode 100644 index 000000000000..eb62e9a4b131 --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/HostBuilder.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace MockHostTypes +{ + public class HostBuilder : IHostBuilder + { + public IHost Build() => new Host(); + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/IHost.cs b/src/Shared/test/testassets/MockHostTypes/IHost.cs new file mode 100644 index 000000000000..27c6dbaf7153 --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/IHost.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace MockHostTypes +{ + public interface IHost + { + IServiceProvider Services { get; } + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/IHostBuilder.cs b/src/Shared/test/testassets/MockHostTypes/IHostBuilder.cs new file mode 100644 index 000000000000..2053b5210688 --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/IHostBuilder.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace MockHostTypes +{ + public interface IHostBuilder + { + IHost Build(); + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/IWebHost.cs b/src/Shared/test/testassets/MockHostTypes/IWebHost.cs new file mode 100644 index 000000000000..f93bba440c94 --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/IWebHost.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace MockHostTypes +{ + public interface IWebHost + { + IServiceProvider Services { get; } + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/IWebHostBuilder.cs b/src/Shared/test/testassets/MockHostTypes/IWebHostBuilder.cs new file mode 100644 index 000000000000..1159ae103ee6 --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/IWebHostBuilder.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace MockHostTypes +{ + public interface IWebHostBuilder + { + IWebHost Build(); + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/MockHostTypes.csproj b/src/Shared/test/testassets/MockHostTypes/MockHostTypes.csproj new file mode 100644 index 000000000000..57b6e1ae58fd --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/MockHostTypes.csproj @@ -0,0 +1,7 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + diff --git a/src/Shared/test/testassets/MockHostTypes/ServiceProvider.cs b/src/Shared/test/testassets/MockHostTypes/ServiceProvider.cs new file mode 100644 index 000000000000..7b550c9d32d3 --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/ServiceProvider.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace MockHostTypes +{ + public class ServiceProvider : IServiceProvider + { + public object GetService(Type serviceType) => null; + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/WebHost.cs b/src/Shared/test/testassets/MockHostTypes/WebHost.cs new file mode 100644 index 000000000000..77d3d58ca4bc --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/WebHost.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace MockHostTypes +{ + public class WebHost : IWebHost + { + public IServiceProvider Services { get; } = new ServiceProvider(); + } +} diff --git a/src/Shared/test/testassets/MockHostTypes/WebHostBuilder.cs b/src/Shared/test/testassets/MockHostTypes/WebHostBuilder.cs new file mode 100644 index 000000000000..216fb28d60e4 --- /dev/null +++ b/src/Shared/test/testassets/MockHostTypes/WebHostBuilder.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace MockHostTypes +{ + public class WebHostBuilder : IWebHostBuilder + { + public IWebHost Build() => new WebHost(); + } +} diff --git a/src/Testing/Directory.Build.props b/src/Testing/Directory.Build.props new file mode 100644 index 000000000000..b49dba01d926 --- /dev/null +++ b/src/Testing/Directory.Build.props @@ -0,0 +1,9 @@ + + + + + + true + false + + diff --git a/src/Testing/ref/Microsoft.AspNetCore.Testing.csproj b/src/Testing/ref/Microsoft.AspNetCore.Testing.csproj new file mode 100644 index 000000000000..4606a5411106 --- /dev/null +++ b/src/Testing/ref/Microsoft.AspNetCore.Testing.csproj @@ -0,0 +1,32 @@ + + + + netstandard2.0;net46 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Testing/ref/Microsoft.AspNetCore.Testing.net46.cs b/src/Testing/ref/Microsoft.AspNetCore.Testing.net46.cs new file mode 100644 index 000000000000..17a922a6c7b7 --- /dev/null +++ b/src/Testing/ref/Microsoft.AspNetCore.Testing.net46.cs @@ -0,0 +1,374 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Testing +{ + public partial class AspNetTestAssemblyRunner : Xunit.Sdk.XunitTestAssemblyRunner + { + public AspNetTestAssemblyRunner(Xunit.Abstractions.ITestAssembly testAssembly, System.Collections.Generic.IEnumerable testCases, Xunit.Abstractions.IMessageSink diagnosticMessageSink, Xunit.Abstractions.IMessageSink executionMessageSink, Xunit.Abstractions.ITestFrameworkExecutionOptions executionOptions) : base (default(Xunit.Abstractions.ITestAssembly), default(System.Collections.Generic.IEnumerable), default(Xunit.Abstractions.IMessageSink), default(Xunit.Abstractions.IMessageSink), default(Xunit.Abstractions.ITestFrameworkExecutionOptions)) { } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override System.Threading.Tasks.Task AfterTestAssemblyStartingAsync() { throw null; } + protected override System.Threading.Tasks.Task BeforeTestAssemblyFinishedAsync() { throw null; } + protected override System.Threading.Tasks.Task RunTestCollectionAsync(Xunit.Sdk.IMessageBus messageBus, Xunit.Abstractions.ITestCollection testCollection, System.Collections.Generic.IEnumerable testCases, System.Threading.CancellationTokenSource cancellationTokenSource) { throw null; } + } + public partial class AspNetTestCollectionRunner : Xunit.Sdk.XunitTestCollectionRunner + { + public AspNetTestCollectionRunner(System.Collections.Generic.Dictionary assemblyFixtureMappings, Xunit.Abstractions.ITestCollection testCollection, System.Collections.Generic.IEnumerable testCases, Xunit.Abstractions.IMessageSink diagnosticMessageSink, Xunit.Sdk.IMessageBus messageBus, Xunit.Sdk.ITestCaseOrderer testCaseOrderer, Xunit.Sdk.ExceptionAggregator aggregator, System.Threading.CancellationTokenSource cancellationTokenSource) : base (default(Xunit.Abstractions.ITestCollection), default(System.Collections.Generic.IEnumerable), default(Xunit.Abstractions.IMessageSink), default(Xunit.Sdk.IMessageBus), default(Xunit.Sdk.ITestCaseOrderer), default(Xunit.Sdk.ExceptionAggregator), default(System.Threading.CancellationTokenSource)) { } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override System.Threading.Tasks.Task AfterTestCollectionStartingAsync() { throw null; } + protected override System.Threading.Tasks.Task BeforeTestCollectionFinishedAsync() { throw null; } + protected override System.Threading.Tasks.Task RunTestClassAsync(Xunit.Abstractions.ITestClass testClass, Xunit.Abstractions.IReflectionTypeInfo @class, System.Collections.Generic.IEnumerable testCases) { throw null; } + } + public partial class AspNetTestFramework : Xunit.Sdk.XunitTestFramework + { + public AspNetTestFramework(Xunit.Abstractions.IMessageSink messageSink) : base (default(Xunit.Abstractions.IMessageSink)) { } + protected override Xunit.Abstractions.ITestFrameworkExecutor CreateExecutor(System.Reflection.AssemblyName assemblyName) { throw null; } + } + public partial class AspNetTestFrameworkExecutor : Xunit.Sdk.XunitTestFrameworkExecutor + { + public AspNetTestFrameworkExecutor(System.Reflection.AssemblyName assemblyName, Xunit.Abstractions.ISourceInformationProvider sourceInformationProvider, Xunit.Abstractions.IMessageSink diagnosticMessageSink) : base (default(System.Reflection.AssemblyName), default(Xunit.Abstractions.ISourceInformationProvider), default(Xunit.Abstractions.IMessageSink)) { } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override void RunTestCases(System.Collections.Generic.IEnumerable testCases, Xunit.Abstractions.IMessageSink executionMessageSink, Xunit.Abstractions.ITestFrameworkExecutionOptions executionOptions) { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=true)] + public partial class AssemblyFixtureAttribute : System.Attribute + { + public AssemblyFixtureAttribute(System.Type fixtureType) { } + public System.Type FixtureType { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=false)] + [Xunit.Sdk.XunitTestCaseDiscovererAttribute("Microsoft.AspNetCore.Testing.ConditionalFactDiscoverer", "Microsoft.AspNetCore.Testing")] + public partial class ConditionalFactAttribute : Xunit.FactAttribute + { + public ConditionalFactAttribute() { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=false)] + [Xunit.Sdk.XunitTestCaseDiscovererAttribute("Microsoft.AspNetCore.Testing.ConditionalTheoryDiscoverer", "Microsoft.AspNetCore.Testing")] + public partial class ConditionalTheoryAttribute : Xunit.TheoryAttribute + { + public ConditionalTheoryAttribute() { } + } + public partial class CultureReplacer : System.IDisposable + { + public CultureReplacer(System.Globalization.CultureInfo culture, System.Globalization.CultureInfo uiCulture) { } + public CultureReplacer(string culture = "en-GB", string uiCulture = "en-US") { } + public static System.Globalization.CultureInfo DefaultCulture { get { throw null; } } + public static string DefaultCultureName { get { throw null; } } + public static string DefaultUICultureName { get { throw null; } } + public void Dispose() { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited=true, AllowMultiple=false)] + public sealed partial class DockerOnlyAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public DockerOnlyAttribute() { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class EnvironmentVariableSkipConditionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public EnvironmentVariableSkipConditionAttribute(string variableName, params string[] values) { } + public bool IsMet { get { throw null; } } + public bool RunOnMatch { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string SkipReason { get { throw null; } } + } + public static partial class ExceptionAssert + { + public static System.ArgumentException ThrowsArgument(System.Action testCode, string paramName, string exceptionMessage) { throw null; } + public static System.Threading.Tasks.Task ThrowsArgumentAsync(System.Func testCode, string paramName, string exceptionMessage) { throw null; } + public static System.ArgumentNullException ThrowsArgumentNull(System.Action testCode, string paramName) { throw null; } + public static System.ArgumentException ThrowsArgumentNullOrEmpty(System.Action testCode, string paramName) { throw null; } + public static System.Threading.Tasks.Task ThrowsArgumentNullOrEmptyAsync(System.Func testCode, string paramName) { throw null; } + public static System.ArgumentException ThrowsArgumentNullOrEmptyString(System.Action testCode, string paramName) { throw null; } + public static System.Threading.Tasks.Task ThrowsArgumentNullOrEmptyStringAsync(System.Func testCode, string paramName) { throw null; } + public static System.ArgumentOutOfRangeException ThrowsArgumentOutOfRange(System.Action testCode, string paramName, string exceptionMessage, object actualValue = null) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task ThrowsAsync(System.Func testCode, string exceptionMessage) where TException : System.Exception { throw null; } + public static TException Throws(System.Action testCode) where TException : System.Exception { throw null; } + public static TException Throws(System.Action testCode, string exceptionMessage) where TException : System.Exception { throw null; } + public static TException Throws(System.Func testCode, string exceptionMessage) where TException : System.Exception { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method)] + [Xunit.Sdk.TraitDiscovererAttribute("Microsoft.AspNetCore.Testing.FlakyTraitDiscoverer", "Microsoft.AspNetCore.Testing")] + public sealed partial class FlakyAttribute : System.Attribute, Xunit.Sdk.ITraitAttribute + { + public FlakyAttribute(string gitHubIssueUrl, string firstFilter, params string[] additionalFilters) { } + public System.Collections.Generic.IReadOnlyList Filters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string GitHubIssueUrl { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public static partial class FlakyOn + { + public const string All = "All"; + public static partial class AzP + { + public const string All = "AzP:All"; + public const string Linux = "AzP:OS:Linux"; + public const string macOS = "AzP:OS:Darwin"; + public const string Windows = "AzP:OS:Windows_NT"; + } + public static partial class Helix + { + public const string All = "Helix:Queue:All"; + public const string Centos7Amd64 = "Helix:Queue:Centos.7.Amd64.Open"; + public const string Debian8Amd64 = "Helix:Queue:Debian.8.Amd64.Open"; + public const string Debian9Amd64 = "Helix:Queue:Debian.9.Amd64.Open"; + public const string Fedora27Amd64 = "Helix:Queue:Fedora.27.Amd64.Open"; + public const string Fedora28Amd64 = "Helix:Queue:Fedora.28.Amd64.Open"; + public const string macOS1012Amd64 = "Helix:Queue:OSX.1012.Amd64.Open"; + public const string Redhat7Amd64 = "Helix:Queue:Redhat.7.Amd64.Open"; + public const string Ubuntu1604Amd64 = "Helix:Queue:Ubuntu.1604.Amd64.Open"; + public const string Ubuntu1810Amd64 = "Helix:Queue:Ubuntu.1810.Amd64.Open"; + public const string Windows10Amd64 = "Helix:Queue:Windows.10.Amd64.ClientRS4.VS2017.Open"; + } + } + public partial class FlakyTraitDiscoverer : Xunit.Sdk.ITraitDiscoverer + { + public FlakyTraitDiscoverer() { } + public System.Collections.Generic.IEnumerable> GetTraits(Xunit.Abstractions.IAttributeInfo traitAttribute) { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=false)] + public partial class FrameworkSkipConditionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public FrameworkSkipConditionAttribute(Microsoft.AspNetCore.Testing.RuntimeFrameworks excludedFrameworks) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + public static partial class HelixQueues + { + public const string Centos7Amd64 = "Centos.7.Amd64.Open"; + public const string Debian8Amd64 = "Debian.8.Amd64.Open"; + public const string Debian9Amd64 = "Debian.9.Amd64.Open"; + public const string Fedora27Amd64 = "Fedora.27.Amd64.Open"; + public const string Fedora28Amd64 = "Fedora.28.Amd64.Open"; + public const string macOS1012Amd64 = "OSX.1012.Amd64.Open"; + public const string Redhat7Amd64 = "Redhat.7.Amd64.Open"; + public const string Ubuntu1604Amd64 = "Ubuntu.1604.Amd64.Open"; + public const string Ubuntu1810Amd64 = "Ubuntu.1810.Amd64.Open"; + public const string Windows10Amd64 = "Windows.10.Amd64.ClientRS4.VS2017.Open"; + } + public static partial class HttpClientSlim + { + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task GetSocket(System.Uri requestUri) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task GetStringAsync(string requestUri, bool validateCertificate = true) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task GetStringAsync(System.Uri requestUri, bool validateCertificate = true) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task PostAsync(string requestUri, System.Net.Http.HttpContent content, bool validateCertificate = true) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task PostAsync(System.Uri requestUri, System.Net.Http.HttpContent content, bool validateCertificate = true) { throw null; } + } + public partial interface ITestCondition + { + bool IsMet { get; } + string SkipReason { get; } + } + public partial interface ITestMethodLifecycle + { + System.Threading.Tasks.Task OnTestEndAsync(Microsoft.AspNetCore.Testing.TestContext context, System.Exception exception, System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task OnTestStartAsync(Microsoft.AspNetCore.Testing.TestContext context, System.Threading.CancellationToken cancellationToken); + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class MaximumOSVersionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public MaximumOSVersionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem, string maxVersion) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class MinimumOSVersionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public MinimumOSVersionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem, string minVersion) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.FlagsAttribute] + public enum OperatingSystems + { + Linux = 1, + MacOSX = 2, + Windows = 4, + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class OSSkipConditionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public OSSkipConditionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem) { } + [System.ObsoleteAttribute("Use the Minimum/MaximumOSVersionAttribute for version checks.", true)] + public OSSkipConditionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem, params string[] versions) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=false)] + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] + public partial class RepeatAttribute : System.Attribute + { + public RepeatAttribute(int runCount = 10) { } + public int RunCount { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public partial class RepeatContext + { + public RepeatContext(int limit) { } + public static Microsoft.AspNetCore.Testing.RepeatContext Current { get { throw null; } } + public int CurrentIteration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public int Limit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method)] + public partial class ReplaceCultureAttribute : Xunit.Sdk.BeforeAfterTestAttribute + { + public ReplaceCultureAttribute() { } + public ReplaceCultureAttribute(string currentCulture, string currentUICulture) { } + public System.Globalization.CultureInfo Culture { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Globalization.CultureInfo UICulture { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public override void After(System.Reflection.MethodInfo methodUnderTest) { } + public override void Before(System.Reflection.MethodInfo methodUnderTest) { } + } + [System.FlagsAttribute] + public enum RuntimeFrameworks + { + None = 0, + Mono = 1, + CLR = 2, + CoreCLR = 4, + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class, AllowMultiple=false)] + public partial class ShortClassNameAttribute : System.Attribute + { + public ShortClassNameAttribute() { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=false)] + public partial class SkipOnCIAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public SkipOnCIAttribute(string issueUrl = "") { } + public bool IsMet { get { throw null; } } + public string IssueUrl { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string SkipReason { get { throw null; } } + public static string GetIfOnAzdo() { throw null; } + public static string GetTargetHelixQueue() { throw null; } + public static bool OnAzdo() { throw null; } + public static bool OnCI() { throw null; } + public static bool OnHelix() { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=false)] + public partial class SkipOnHelixAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public SkipOnHelixAttribute(string issueUrl) { } + public bool IsMet { get { throw null; } } + public string IssueUrl { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Queues { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string SkipReason { get { throw null; } } + public static string GetTargetHelixQueue() { throw null; } + public static bool OnHelix() { throw null; } + } + public partial class SkippedTestCase : Xunit.Sdk.XunitTestCase + { + [System.ObsoleteAttribute("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippedTestCase() { } + public SkippedTestCase(string skipReason, Xunit.Abstractions.IMessageSink diagnosticMessageSink, Xunit.Sdk.TestMethodDisplay defaultMethodDisplay, Xunit.Sdk.TestMethodDisplayOptions defaultMethodDisplayOptions, Xunit.Abstractions.ITestMethod testMethod, object[] testMethodArguments = null) { } + public override void Deserialize(Xunit.Abstractions.IXunitSerializationInfo data) { } + protected override string GetSkipReason(Xunit.Abstractions.IAttributeInfo factAttribute) { throw null; } + public override void Serialize(Xunit.Abstractions.IXunitSerializationInfo data) { } + } + public static partial class TaskExtensions + { + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task TimeoutAfter(this System.Threading.Tasks.Task task, System.TimeSpan timeout, [System.Runtime.CompilerServices.CallerFilePathAttribute] string filePath = null, [System.Runtime.CompilerServices.CallerLineNumberAttribute] int lineNumber = 0) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task TimeoutAfter(this System.Threading.Tasks.Task task, System.TimeSpan timeout, [System.Runtime.CompilerServices.CallerFilePathAttribute] string filePath = null, [System.Runtime.CompilerServices.CallerLineNumberAttribute] int lineNumber = 0) { throw null; } + } + public sealed partial class TestContext + { + public TestContext(System.Type testClass, object[] constructorArguments, System.Reflection.MethodInfo testMethod, object[] methodArguments, Xunit.Abstractions.ITestOutputHelper output) { } + public object[] ConstructorArguments { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.AspNetCore.Testing.TestFileOutputContext FileOutput { get { throw null; } } + public object[] MethodArguments { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Xunit.Abstractions.ITestOutputHelper Output { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Type TestClass { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Reflection.MethodInfo TestMethod { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public sealed partial class TestFileOutputContext + { + public TestFileOutputContext(Microsoft.AspNetCore.Testing.TestContext parent) { } + public string AssemblyOutputDirectory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TestClassName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TestClassOutputDirectory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TestName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public static string GetAssemblyBaseDirectory(System.Reflection.Assembly assembly, string baseDirectory = null) { throw null; } + public static string GetOutputDirectory(System.Reflection.Assembly assembly) { throw null; } + public static bool GetPreserveExistingLogsInOutput(System.Reflection.Assembly assembly) { throw null; } + public static string GetTestClassName(System.Type type) { throw null; } + public static string GetTestMethodName(System.Reflection.MethodInfo method, object[] arguments) { throw null; } + public string GetUniqueFileName(string prefix, string extension) { throw null; } + public static string RemoveIllegalFileChars(string s) { throw null; } + } + public static partial class TestMethodExtensions + { + public static string EvaluateSkipConditions(this Xunit.Abstractions.ITestMethod testMethod) { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=false, Inherited=true)] + public partial class TestOutputDirectoryAttribute : System.Attribute + { + public TestOutputDirectoryAttribute(string preserveExistingLogsInOutput, string targetFramework, string baseDirectory = null) { } + public string BaseDirectory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public bool PreserveExistingLogsInOutput { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TargetFramework { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.ObsoleteAttribute("This API is obsolete and the pattern its usage encouraged should not be used anymore. See https://github.com/dotnet/extensions/issues/1697 for details.")] + public partial class TestPathUtilities + { + public TestPathUtilities() { } + public static string GetRepoRootDirectory() { throw null; } + public static string GetSolutionRootDirectory(string solution) { throw null; } + } + public static partial class TestPlatformHelper + { + public static bool IsLinux { get { throw null; } } + public static bool IsMac { get { throw null; } } + public static bool IsMono { get { throw null; } } + public static bool IsWindows { get { throw null; } } + } + public static partial class WindowsVersions + { + public const string Win10 = "10.0"; + public const string Win10_19H1 = "10.0.18362"; + public const string Win10_19H2 = "10.0.18363"; + public const string Win10_20H1 = "10.0.19033"; + public const string Win10_RS4 = "10.0.17134"; + public const string Win10_RS5 = "10.0.17763"; + [System.ObsoleteAttribute("Use Win7 instead.", true)] + public const string Win2008R2 = "6.1"; + public const string Win7 = "6.1"; + public const string Win8 = "6.2"; + public const string Win81 = "6.3"; + } +} +namespace Microsoft.AspNetCore.Testing.Tracing +{ + public partial class CollectingEventListener : System.Diagnostics.Tracing.EventListener + { + public CollectingEventListener() { } + public void CollectFrom(System.Diagnostics.Tracing.EventSource eventSource) { } + public void CollectFrom(string eventSourceName) { } + public System.Collections.Generic.IReadOnlyList GetEventsWritten() { throw null; } + protected override void OnEventSourceCreated(System.Diagnostics.Tracing.EventSource eventSource) { } + protected override void OnEventWritten(System.Diagnostics.Tracing.EventWrittenEventArgs eventData) { } + } + public partial class EventAssert + { + public EventAssert(int expectedId, string expectedName, System.Diagnostics.Tracing.EventLevel expectedLevel) { } + public static void Collection(System.Collections.Generic.IEnumerable events, params Microsoft.AspNetCore.Testing.Tracing.EventAssert[] asserts) { } + public static Microsoft.AspNetCore.Testing.Tracing.EventAssert Event(int id, string name, System.Diagnostics.Tracing.EventLevel level) { throw null; } + public Microsoft.AspNetCore.Testing.Tracing.EventAssert Payload(string name, System.Action asserter) { throw null; } + public Microsoft.AspNetCore.Testing.Tracing.EventAssert Payload(string name, object expectedValue) { throw null; } + } + [Xunit.CollectionAttribute("Microsoft.AspNetCore.Testing.Tracing.EventSourceTestCollection")] + public abstract partial class EventSourceTestBase : System.IDisposable + { + public const string CollectionName = "Microsoft.AspNetCore.Testing.Tracing.EventSourceTestCollection"; + public EventSourceTestBase() { } + protected void CollectFrom(System.Diagnostics.Tracing.EventSource eventSource) { } + protected void CollectFrom(string eventSourceName) { } + public void Dispose() { } + protected System.Collections.Generic.IReadOnlyList GetEvents() { throw null; } + } +} diff --git a/src/Testing/ref/Microsoft.AspNetCore.Testing.netstandard2.0.cs b/src/Testing/ref/Microsoft.AspNetCore.Testing.netstandard2.0.cs new file mode 100644 index 000000000000..17a922a6c7b7 --- /dev/null +++ b/src/Testing/ref/Microsoft.AspNetCore.Testing.netstandard2.0.cs @@ -0,0 +1,374 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Testing +{ + public partial class AspNetTestAssemblyRunner : Xunit.Sdk.XunitTestAssemblyRunner + { + public AspNetTestAssemblyRunner(Xunit.Abstractions.ITestAssembly testAssembly, System.Collections.Generic.IEnumerable testCases, Xunit.Abstractions.IMessageSink diagnosticMessageSink, Xunit.Abstractions.IMessageSink executionMessageSink, Xunit.Abstractions.ITestFrameworkExecutionOptions executionOptions) : base (default(Xunit.Abstractions.ITestAssembly), default(System.Collections.Generic.IEnumerable), default(Xunit.Abstractions.IMessageSink), default(Xunit.Abstractions.IMessageSink), default(Xunit.Abstractions.ITestFrameworkExecutionOptions)) { } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override System.Threading.Tasks.Task AfterTestAssemblyStartingAsync() { throw null; } + protected override System.Threading.Tasks.Task BeforeTestAssemblyFinishedAsync() { throw null; } + protected override System.Threading.Tasks.Task RunTestCollectionAsync(Xunit.Sdk.IMessageBus messageBus, Xunit.Abstractions.ITestCollection testCollection, System.Collections.Generic.IEnumerable testCases, System.Threading.CancellationTokenSource cancellationTokenSource) { throw null; } + } + public partial class AspNetTestCollectionRunner : Xunit.Sdk.XunitTestCollectionRunner + { + public AspNetTestCollectionRunner(System.Collections.Generic.Dictionary assemblyFixtureMappings, Xunit.Abstractions.ITestCollection testCollection, System.Collections.Generic.IEnumerable testCases, Xunit.Abstractions.IMessageSink diagnosticMessageSink, Xunit.Sdk.IMessageBus messageBus, Xunit.Sdk.ITestCaseOrderer testCaseOrderer, Xunit.Sdk.ExceptionAggregator aggregator, System.Threading.CancellationTokenSource cancellationTokenSource) : base (default(Xunit.Abstractions.ITestCollection), default(System.Collections.Generic.IEnumerable), default(Xunit.Abstractions.IMessageSink), default(Xunit.Sdk.IMessageBus), default(Xunit.Sdk.ITestCaseOrderer), default(Xunit.Sdk.ExceptionAggregator), default(System.Threading.CancellationTokenSource)) { } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override System.Threading.Tasks.Task AfterTestCollectionStartingAsync() { throw null; } + protected override System.Threading.Tasks.Task BeforeTestCollectionFinishedAsync() { throw null; } + protected override System.Threading.Tasks.Task RunTestClassAsync(Xunit.Abstractions.ITestClass testClass, Xunit.Abstractions.IReflectionTypeInfo @class, System.Collections.Generic.IEnumerable testCases) { throw null; } + } + public partial class AspNetTestFramework : Xunit.Sdk.XunitTestFramework + { + public AspNetTestFramework(Xunit.Abstractions.IMessageSink messageSink) : base (default(Xunit.Abstractions.IMessageSink)) { } + protected override Xunit.Abstractions.ITestFrameworkExecutor CreateExecutor(System.Reflection.AssemblyName assemblyName) { throw null; } + } + public partial class AspNetTestFrameworkExecutor : Xunit.Sdk.XunitTestFrameworkExecutor + { + public AspNetTestFrameworkExecutor(System.Reflection.AssemblyName assemblyName, Xunit.Abstractions.ISourceInformationProvider sourceInformationProvider, Xunit.Abstractions.IMessageSink diagnosticMessageSink) : base (default(System.Reflection.AssemblyName), default(Xunit.Abstractions.ISourceInformationProvider), default(Xunit.Abstractions.IMessageSink)) { } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override void RunTestCases(System.Collections.Generic.IEnumerable testCases, Xunit.Abstractions.IMessageSink executionMessageSink, Xunit.Abstractions.ITestFrameworkExecutionOptions executionOptions) { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=true)] + public partial class AssemblyFixtureAttribute : System.Attribute + { + public AssemblyFixtureAttribute(System.Type fixtureType) { } + public System.Type FixtureType { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=false)] + [Xunit.Sdk.XunitTestCaseDiscovererAttribute("Microsoft.AspNetCore.Testing.ConditionalFactDiscoverer", "Microsoft.AspNetCore.Testing")] + public partial class ConditionalFactAttribute : Xunit.FactAttribute + { + public ConditionalFactAttribute() { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=false)] + [Xunit.Sdk.XunitTestCaseDiscovererAttribute("Microsoft.AspNetCore.Testing.ConditionalTheoryDiscoverer", "Microsoft.AspNetCore.Testing")] + public partial class ConditionalTheoryAttribute : Xunit.TheoryAttribute + { + public ConditionalTheoryAttribute() { } + } + public partial class CultureReplacer : System.IDisposable + { + public CultureReplacer(System.Globalization.CultureInfo culture, System.Globalization.CultureInfo uiCulture) { } + public CultureReplacer(string culture = "en-GB", string uiCulture = "en-US") { } + public static System.Globalization.CultureInfo DefaultCulture { get { throw null; } } + public static string DefaultCultureName { get { throw null; } } + public static string DefaultUICultureName { get { throw null; } } + public void Dispose() { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited=true, AllowMultiple=false)] + public sealed partial class DockerOnlyAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public DockerOnlyAttribute() { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class EnvironmentVariableSkipConditionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public EnvironmentVariableSkipConditionAttribute(string variableName, params string[] values) { } + public bool IsMet { get { throw null; } } + public bool RunOnMatch { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string SkipReason { get { throw null; } } + } + public static partial class ExceptionAssert + { + public static System.ArgumentException ThrowsArgument(System.Action testCode, string paramName, string exceptionMessage) { throw null; } + public static System.Threading.Tasks.Task ThrowsArgumentAsync(System.Func testCode, string paramName, string exceptionMessage) { throw null; } + public static System.ArgumentNullException ThrowsArgumentNull(System.Action testCode, string paramName) { throw null; } + public static System.ArgumentException ThrowsArgumentNullOrEmpty(System.Action testCode, string paramName) { throw null; } + public static System.Threading.Tasks.Task ThrowsArgumentNullOrEmptyAsync(System.Func testCode, string paramName) { throw null; } + public static System.ArgumentException ThrowsArgumentNullOrEmptyString(System.Action testCode, string paramName) { throw null; } + public static System.Threading.Tasks.Task ThrowsArgumentNullOrEmptyStringAsync(System.Func testCode, string paramName) { throw null; } + public static System.ArgumentOutOfRangeException ThrowsArgumentOutOfRange(System.Action testCode, string paramName, string exceptionMessage, object actualValue = null) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task ThrowsAsync(System.Func testCode, string exceptionMessage) where TException : System.Exception { throw null; } + public static TException Throws(System.Action testCode) where TException : System.Exception { throw null; } + public static TException Throws(System.Action testCode, string exceptionMessage) where TException : System.Exception { throw null; } + public static TException Throws(System.Func testCode, string exceptionMessage) where TException : System.Exception { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method)] + [Xunit.Sdk.TraitDiscovererAttribute("Microsoft.AspNetCore.Testing.FlakyTraitDiscoverer", "Microsoft.AspNetCore.Testing")] + public sealed partial class FlakyAttribute : System.Attribute, Xunit.Sdk.ITraitAttribute + { + public FlakyAttribute(string gitHubIssueUrl, string firstFilter, params string[] additionalFilters) { } + public System.Collections.Generic.IReadOnlyList Filters { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string GitHubIssueUrl { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public static partial class FlakyOn + { + public const string All = "All"; + public static partial class AzP + { + public const string All = "AzP:All"; + public const string Linux = "AzP:OS:Linux"; + public const string macOS = "AzP:OS:Darwin"; + public const string Windows = "AzP:OS:Windows_NT"; + } + public static partial class Helix + { + public const string All = "Helix:Queue:All"; + public const string Centos7Amd64 = "Helix:Queue:Centos.7.Amd64.Open"; + public const string Debian8Amd64 = "Helix:Queue:Debian.8.Amd64.Open"; + public const string Debian9Amd64 = "Helix:Queue:Debian.9.Amd64.Open"; + public const string Fedora27Amd64 = "Helix:Queue:Fedora.27.Amd64.Open"; + public const string Fedora28Amd64 = "Helix:Queue:Fedora.28.Amd64.Open"; + public const string macOS1012Amd64 = "Helix:Queue:OSX.1012.Amd64.Open"; + public const string Redhat7Amd64 = "Helix:Queue:Redhat.7.Amd64.Open"; + public const string Ubuntu1604Amd64 = "Helix:Queue:Ubuntu.1604.Amd64.Open"; + public const string Ubuntu1810Amd64 = "Helix:Queue:Ubuntu.1810.Amd64.Open"; + public const string Windows10Amd64 = "Helix:Queue:Windows.10.Amd64.ClientRS4.VS2017.Open"; + } + } + public partial class FlakyTraitDiscoverer : Xunit.Sdk.ITraitDiscoverer + { + public FlakyTraitDiscoverer() { } + public System.Collections.Generic.IEnumerable> GetTraits(Xunit.Abstractions.IAttributeInfo traitAttribute) { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method, AllowMultiple=false)] + public partial class FrameworkSkipConditionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public FrameworkSkipConditionAttribute(Microsoft.AspNetCore.Testing.RuntimeFrameworks excludedFrameworks) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + public static partial class HelixQueues + { + public const string Centos7Amd64 = "Centos.7.Amd64.Open"; + public const string Debian8Amd64 = "Debian.8.Amd64.Open"; + public const string Debian9Amd64 = "Debian.9.Amd64.Open"; + public const string Fedora27Amd64 = "Fedora.27.Amd64.Open"; + public const string Fedora28Amd64 = "Fedora.28.Amd64.Open"; + public const string macOS1012Amd64 = "OSX.1012.Amd64.Open"; + public const string Redhat7Amd64 = "Redhat.7.Amd64.Open"; + public const string Ubuntu1604Amd64 = "Ubuntu.1604.Amd64.Open"; + public const string Ubuntu1810Amd64 = "Ubuntu.1810.Amd64.Open"; + public const string Windows10Amd64 = "Windows.10.Amd64.ClientRS4.VS2017.Open"; + } + public static partial class HttpClientSlim + { + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task GetSocket(System.Uri requestUri) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task GetStringAsync(string requestUri, bool validateCertificate = true) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task GetStringAsync(System.Uri requestUri, bool validateCertificate = true) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task PostAsync(string requestUri, System.Net.Http.HttpContent content, bool validateCertificate = true) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task PostAsync(System.Uri requestUri, System.Net.Http.HttpContent content, bool validateCertificate = true) { throw null; } + } + public partial interface ITestCondition + { + bool IsMet { get; } + string SkipReason { get; } + } + public partial interface ITestMethodLifecycle + { + System.Threading.Tasks.Task OnTestEndAsync(Microsoft.AspNetCore.Testing.TestContext context, System.Exception exception, System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task OnTestStartAsync(Microsoft.AspNetCore.Testing.TestContext context, System.Threading.CancellationToken cancellationToken); + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class MaximumOSVersionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public MaximumOSVersionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem, string maxVersion) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class MinimumOSVersionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public MinimumOSVersionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem, string minVersion) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.FlagsAttribute] + public enum OperatingSystems + { + Linux = 1, + MacOSX = 2, + Windows = 4, + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] + public partial class OSSkipConditionAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public OSSkipConditionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem) { } + [System.ObsoleteAttribute("Use the Minimum/MaximumOSVersionAttribute for version checks.", true)] + public OSSkipConditionAttribute(Microsoft.AspNetCore.Testing.OperatingSystems operatingSystem, params string[] versions) { } + public bool IsMet { get { throw null; } } + public string SkipReason { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=false)] + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)] + public partial class RepeatAttribute : System.Attribute + { + public RepeatAttribute(int runCount = 10) { } + public int RunCount { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public partial class RepeatContext + { + public RepeatContext(int limit) { } + public static Microsoft.AspNetCore.Testing.RepeatContext Current { get { throw null; } } + public int CurrentIteration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public int Limit { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Method)] + public partial class ReplaceCultureAttribute : Xunit.Sdk.BeforeAfterTestAttribute + { + public ReplaceCultureAttribute() { } + public ReplaceCultureAttribute(string currentCulture, string currentUICulture) { } + public System.Globalization.CultureInfo Culture { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Globalization.CultureInfo UICulture { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public override void After(System.Reflection.MethodInfo methodUnderTest) { } + public override void Before(System.Reflection.MethodInfo methodUnderTest) { } + } + [System.FlagsAttribute] + public enum RuntimeFrameworks + { + None = 0, + Mono = 1, + CLR = 2, + CoreCLR = 4, + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly | System.AttributeTargets.Class, AllowMultiple=false)] + public partial class ShortClassNameAttribute : System.Attribute + { + public ShortClassNameAttribute() { } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=false)] + public partial class SkipOnCIAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public SkipOnCIAttribute(string issueUrl = "") { } + public bool IsMet { get { throw null; } } + public string IssueUrl { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string SkipReason { get { throw null; } } + public static string GetIfOnAzdo() { throw null; } + public static string GetTargetHelixQueue() { throw null; } + public static bool OnAzdo() { throw null; } + public static bool OnCI() { throw null; } + public static bool OnHelix() { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=false)] + public partial class SkipOnHelixAttribute : System.Attribute, Microsoft.AspNetCore.Testing.ITestCondition + { + public SkipOnHelixAttribute(string issueUrl) { } + public bool IsMet { get { throw null; } } + public string IssueUrl { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string Queues { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public string SkipReason { get { throw null; } } + public static string GetTargetHelixQueue() { throw null; } + public static bool OnHelix() { throw null; } + } + public partial class SkippedTestCase : Xunit.Sdk.XunitTestCase + { + [System.ObsoleteAttribute("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippedTestCase() { } + public SkippedTestCase(string skipReason, Xunit.Abstractions.IMessageSink diagnosticMessageSink, Xunit.Sdk.TestMethodDisplay defaultMethodDisplay, Xunit.Sdk.TestMethodDisplayOptions defaultMethodDisplayOptions, Xunit.Abstractions.ITestMethod testMethod, object[] testMethodArguments = null) { } + public override void Deserialize(Xunit.Abstractions.IXunitSerializationInfo data) { } + protected override string GetSkipReason(Xunit.Abstractions.IAttributeInfo factAttribute) { throw null; } + public override void Serialize(Xunit.Abstractions.IXunitSerializationInfo data) { } + } + public static partial class TaskExtensions + { + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task TimeoutAfter(this System.Threading.Tasks.Task task, System.TimeSpan timeout, [System.Runtime.CompilerServices.CallerFilePathAttribute] string filePath = null, [System.Runtime.CompilerServices.CallerLineNumberAttribute] int lineNumber = 0) { throw null; } + [System.Diagnostics.DebuggerStepThroughAttribute] + public static System.Threading.Tasks.Task TimeoutAfter(this System.Threading.Tasks.Task task, System.TimeSpan timeout, [System.Runtime.CompilerServices.CallerFilePathAttribute] string filePath = null, [System.Runtime.CompilerServices.CallerLineNumberAttribute] int lineNumber = 0) { throw null; } + } + public sealed partial class TestContext + { + public TestContext(System.Type testClass, object[] constructorArguments, System.Reflection.MethodInfo testMethod, object[] methodArguments, Xunit.Abstractions.ITestOutputHelper output) { } + public object[] ConstructorArguments { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Microsoft.AspNetCore.Testing.TestFileOutputContext FileOutput { get { throw null; } } + public object[] MethodArguments { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public Xunit.Abstractions.ITestOutputHelper Output { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Type TestClass { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public System.Reflection.MethodInfo TestMethod { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + public sealed partial class TestFileOutputContext + { + public TestFileOutputContext(Microsoft.AspNetCore.Testing.TestContext parent) { } + public string AssemblyOutputDirectory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TestClassName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TestClassOutputDirectory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TestName { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public static string GetAssemblyBaseDirectory(System.Reflection.Assembly assembly, string baseDirectory = null) { throw null; } + public static string GetOutputDirectory(System.Reflection.Assembly assembly) { throw null; } + public static bool GetPreserveExistingLogsInOutput(System.Reflection.Assembly assembly) { throw null; } + public static string GetTestClassName(System.Type type) { throw null; } + public static string GetTestMethodName(System.Reflection.MethodInfo method, object[] arguments) { throw null; } + public string GetUniqueFileName(string prefix, string extension) { throw null; } + public static string RemoveIllegalFileChars(string s) { throw null; } + } + public static partial class TestMethodExtensions + { + public static string EvaluateSkipConditions(this Xunit.Abstractions.ITestMethod testMethod) { throw null; } + } + [System.AttributeUsageAttribute(System.AttributeTargets.Assembly, AllowMultiple=false, Inherited=true)] + public partial class TestOutputDirectoryAttribute : System.Attribute + { + public TestOutputDirectoryAttribute(string preserveExistingLogsInOutput, string targetFramework, string baseDirectory = null) { } + public string BaseDirectory { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public bool PreserveExistingLogsInOutput { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + public string TargetFramework { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } } + } + [System.ObsoleteAttribute("This API is obsolete and the pattern its usage encouraged should not be used anymore. See https://github.com/dotnet/extensions/issues/1697 for details.")] + public partial class TestPathUtilities + { + public TestPathUtilities() { } + public static string GetRepoRootDirectory() { throw null; } + public static string GetSolutionRootDirectory(string solution) { throw null; } + } + public static partial class TestPlatformHelper + { + public static bool IsLinux { get { throw null; } } + public static bool IsMac { get { throw null; } } + public static bool IsMono { get { throw null; } } + public static bool IsWindows { get { throw null; } } + } + public static partial class WindowsVersions + { + public const string Win10 = "10.0"; + public const string Win10_19H1 = "10.0.18362"; + public const string Win10_19H2 = "10.0.18363"; + public const string Win10_20H1 = "10.0.19033"; + public const string Win10_RS4 = "10.0.17134"; + public const string Win10_RS5 = "10.0.17763"; + [System.ObsoleteAttribute("Use Win7 instead.", true)] + public const string Win2008R2 = "6.1"; + public const string Win7 = "6.1"; + public const string Win8 = "6.2"; + public const string Win81 = "6.3"; + } +} +namespace Microsoft.AspNetCore.Testing.Tracing +{ + public partial class CollectingEventListener : System.Diagnostics.Tracing.EventListener + { + public CollectingEventListener() { } + public void CollectFrom(System.Diagnostics.Tracing.EventSource eventSource) { } + public void CollectFrom(string eventSourceName) { } + public System.Collections.Generic.IReadOnlyList GetEventsWritten() { throw null; } + protected override void OnEventSourceCreated(System.Diagnostics.Tracing.EventSource eventSource) { } + protected override void OnEventWritten(System.Diagnostics.Tracing.EventWrittenEventArgs eventData) { } + } + public partial class EventAssert + { + public EventAssert(int expectedId, string expectedName, System.Diagnostics.Tracing.EventLevel expectedLevel) { } + public static void Collection(System.Collections.Generic.IEnumerable events, params Microsoft.AspNetCore.Testing.Tracing.EventAssert[] asserts) { } + public static Microsoft.AspNetCore.Testing.Tracing.EventAssert Event(int id, string name, System.Diagnostics.Tracing.EventLevel level) { throw null; } + public Microsoft.AspNetCore.Testing.Tracing.EventAssert Payload(string name, System.Action asserter) { throw null; } + public Microsoft.AspNetCore.Testing.Tracing.EventAssert Payload(string name, object expectedValue) { throw null; } + } + [Xunit.CollectionAttribute("Microsoft.AspNetCore.Testing.Tracing.EventSourceTestCollection")] + public abstract partial class EventSourceTestBase : System.IDisposable + { + public const string CollectionName = "Microsoft.AspNetCore.Testing.Tracing.EventSourceTestCollection"; + public EventSourceTestBase() { } + protected void CollectFrom(System.Diagnostics.Tracing.EventSource eventSource) { } + protected void CollectFrom(string eventSourceName) { } + public void Dispose() { } + protected System.Collections.Generic.IReadOnlyList GetEvents() { throw null; } + } +} diff --git a/src/Testing/src/CultureReplacer.cs b/src/Testing/src/CultureReplacer.cs new file mode 100644 index 000000000000..51e35e83544a --- /dev/null +++ b/src/Testing/src/CultureReplacer.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Threading; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class CultureReplacer : IDisposable + { + private const string _defaultCultureName = "en-GB"; + private const string _defaultUICultureName = "en-US"; + private static readonly CultureInfo _defaultCulture = new CultureInfo(_defaultCultureName); + private readonly CultureInfo _originalCulture; + private readonly CultureInfo _originalUICulture; + private readonly long _threadId; + + // Culture => Formatting of dates/times/money/etc, defaults to en-GB because en-US is the same as InvariantCulture + // We want to be able to find issues where the InvariantCulture is used, but a specific culture should be. + // + // UICulture => Language + public CultureReplacer(string culture = _defaultCultureName, string uiCulture = _defaultUICultureName) + : this(new CultureInfo(culture), new CultureInfo(uiCulture)) + { + } + + public CultureReplacer(CultureInfo culture, CultureInfo uiCulture) + { + _originalCulture = CultureInfo.CurrentCulture; + _originalUICulture = CultureInfo.CurrentUICulture; + _threadId = Thread.CurrentThread.ManagedThreadId; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = uiCulture; + } + + /// + /// The name of the culture that is used as the default value for CultureInfo.DefaultThreadCurrentCulture when CultureReplacer is used. + /// + public static string DefaultCultureName + { + get { return _defaultCultureName; } + } + + /// + /// The name of the culture that is used as the default value for [Thread.CurrentThread(NET45)/CultureInfo(K10)].CurrentUICulture when CultureReplacer is used. + /// + public static string DefaultUICultureName + { + get { return _defaultUICultureName; } + } + + /// + /// The culture that is used as the default value for [Thread.CurrentThread(NET45)/CultureInfo(K10)].CurrentCulture when CultureReplacer is used. + /// + public static CultureInfo DefaultCulture + { + get { return _defaultCulture; } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (disposing) + { + Assert.True(Thread.CurrentThread.ManagedThreadId == _threadId, + "The current thread is not the same as the thread invoking the constructor. This should never happen."); + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUICulture; + } + } + } +} diff --git a/src/Testing/src/ExceptionAssertions.cs b/src/Testing/src/ExceptionAssertions.cs new file mode 100644 index 000000000000..244cad5a37db --- /dev/null +++ b/src/Testing/src/ExceptionAssertions.cs @@ -0,0 +1,271 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + // TODO: eventually want: public partial class Assert : Xunit.Assert + public static class ExceptionAssert + { + /// + /// Verifies that an exception of the given type (or optionally a derived type) is thrown. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception that was thrown, when successful + public static TException Throws(Action testCode) + where TException : Exception + { + return VerifyException(RecordException(testCode)); + } + + /// + /// Verifies that an exception of the given type is thrown. + /// Also verifies that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + public static TException Throws(Action testCode, string exceptionMessage) + where TException : Exception + { + var ex = Throws(testCode); + VerifyExceptionMessage(ex, exceptionMessage); + return ex; + } + + /// + /// Verifies that an exception of the given type is thrown. + /// Also verifies that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + public static async Task ThrowsAsync(Func testCode, string exceptionMessage) + where TException : Exception + { + // The 'testCode' Task might execute asynchronously in a different thread making it hard to enforce the thread culture. + // The correct way to verify exception messages in such a scenario would be to run the task synchronously inside of a + // culture enforced block. + var ex = await Assert.ThrowsAsync(testCode); + VerifyExceptionMessage(ex, exceptionMessage); + return ex; + } + + /// + /// Verifies that an exception of the given type is thrown. + /// Also verified that the exception message matches. + /// + /// The type of the exception expected to be thrown + /// A delegate to the code to be tested + /// The exception message to verify + /// The exception that was thrown, when successful + public static TException Throws(Func testCode, string exceptionMessage) + where TException : Exception + { + return Throws(() => { testCode(); }, exceptionMessage); + } + + /// + /// Verifies that the code throws an . + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception message to verify + /// The exception that was thrown, when successful + public static ArgumentException ThrowsArgument(Action testCode, string paramName, string exceptionMessage) + { + return ThrowsArgumentInternal(testCode, paramName, exceptionMessage); + } + + private static TException ThrowsArgumentInternal( + Action testCode, + string paramName, + string exceptionMessage) + where TException : ArgumentException + { + var ex = Throws(testCode); + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + VerifyExceptionMessage(ex, exceptionMessage, partialMatch: true); + return ex; + } + + /// + /// Verifies that the code throws an . + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception message to verify + /// The exception that was thrown, when successful + public static Task ThrowsArgumentAsync(Func testCode, string paramName, string exceptionMessage) + { + return ThrowsArgumentAsyncInternal(testCode, paramName, exceptionMessage); + } + + private static async Task ThrowsArgumentAsyncInternal( + Func testCode, + string paramName, + string exceptionMessage) + where TException : ArgumentException + { + var ex = await Assert.ThrowsAsync(testCode); + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + VerifyExceptionMessage(ex, exceptionMessage, partialMatch: true); + return ex; + } + + /// + /// Verifies that the code throws an . + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static ArgumentNullException ThrowsArgumentNull(Action testCode, string paramName) + { + var ex = Throws(testCode); + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + return ex; + } + + /// + /// Verifies that the code throws an ArgumentException with the expected message that indicates that the value cannot + /// be null or empty. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static ArgumentException ThrowsArgumentNullOrEmpty(Action testCode, string paramName) + { + return ThrowsArgumentInternal(testCode, paramName, "Value cannot be null or empty."); + } + + /// + /// Verifies that the code throws an ArgumentException with the expected message that indicates that the value cannot + /// be null or empty. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static Task ThrowsArgumentNullOrEmptyAsync(Func testCode, string paramName) + { + return ThrowsArgumentAsyncInternal(testCode, paramName, "Value cannot be null or empty."); + } + + /// + /// Verifies that the code throws an ArgumentNullException with the expected message that indicates that the value cannot + /// be null or empty string. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static ArgumentException ThrowsArgumentNullOrEmptyString(Action testCode, string paramName) + { + return ThrowsArgumentInternal(testCode, paramName, "Value cannot be null or an empty string."); + } + + /// + /// Verifies that the code throws an ArgumentNullException with the expected message that indicates that the value cannot + /// be null or empty string. + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception that was thrown, when successful + public static Task ThrowsArgumentNullOrEmptyStringAsync(Func testCode, string paramName) + { + return ThrowsArgumentAsyncInternal(testCode, paramName, "Value cannot be null or an empty string."); + } + + /// + /// Verifies that the code throws an ArgumentOutOfRangeException (or optionally any exception which derives from it). + /// + /// A delegate to the code to be tested + /// The name of the parameter that should throw the exception + /// The exception message to verify + /// The actual value provided + /// The exception that was thrown, when successful + public static ArgumentOutOfRangeException ThrowsArgumentOutOfRange(Action testCode, string paramName, string exceptionMessage, object actualValue = null) + { + var ex = ThrowsArgumentInternal(testCode, paramName, exceptionMessage); + + if (paramName != null) + { + Assert.Equal(paramName, ex.ParamName); + } + + if (actualValue != null) + { + Assert.Equal(actualValue, ex.ActualValue); + } + + return ex; + } + + // We've re-implemented all the xUnit.net Throws code so that we can get this + // updated implementation of RecordException which silently unwraps any instances + // of AggregateException. In addition to unwrapping exceptions, this method ensures + // that tests are executed in with a known set of Culture and UICulture. This prevents + // tests from failing when executed on a non-English machine. + private static Exception RecordException(Action testCode) + { + try + { + using (new CultureReplacer()) + { + testCode(); + } + return null; + } + catch (Exception exception) + { + return UnwrapException(exception); + } + } + + private static Exception UnwrapException(Exception exception) + { + var aggEx = exception as AggregateException; + return aggEx != null ? aggEx.GetBaseException() : exception; + } + + private static TException VerifyException(Exception exception) + { + var tie = exception as TargetInvocationException; + if (tie != null) + { + exception = tie.InnerException; + } + Assert.NotNull(exception); + return Assert.IsAssignableFrom(exception); + } + + private static void VerifyExceptionMessage(Exception exception, string expectedMessage, bool partialMatch = false) + { + if (expectedMessage != null) + { + if (!partialMatch) + { + Assert.Equal(expectedMessage, exception.Message); + } + else + { + Assert.Contains(expectedMessage, exception.Message); + } + } + } + } +} \ No newline at end of file diff --git a/src/Testing/src/FlakyOn.cs b/src/Testing/src/FlakyOn.cs new file mode 100644 index 000000000000..81d929904390 --- /dev/null +++ b/src/Testing/src/FlakyOn.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.AspNetCore.Testing +{ + public static class FlakyOn + { + public const string All = "All"; + + public static class Helix + { + public const string All = QueuePrefix + "All"; + + public const string Fedora28Amd64 = QueuePrefix + HelixQueues.Fedora28Amd64; + public const string Fedora27Amd64 = QueuePrefix + HelixQueues.Fedora27Amd64; + public const string Redhat7Amd64 = QueuePrefix + HelixQueues.Redhat7Amd64; + public const string Debian9Amd64 = QueuePrefix + HelixQueues.Debian9Amd64; + public const string Debian8Amd64 = QueuePrefix + HelixQueues.Debian8Amd64; + public const string Centos7Amd64 = QueuePrefix + HelixQueues.Centos7Amd64; + public const string Ubuntu1604Amd64 = QueuePrefix + HelixQueues.Ubuntu1604Amd64; + public const string Ubuntu1810Amd64 = QueuePrefix + HelixQueues.Ubuntu1810Amd64; + public const string macOS1012Amd64 = QueuePrefix + HelixQueues.macOS1012Amd64; + public const string Windows10Amd64 = QueuePrefix + HelixQueues.Windows10Amd64; + + private const string Prefix = "Helix:"; + private const string QueuePrefix = Prefix + "Queue:"; + } + + public static class AzP + { + public const string All = Prefix + "All"; + public const string Windows = OsPrefix + "Windows_NT"; + public const string macOS = OsPrefix + "Darwin"; + public const string Linux = OsPrefix + "Linux"; + + private const string Prefix = "AzP:"; + private const string OsPrefix = Prefix + "OS:"; + } + } +} diff --git a/src/Testing/src/HelixQueues.cs b/src/Testing/src/HelixQueues.cs new file mode 100644 index 000000000000..ef5e4d1f5a13 --- /dev/null +++ b/src/Testing/src/HelixQueues.cs @@ -0,0 +1,18 @@ +namespace Microsoft.AspNetCore.Testing +{ + public static class HelixQueues + { + public const string Fedora28Amd64 = "Fedora.28." + Amd64Suffix; + public const string Fedora27Amd64 = "Fedora.27." + Amd64Suffix; + public const string Redhat7Amd64 = "Redhat.7." + Amd64Suffix; + public const string Debian9Amd64 = "Debian.9." + Amd64Suffix; + public const string Debian8Amd64 = "Debian.8." + Amd64Suffix; + public const string Centos7Amd64 = "Centos.7." + Amd64Suffix; + public const string Ubuntu1604Amd64 = "Ubuntu.1604." + Amd64Suffix; + public const string Ubuntu1810Amd64 = "Ubuntu.1810." + Amd64Suffix; + public const string macOS1012Amd64 = "OSX.1012." + Amd64Suffix; + public const string Windows10Amd64 = "Windows.10.Amd64.ClientRS4.VS2017.Open"; // Doesn't have the default suffix! + + private const string Amd64Suffix = "Amd64.Open"; + } +} diff --git a/src/Testing/src/HttpClientSlim.cs b/src/Testing/src/HttpClientSlim.cs new file mode 100644 index 000000000000..890ec2d160a7 --- /dev/null +++ b/src/Testing/src/HttpClientSlim.cs @@ -0,0 +1,192 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Authentication; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Lightweight version of HttpClient implemented using Socket and SslStream. + /// + public static class HttpClientSlim + { + public static async Task GetStringAsync(string requestUri, bool validateCertificate = true) + => await GetStringAsync(new Uri(requestUri), validateCertificate).ConfigureAwait(false); + + public static async Task GetStringAsync(Uri requestUri, bool validateCertificate = true) + { + return await RetryRequest(async () => + { + using (var stream = await GetStream(requestUri, validateCertificate).ConfigureAwait(false)) + { + using (var writer = new StreamWriter(stream, Encoding.ASCII, bufferSize: 1024, leaveOpen: true)) + { + await writer.WriteAsync($"GET {requestUri.PathAndQuery} HTTP/1.0\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Host: {GetHost(requestUri)}\r\n").ConfigureAwait(false); + await writer.WriteAsync("\r\n").ConfigureAwait(false); + } + + return await ReadResponse(stream).ConfigureAwait(false); + } + }); + } + + internal static string GetHost(Uri requestUri) + { + var authority = requestUri.Authority; + if (requestUri.HostNameType == UriHostNameType.IPv6) + { + // Make sure there's no % scope id. https://github.com/aspnet/KestrelHttpServer/issues/2637 + var address = IPAddress.Parse(requestUri.Host); + address = new IPAddress(address.GetAddressBytes()); // Drop scope Id. + if (requestUri.IsDefaultPort) + { + authority = $"[{address}]"; + } + else + { + authority = $"[{address}]:{requestUri.Port.ToString(CultureInfo.InvariantCulture)}"; + } + } + return authority; + } + + public static async Task PostAsync(string requestUri, HttpContent content, bool validateCertificate = true) + => await PostAsync(new Uri(requestUri), content, validateCertificate).ConfigureAwait(false); + + public static async Task PostAsync(Uri requestUri, HttpContent content, bool validateCertificate = true) + { + return await RetryRequest(async () => + { + using (var stream = await GetStream(requestUri, validateCertificate)) + { + using (var writer = new StreamWriter(stream, Encoding.ASCII, bufferSize: 1024, leaveOpen: true)) + { + await writer.WriteAsync($"POST {requestUri.PathAndQuery} HTTP/1.0\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Host: {requestUri.Authority}\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Content-Type: {content.Headers.ContentType}\r\n").ConfigureAwait(false); + await writer.WriteAsync($"Content-Length: {content.Headers.ContentLength}\r\n").ConfigureAwait(false); + await writer.WriteAsync("\r\n").ConfigureAwait(false); + } + + await content.CopyToAsync(stream).ConfigureAwait(false); + + return await ReadResponse(stream).ConfigureAwait(false); + } + }); + } + + private static async Task ReadResponse(Stream stream) + { + using (var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: true, + bufferSize: 1024, leaveOpen: true)) + { + var response = await reader.ReadToEndAsync().ConfigureAwait(false); + + var status = GetStatus(response); + new HttpResponseMessage(status).EnsureSuccessStatusCode(); + + var body = response.Substring(response.IndexOf("\r\n\r\n") + 4); + return body; + } + } + + private static async Task RetryRequest(Func> retryBlock) + { + var retryCount = 1; + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + retryCount = 3; + } + + for (var retry = 0; retry < retryCount; retry++) + { + try + { + return await retryBlock().ConfigureAwait(false); + } + catch (InvalidDataException) + { + if (retry == retryCount - 1) + { + throw; + } + } + } + + // This will never be hit. + throw new NotSupportedException(); + } + + private static HttpStatusCode GetStatus(string response) + { + var statusStart = response.IndexOf(' ') + 1; + var statusEnd = response.IndexOf(' ', statusStart) - 1; + var statusLength = statusEnd - statusStart + 1; + + if (statusLength < 1) + { + throw new InvalidDataException($"No StatusCode found in '{response}'"); + } + + return (HttpStatusCode)int.Parse(response.Substring(statusStart, statusLength)); + } + + private static async Task GetStream(Uri requestUri, bool validateCertificate) + { + var socket = await GetSocket(requestUri); + var stream = new NetworkStream(socket, ownsSocket: true); + + if (requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + var sslStream = new SslStream(stream, leaveInnerStreamOpen: false, userCertificateValidationCallback: + validateCertificate ? null : (RemoteCertificateValidationCallback)((a, b, c, d) => true)); + + await sslStream.AuthenticateAsClientAsync(requestUri.Host, clientCertificates: null, + enabledSslProtocols: SslProtocols.Tls11 | SslProtocols.Tls12, + checkCertificateRevocation: validateCertificate).ConfigureAwait(false); + return sslStream; + } + else + { + return stream; + } + } + + public static async Task GetSocket(Uri requestUri) + { + var tcs = new TaskCompletionSource(); + + var socketArgs = new SocketAsyncEventArgs(); + socketArgs.RemoteEndPoint = new DnsEndPoint(requestUri.DnsSafeHost, requestUri.Port); + socketArgs.Completed += (s, e) => tcs.TrySetResult(e.ConnectSocket); + + // Must use static ConnectAsync(), since instance Connect() does not support DNS names on OSX/Linux. + if (Socket.ConnectAsync(SocketType.Stream, ProtocolType.Tcp, socketArgs)) + { + await tcs.Task.ConfigureAwait(false); + } + + var socket = socketArgs.ConnectSocket; + + if (socket == null) + { + throw new SocketException((int)socketArgs.SocketError); + } + else + { + return socket; + } + } + } +} diff --git a/src/Testing/src/ITestMethodLifecycle.cs b/src/Testing/src/ITestMethodLifecycle.cs new file mode 100644 index 000000000000..d22779b6dd03 --- /dev/null +++ b/src/Testing/src/ITestMethodLifecycle.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Defines a lifecycle for attributes or classes that want to know about tests starting + /// or ending. Implement this on a test class, or attribute at the method/class/assembly level. + /// + /// + /// Requires defining as the test framework. + /// + public interface ITestMethodLifecycle + { + Task OnTestStartAsync(TestContext context, CancellationToken cancellationToken); + + Task OnTestEndAsync(TestContext context, Exception exception, CancellationToken cancellationToken); + } +} diff --git a/src/Testing/src/Microsoft.AspNetCore.Testing.csproj b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj new file mode 100644 index 000000000000..448351fee566 --- /dev/null +++ b/src/Testing/src/Microsoft.AspNetCore.Testing.csproj @@ -0,0 +1,51 @@ + + + + Various helpers for writing tests that use ASP.NET Core. + netstandard2.0;net46 + $(NoWarn);CS1591 + true + aspnetcore + + false + true + true + true + + true + + + + + + + + + + + + + + + + + + + + + + + + True + contentFiles\cs\netstandard2.0\ + + + + diff --git a/src/Testing/src/RepeatAttribute.cs b/src/Testing/src/RepeatAttribute.cs new file mode 100644 index 000000000000..7bf307373480 --- /dev/null +++ b/src/Testing/src/RepeatAttribute.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.ComponentModel; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Runs a test multiple times to stress flaky tests that are believed to be fixed. + /// This can be used on an assembly, class, or method name. Requires using the AspNetCore test framework. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false)] + public class RepeatAttribute : Attribute + { + public RepeatAttribute(int runCount = 10) + { + RunCount = runCount; + } + + /// + /// The number of times to run a test. + /// + public int RunCount { get; } + } +} diff --git a/src/Testing/src/RepeatContext.cs b/src/Testing/src/RepeatContext.cs new file mode 100644 index 000000000000..d76a0f177e2a --- /dev/null +++ b/src/Testing/src/RepeatContext.cs @@ -0,0 +1,27 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; + +namespace Microsoft.AspNetCore.Testing +{ + public class RepeatContext + { + private static AsyncLocal _current = new AsyncLocal(); + + public static RepeatContext Current + { + get => _current.Value; + internal set => _current.Value = value; + } + + public RepeatContext(int limit) + { + Limit = limit; + } + + public int Limit { get; } + + public int CurrentIteration { get; set; } + } +} diff --git a/src/Testing/src/ReplaceCulture.cs b/src/Testing/src/ReplaceCulture.cs new file mode 100644 index 000000000000..9580bfd0da7e --- /dev/null +++ b/src/Testing/src/ReplaceCulture.cs @@ -0,0 +1,70 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Globalization; +using System.Reflection; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Replaces the current culture and UI culture for the test. + /// + [AttributeUsage(AttributeTargets.Method)] + public class ReplaceCultureAttribute : BeforeAfterTestAttribute + { + private const string _defaultCultureName = "en-GB"; + private const string _defaultUICultureName = "en-US"; + private CultureInfo _originalCulture; + private CultureInfo _originalUICulture; + + /// + /// Replaces the current culture and UI culture to en-GB and en-US respectively. + /// + public ReplaceCultureAttribute() : + this(_defaultCultureName, _defaultUICultureName) + { + } + + /// + /// Replaces the current culture and UI culture based on specified values. + /// + public ReplaceCultureAttribute(string currentCulture, string currentUICulture) + { + Culture = new CultureInfo(currentCulture); + UICulture = new CultureInfo(currentUICulture); + } + + /// + /// The for the test. Defaults to en-GB. + /// + /// + /// en-GB is used here as the default because en-US is equivalent to the InvariantCulture. We + /// want to be able to find bugs where we're accidentally relying on the Invariant instead of the + /// user's culture. + /// + public CultureInfo Culture { get; } + + /// + /// The for the test. Defaults to en-US. + /// + public CultureInfo UICulture { get; } + + public override void Before(MethodInfo methodUnderTest) + { + _originalCulture = CultureInfo.CurrentCulture; + _originalUICulture = CultureInfo.CurrentUICulture; + + CultureInfo.CurrentCulture = Culture; + CultureInfo.CurrentUICulture = UICulture; + } + + public override void After(MethodInfo methodUnderTest) + { + CultureInfo.CurrentCulture = _originalCulture; + CultureInfo.CurrentUICulture = _originalUICulture; + } + } +} + diff --git a/src/Testing/src/ShortClassNameAttribute.cs b/src/Testing/src/ShortClassNameAttribute.cs new file mode 100644 index 000000000000..6a36575d70bf --- /dev/null +++ b/src/Testing/src/ShortClassNameAttribute.cs @@ -0,0 +1,17 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Used to specify that should used the + /// unqualified class name. This is needed when a fully-qualified class name exceeds + /// max path for logging. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false)] + public class ShortClassNameAttribute : Attribute + { + } +} diff --git a/src/Testing/src/TaskExtensions.cs b/src/Testing/src/TaskExtensions.cs new file mode 100644 index 000000000000..f99bf7361aca --- /dev/null +++ b/src/Testing/src/TaskExtensions.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Testing +{ + public static class TaskExtensions + { + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default) + { + // Don't create a timer if the task is already completed + // or the debugger is attached + if (task.IsCompleted || Debugger.IsAttached) + { + return await task; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + return await task; + } + else + { + throw new TimeoutException(CreateMessage(timeout, filePath, lineNumber)); + } + } + + public static async Task TimeoutAfter(this Task task, TimeSpan timeout, + [CallerFilePath] string filePath = null, + [CallerLineNumber] int lineNumber = default) + { + // Don't create a timer if the task is already completed + // or the debugger is attached + if (task.IsCompleted || Debugger.IsAttached) + { + await task; + return; + } + + var cts = new CancellationTokenSource(); + if (task == await Task.WhenAny(task, Task.Delay(timeout, cts.Token))) + { + cts.Cancel(); + await task; + } + else + { + throw new TimeoutException(CreateMessage(timeout, filePath, lineNumber)); + } + } + + private static string CreateMessage(TimeSpan timeout, string filePath, int lineNumber) + => string.IsNullOrEmpty(filePath) + ? $"The operation timed out after reaching the limit of {timeout.TotalMilliseconds}ms." + : $"The operation at {filePath}:{lineNumber} timed out after reaching the limit of {timeout.TotalMilliseconds}ms."; + } +} diff --git a/src/Testing/src/TestContext.cs b/src/Testing/src/TestContext.cs new file mode 100644 index 000000000000..a702d71ecf16 --- /dev/null +++ b/src/Testing/src/TestContext.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Provides access to contextual information about the running tests. Get access by + /// implementing . + /// + /// + /// Requires defining as the test framework. + /// + public sealed class TestContext + { + private Lazy _files; + + public TestContext( + Type testClass, + object[] constructorArguments, + MethodInfo testMethod, + object[] methodArguments, + ITestOutputHelper output) + { + TestClass = testClass; + ConstructorArguments = constructorArguments; + TestMethod = testMethod; + MethodArguments = methodArguments; + Output = output; + + _files = new Lazy(() => new TestFileOutputContext(this)); + } + + public Type TestClass { get; } + public MethodInfo TestMethod { get; } + public object[] ConstructorArguments { get; } + public object[] MethodArguments { get; } + public ITestOutputHelper Output { get; } + public TestFileOutputContext FileOutput => _files.Value; + } +} diff --git a/src/Testing/src/TestFileOutputContext.cs b/src/Testing/src/TestFileOutputContext.cs new file mode 100644 index 000000000000..fb79fd7bf79c --- /dev/null +++ b/src/Testing/src/TestFileOutputContext.cs @@ -0,0 +1,146 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Provides access to file storage for the running test. Get access by + /// implementing , and accessing . + /// + /// + /// Requires defining as the test framework. + /// + public sealed class TestFileOutputContext + { + private static char[] InvalidFileChars = new char[] + { + '\"', '<', '>', '|', '\0', + (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10, + (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20, + (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30, + (char)31, ':', '*', '?', '\\', '/', ' ', (char)127 + }; + + private readonly TestContext _parent; + + public TestFileOutputContext(TestContext parent) + { + _parent = parent; + + TestName = GetTestMethodName(parent.TestMethod, parent.MethodArguments); + TestClassName = GetTestClassName(parent.TestClass); + + AssemblyOutputDirectory = GetAssemblyBaseDirectory(_parent.TestClass.Assembly); + if (!string.IsNullOrEmpty(AssemblyOutputDirectory)) + { + TestClassOutputDirectory = Path.Combine(AssemblyOutputDirectory, TestClassName); + } + } + + public string TestName { get; } + + public string TestClassName { get; } + + public string AssemblyOutputDirectory { get; } + + public string TestClassOutputDirectory { get; } + + public string GetUniqueFileName(string prefix, string extension) + { + if (prefix == null) + { + throw new ArgumentNullException(nameof(prefix)); + } + + if (extension != null && !extension.StartsWith(".", StringComparison.Ordinal)) + { + throw new ArgumentException("The extension must start with '.' if one is provided.", nameof(extension)); + } + + var path = Path.Combine(TestClassOutputDirectory, $"{prefix}{extension}"); + + var i = 1; + while (File.Exists(path)) + { + path = Path.Combine(TestClassOutputDirectory, $"{prefix}{i++}{extension}"); + } + + return path; + } + + // Gets the output directory without appending the TFM or assembly name. + public static string GetOutputDirectory(Assembly assembly) + { + var attribute = assembly.GetCustomAttributes().OfType().FirstOrDefault(); + return attribute?.BaseDirectory; + } + + public static string GetAssemblyBaseDirectory(Assembly assembly, string baseDirectory = null) + { + var attribute = assembly.GetCustomAttributes().OfType().FirstOrDefault(); + baseDirectory = baseDirectory ?? attribute?.BaseDirectory; + if (string.IsNullOrEmpty(baseDirectory)) + { + return string.Empty; + } + + return Path.Combine(baseDirectory, assembly.GetName().Name, attribute.TargetFramework); + } + + public static bool GetPreserveExistingLogsInOutput(Assembly assembly) + { + var attribute = assembly.GetCustomAttributes().OfType().FirstOrDefault(); + return attribute.PreserveExistingLogsInOutput; + } + + public static string GetTestClassName(Type type) + { + var shortNameAttribute = + type.GetCustomAttribute() ?? + type.Assembly.GetCustomAttribute(); + var name = shortNameAttribute == null ? type.FullName : type.Name; + + // Try to shorten the class name using the assembly name + var assemblyName = type.Assembly.GetName().Name; + if (name.StartsWith(assemblyName + ".")) + { + name = name.Substring(assemblyName.Length + 1); + } + + return name; + } + + public static string GetTestMethodName(MethodInfo method, object[] arguments) + { + var name = arguments.Aggregate(method.Name, (a, b) => $"{a}-{(b ?? "null")}"); + return RemoveIllegalFileChars(name); + } + + public static string RemoveIllegalFileChars(string s) + { + var sb = new StringBuilder(); + + foreach (var c in s) + { + if (InvalidFileChars.Contains(c)) + { + if (sb.Length > 0 && sb[sb.Length - 1] != '_') + { + sb.Append('_'); + } + } + else + { + sb.Append(c); + } + } + return sb.ToString(); + } + } +} diff --git a/src/Testing/src/TestOutputDirectoryAttribute.cs b/src/Testing/src/TestOutputDirectoryAttribute.cs new file mode 100644 index 000000000000..b1895c1d922a --- /dev/null +++ b/src/Testing/src/TestOutputDirectoryAttribute.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false, Inherited = true)] + public class TestOutputDirectoryAttribute : Attribute + { + public TestOutputDirectoryAttribute(string preserveExistingLogsInOutput, string targetFramework, string baseDirectory = null) + { + TargetFramework = targetFramework; + BaseDirectory = baseDirectory; + PreserveExistingLogsInOutput = bool.Parse(preserveExistingLogsInOutput); + } + + public string BaseDirectory { get; } + public string TargetFramework { get; } + public bool PreserveExistingLogsInOutput { get; } + } +} diff --git a/src/Testing/src/TestPathUtilities.cs b/src/Testing/src/TestPathUtilities.cs new file mode 100644 index 000000000000..6d4449ca92fa --- /dev/null +++ b/src/Testing/src/TestPathUtilities.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.AspNetCore.Testing +{ + [Obsolete("This API is obsolete and the pattern its usage encouraged should not be used anymore. See https://github.com/dotnet/extensions/issues/1697 for details.")] + public class TestPathUtilities + { + public static string GetRepoRootDirectory() + { + return GetSolutionRootDirectory("Extensions"); + } + + public static string GetSolutionRootDirectory(string solution) + { + var applicationBasePath = AppContext.BaseDirectory; + var directoryInfo = new DirectoryInfo(applicationBasePath); + + do + { + var projectFileInfo = new FileInfo(Path.Combine(directoryInfo.FullName, $"{solution}.sln")); + if (projectFileInfo.Exists) + { + return projectFileInfo.DirectoryName; + } + + directoryInfo = directoryInfo.Parent; + } + while (directoryInfo.Parent != null); + + throw new Exception($"Solution file {solution}.sln could not be found in {applicationBasePath} or its parent directories."); + } + } +} diff --git a/src/Testing/src/TestPlatformHelper.cs b/src/Testing/src/TestPlatformHelper.cs new file mode 100644 index 000000000000..2c13e08eb344 --- /dev/null +++ b/src/Testing/src/TestPlatformHelper.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Testing +{ + public static class TestPlatformHelper + { + public static bool IsMono => + Type.GetType("Mono.Runtime") != null; + + public static bool IsWindows => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + public static bool IsLinux => + RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + public static bool IsMac => + RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + } +} diff --git a/src/Testing/src/Tracing/CollectingEventListener.cs b/src/Testing/src/Tracing/CollectingEventListener.cs new file mode 100644 index 000000000000..d22a4996afbd --- /dev/null +++ b/src/Testing/src/Tracing/CollectingEventListener.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Linq; + +namespace Microsoft.AspNetCore.Testing.Tracing +{ + public class CollectingEventListener : EventListener + { + private ConcurrentQueue _events = new ConcurrentQueue(); + + private object _lock = new object(); + + private Dictionary _existingSources = new Dictionary(StringComparer.OrdinalIgnoreCase); + private HashSet _requestedEventSources = new HashSet(); + + public void CollectFrom(string eventSourceName) + { + lock(_lock) + { + // Check if it's already been created + if(_existingSources.TryGetValue(eventSourceName, out var existingSource)) + { + // It has, so just enable it now + CollectFrom(existingSource); + } + else + { + // It hasn't, so queue this request for when it is created + _requestedEventSources.Add(eventSourceName); + } + } + } + + public void CollectFrom(EventSource eventSource) => EnableEvents(eventSource, EventLevel.Verbose, EventKeywords.All); + + public IReadOnlyList GetEventsWritten() => _events.ToArray(); + + protected override void OnEventSourceCreated(EventSource eventSource) + { + lock (_lock) + { + // Add this to the list of existing sources for future CollectEventsFrom requests. + _existingSources[eventSource.Name] = eventSource; + + // Check if we have a pending request to enable it + if (_requestedEventSources.Contains(eventSource.Name)) + { + CollectFrom(eventSource); + } + } + } + + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + _events.Enqueue(eventData); + } + } +} diff --git a/src/Testing/src/Tracing/EventAssert.cs b/src/Testing/src/Tracing/EventAssert.cs new file mode 100644 index 000000000000..b32fb36dad99 --- /dev/null +++ b/src/Testing/src/Tracing/EventAssert.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tracing +{ + public class EventAssert + { + private readonly int _expectedId; + private readonly string _expectedName; + private readonly EventLevel _expectedLevel; + private readonly IList<(string name, Action asserter)> _payloadAsserters = new List<(string, Action)>(); + + public EventAssert(int expectedId, string expectedName, EventLevel expectedLevel) + { + _expectedId = expectedId; + _expectedName = expectedName; + _expectedLevel = expectedLevel; + } + + public static void Collection(IEnumerable events, params EventAssert[] asserts) + { + Assert.Collection( + events, + asserts.Select(a => a.CreateAsserter()).ToArray()); + } + + public static EventAssert Event(int id, string name, EventLevel level) + { + return new EventAssert(id, name, level); + } + + public EventAssert Payload(string name, object expectedValue) => Payload(name, actualValue => Assert.Equal(expectedValue, actualValue)); + + public EventAssert Payload(string name, Action asserter) + { + _payloadAsserters.Add((name, asserter)); + return this; + } + + private Action CreateAsserter() => Execute; + + private void Execute(EventWrittenEventArgs evt) + { + Assert.Equal(_expectedId, evt.EventId); + Assert.Equal(_expectedName, evt.EventName); + Assert.Equal(_expectedLevel, evt.Level); + + Action CreateNameAsserter((string name, Action asserter) val) + { + return actualValue => Assert.Equal(val.name, actualValue); + } + + Assert.Collection(evt.PayloadNames, _payloadAsserters.Select(CreateNameAsserter).ToArray()); + Assert.Collection(evt.Payload, _payloadAsserters.Select(t => t.asserter).ToArray()); + } + } +} diff --git a/src/Testing/src/Tracing/EventSourceTestBase.cs b/src/Testing/src/Tracing/EventSourceTestBase.cs new file mode 100644 index 000000000000..721966d6c5c0 --- /dev/null +++ b/src/Testing/src/Tracing/EventSourceTestBase.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Tracing; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tracing +{ + // This collection attribute is what makes the "magic" happen. It forces xunit to run all tests that inherit from this + // base class sequentially, preventing conflicts (since EventSource/EventListener is a process-global concept). + [Collection(CollectionName)] + public abstract class EventSourceTestBase : IDisposable + { + public const string CollectionName = "Microsoft.AspNetCore.Testing.Tracing.EventSourceTestCollection"; + + private readonly CollectingEventListener _listener; + + public EventSourceTestBase() + { + _listener = new CollectingEventListener(); + } + + protected void CollectFrom(string eventSourceName) + { + _listener.CollectFrom(eventSourceName); + } + + protected void CollectFrom(EventSource eventSource) + { + _listener.CollectFrom(eventSource); + } + + protected IReadOnlyList GetEvents() => _listener.GetEventsWritten(); + + public void Dispose() + { + _listener.Dispose(); + } + } +} diff --git a/src/Testing/src/contentFiles/cs/netstandard2.0/EventSourceTestCollection.cs b/src/Testing/src/contentFiles/cs/netstandard2.0/EventSourceTestCollection.cs new file mode 100644 index 000000000000..0ed9e1a9a9b4 --- /dev/null +++ b/src/Testing/src/contentFiles/cs/netstandard2.0/EventSourceTestCollection.cs @@ -0,0 +1,10 @@ +namespace Microsoft.AspNetCore.Testing.Tracing +{ + // This file comes from Microsoft.AspNetCore.Testing and has to be defined in the test assembly. + // It enables EventSourceTestBase's parallel isolation functionality. + + [Xunit.CollectionDefinition(EventSourceTestBase.CollectionName, DisableParallelization = true)] + public class EventSourceTestCollection + { + } +} diff --git a/src/Testing/src/xunit/AspNetTestAssemblyRunner.cs b/src/Testing/src/xunit/AspNetTestAssemblyRunner.cs new file mode 100644 index 000000000000..48fbbbfa3bed --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestAssemblyRunner.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + public class AspNetTestAssemblyRunner : XunitTestAssemblyRunner + { + private readonly Dictionary _assemblyFixtureMappings = new Dictionary(); + + public AspNetTestAssemblyRunner( + ITestAssembly testAssembly, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageSink executionMessageSink, + ITestFrameworkExecutionOptions executionOptions) + : base(testAssembly, testCases, diagnosticMessageSink, executionMessageSink, executionOptions) + { + } + + protected override async Task AfterTestAssemblyStartingAsync() + { + await base.AfterTestAssemblyStartingAsync(); + + // Find all the AssemblyFixtureAttributes on the test assembly + Aggregator.Run(() => + { + var fixturesAttributes = ((IReflectionAssemblyInfo)TestAssembly.Assembly) + .Assembly + .GetCustomAttributes(typeof(AssemblyFixtureAttribute), false) + .Cast() + .ToList(); + + // Instantiate all the fixtures + foreach (var fixtureAttribute in fixturesAttributes) + { + var ctorWithDiagnostics = fixtureAttribute.FixtureType.GetConstructor(new[] { typeof(IMessageSink) }); + if (ctorWithDiagnostics != null) + { + _assemblyFixtureMappings[fixtureAttribute.FixtureType] = Activator.CreateInstance(fixtureAttribute.FixtureType, DiagnosticMessageSink); + } + else + { + _assemblyFixtureMappings[fixtureAttribute.FixtureType] = Activator.CreateInstance(fixtureAttribute.FixtureType); + } + } + }); + } + + protected override Task BeforeTestAssemblyFinishedAsync() + { + // Dispose fixtures + foreach (var disposable in _assemblyFixtureMappings.Values.OfType()) + { + Aggregator.Run(disposable.Dispose); + } + + return base.BeforeTestAssemblyFinishedAsync(); + } + + protected override Task RunTestCollectionAsync( + IMessageBus messageBus, + ITestCollection testCollection, + IEnumerable testCases, + CancellationTokenSource cancellationTokenSource) + => new AspNetTestCollectionRunner( + _assemblyFixtureMappings, + testCollection, + testCases, + DiagnosticMessageSink, + messageBus, + TestCaseOrderer, + new ExceptionAggregator(Aggregator), + cancellationTokenSource).RunAsync(); + } +} diff --git a/src/Testing/src/xunit/AspNetTestCaseRunner.cs b/src/Testing/src/xunit/AspNetTestCaseRunner.cs new file mode 100644 index 000000000000..42773db21274 --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestCaseRunner.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + internal class AspNetTestCaseRunner : XunitTestCaseRunner + { + public AspNetTestCaseRunner( + IXunitTestCase testCase, + string displayName, + string skipReason, + object[] constructorArguments, + object[] testMethodArguments, + IMessageBus messageBus, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(testCase, displayName, skipReason, constructorArguments, testMethodArguments, messageBus, aggregator, cancellationTokenSource) + { + } + + protected override XunitTestRunner CreateTestRunner(ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, object[] testMethodArguments, string skipReason, IReadOnlyList beforeAfterAttributes, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) + { + return new AspNetTestRunner(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource); + } + } +} diff --git a/src/Testing/src/xunit/AspNetTestClassRunner.cs b/src/Testing/src/xunit/AspNetTestClassRunner.cs new file mode 100644 index 000000000000..bbefa37427bf --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestClassRunner.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + internal class AspNetTestClassRunner : XunitTestClassRunner + { + public AspNetTestClassRunner( + ITestClass testClass, + IReflectionTypeInfo @class, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ITestCaseOrderer testCaseOrderer, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource, + IDictionary collectionFixtureMappings) + : base(testClass, @class, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource, collectionFixtureMappings) + { + } + + protected override Task RunTestMethodAsync(ITestMethod testMethod, IReflectionMethodInfo method, IEnumerable testCases, object[] constructorArguments) + { + var runner = new AspNetTestMethodRunner( + testMethod, + Class, + method, + testCases, + DiagnosticMessageSink, + MessageBus, + new ExceptionAggregator(Aggregator), + CancellationTokenSource, + constructorArguments); + return runner.RunAsync(); + } + } +} diff --git a/src/Testing/src/xunit/AspNetTestCollectionRunner.cs b/src/Testing/src/xunit/AspNetTestCollectionRunner.cs new file mode 100644 index 000000000000..522cbd4624ca --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestCollectionRunner.cs @@ -0,0 +1,75 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + public class AspNetTestCollectionRunner : XunitTestCollectionRunner + { + private readonly IDictionary _assemblyFixtureMappings; + private readonly IMessageSink _diagnosticMessageSink; + + public AspNetTestCollectionRunner( + Dictionary assemblyFixtureMappings, + ITestCollection testCollection, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ITestCaseOrderer testCaseOrderer, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(testCollection, testCases, diagnosticMessageSink, messageBus, testCaseOrderer, aggregator, cancellationTokenSource) + { + _assemblyFixtureMappings = assemblyFixtureMappings; + _diagnosticMessageSink = diagnosticMessageSink; + } + + protected override async Task AfterTestCollectionStartingAsync() + { + await base.AfterTestCollectionStartingAsync(); + + // note: We pass the assembly fixtures into the runner as ICollectionFixture<> - this seems to work OK without any + // drawbacks. It's reasonable that we could add IAssemblyFixture<> and related plumbing if it ever became required. + // + // The reason for assembly fixture is when we want to start/stop something as the project scope - tests can only be + // in one test collection at a time. + foreach (var mapping in _assemblyFixtureMappings) + { + CollectionFixtureMappings.Add(mapping.Key, mapping.Value); + } + } + + protected override Task BeforeTestCollectionFinishedAsync() + { + // We need to remove the assembly fixtures so they won't get disposed. + foreach (var mapping in _assemblyFixtureMappings) + { + CollectionFixtureMappings.Remove(mapping.Key); + } + + return base.BeforeTestCollectionFinishedAsync(); + } + + protected override Task RunTestClassAsync(ITestClass testClass, IReflectionTypeInfo @class, IEnumerable testCases) + { + var runner = new AspNetTestClassRunner( + testClass, + @class, + testCases, + DiagnosticMessageSink, + MessageBus, + TestCaseOrderer, + new ExceptionAggregator(Aggregator), + CancellationTokenSource, + CollectionFixtureMappings); + return runner.RunAsync(); + } + } +} diff --git a/src/Testing/src/xunit/AspNetTestFramework.cs b/src/Testing/src/xunit/AspNetTestFramework.cs new file mode 100644 index 000000000000..0a2dc1b21fa3 --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestFramework.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + public class AspNetTestFramework : XunitTestFramework + { + public AspNetTestFramework(IMessageSink messageSink) + : base(messageSink) + { + } + + protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName) + => new AspNetTestFrameworkExecutor(assemblyName, SourceInformationProvider, DiagnosticMessageSink); + } +} diff --git a/src/Testing/src/xunit/AspNetTestFrameworkExecutor.cs b/src/Testing/src/xunit/AspNetTestFrameworkExecutor.cs new file mode 100644 index 000000000000..b34f0b715e40 --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestFrameworkExecutor.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Reflection; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + public class AspNetTestFrameworkExecutor : XunitTestFrameworkExecutor + { + public AspNetTestFrameworkExecutor(AssemblyName assemblyName, ISourceInformationProvider sourceInformationProvider, IMessageSink diagnosticMessageSink) + : base(assemblyName, sourceInformationProvider, diagnosticMessageSink) + { + } + + protected override async void RunTestCases(IEnumerable testCases, IMessageSink executionMessageSink, ITestFrameworkExecutionOptions executionOptions) + { + using (var assemblyRunner = new AspNetTestAssemblyRunner(TestAssembly, testCases, DiagnosticMessageSink, executionMessageSink, executionOptions)) + { + await assemblyRunner.RunAsync(); + } + } + } +} diff --git a/src/Testing/src/xunit/AspNetTestInvoker.cs b/src/Testing/src/xunit/AspNetTestInvoker.cs new file mode 100644 index 000000000000..a764db6622d0 --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestInvoker.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + internal class AspNetTestInvoker : XunitTestInvoker + { + public AspNetTestInvoker( + ITest test, + IMessageBus messageBus, + Type testClass, + object[] constructorArguments, + MethodInfo testMethod, + object[] testMethodArguments, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, beforeAfterAttributes, aggregator, cancellationTokenSource) + { + } + + protected override async Task InvokeTestMethodAsync(object testClassInstance) + { + var output = new TestOutputHelper(); + output.Initialize(MessageBus, Test); + + var context = new TestContext(TestClass, ConstructorArguments, TestMethod, TestMethodArguments, output); + var lifecycleHooks = GetLifecycleHooks(testClassInstance, TestClass, TestMethod); + + await Aggregator.RunAsync(async () => + { + foreach (var lifecycleHook in lifecycleHooks) + { + await lifecycleHook.OnTestStartAsync(context, CancellationTokenSource.Token); + } + }); + + var time = await base.InvokeTestMethodAsync(testClassInstance); + + await Aggregator.RunAsync(async () => + { + var exception = Aggregator.HasExceptions ? Aggregator.ToException() : null; + foreach (var lifecycleHook in lifecycleHooks) + { + await lifecycleHook.OnTestEndAsync(context, exception, CancellationTokenSource.Token); + } + }); + + return time; + } + + private static IEnumerable GetLifecycleHooks(object testClassInstance, Type testClass, MethodInfo testMethod) + { + foreach (var attribute in testMethod.GetCustomAttributes(inherit: true).OfType()) + { + yield return attribute; + } + + if (testClassInstance is ITestMethodLifecycle instance) + { + yield return instance; + } + + foreach (var attribute in testClass.GetCustomAttributes(inherit: true).OfType()) + { + yield return attribute; + } + + foreach (var attribute in testClass.Assembly.GetCustomAttributes(inherit: true).OfType()) + { + yield return attribute; + } + } + } +} diff --git a/src/Testing/src/xunit/AspNetTestMethodRunner.cs b/src/Testing/src/xunit/AspNetTestMethodRunner.cs new file mode 100644 index 000000000000..e238d0769d81 --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestMethodRunner.cs @@ -0,0 +1,73 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing.xunit; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + internal class AspNetTestMethodRunner : XunitTestMethodRunner + { + private readonly object[] _constructorArguments; + private readonly IMessageSink _diagnosticMessageSink; + + public AspNetTestMethodRunner( + ITestMethod testMethod, + IReflectionTypeInfo @class, + IReflectionMethodInfo method, + IEnumerable testCases, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource, + object[] constructorArguments) + : base(testMethod, @class, method, testCases, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource, constructorArguments) + { + _diagnosticMessageSink = diagnosticMessageSink; + _constructorArguments = constructorArguments; + } + + protected override Task RunTestCaseAsync(IXunitTestCase testCase) + { + if (testCase.GetType() == typeof(XunitTestCase)) + { + // If we get here this is a 'regular' test case, not something that represents a skipped test. + // + // We can take control of it's invocation thusly. + var runner = new AspNetTestCaseRunner( + testCase, + testCase.DisplayName, + testCase.SkipReason, + _constructorArguments, + testCase.TestMethodArguments, + MessageBus, + new ExceptionAggregator(Aggregator), + CancellationTokenSource); + return runner.RunAsync(); + } + + if (testCase.GetType() == typeof(XunitTheoryTestCase)) + { + // If we get here this is a 'regular' theory test case, not something that represents a skipped test. + // + // We can take control of it's invocation thusly. + var runner = new AspNetTheoryTestCaseRunner( + testCase, + testCase.DisplayName, + testCase.SkipReason, + _constructorArguments, + _diagnosticMessageSink, + MessageBus, + new ExceptionAggregator(Aggregator), + CancellationTokenSource); + return runner.RunAsync(); + } + + return base.RunTestCaseAsync(testCase); + } + } +} diff --git a/src/Testing/src/xunit/AspNetTestRunner.cs b/src/Testing/src/xunit/AspNetTestRunner.cs new file mode 100644 index 000000000000..2786a866b417 --- /dev/null +++ b/src/Testing/src/xunit/AspNetTestRunner.cs @@ -0,0 +1,78 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + internal class AspNetTestRunner : XunitTestRunner + { + public AspNetTestRunner( + ITest test, + IMessageBus messageBus, + Type testClass, + object[] constructorArguments, + MethodInfo testMethod, + object[] testMethodArguments, + string skipReason, + IReadOnlyList beforeAfterAttributes, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource) + { + } + + protected override async Task InvokeTestMethodAsync(ExceptionAggregator aggregator) + { + var repeatAttribute = GetRepeatAttribute(TestMethod); + if (repeatAttribute == null) + { + return await InvokeTestMethodCoreAsync(aggregator); + } + + var repeatContext = new RepeatContext(repeatAttribute.RunCount); + RepeatContext.Current = repeatContext; + + var timeTaken = 0.0M; + for (repeatContext.CurrentIteration = 0; repeatContext.CurrentIteration < repeatContext.Limit; repeatContext.CurrentIteration++) + { + timeTaken = await InvokeTestMethodCoreAsync(aggregator); + if (aggregator.HasExceptions) + { + return timeTaken; + } + } + + return timeTaken; + } + + private Task InvokeTestMethodCoreAsync(ExceptionAggregator aggregator) + { + var invoker = new AspNetTestInvoker(Test, MessageBus, TestClass, ConstructorArguments, TestMethod, TestMethodArguments, BeforeAfterAttributes, aggregator, CancellationTokenSource); + return invoker.RunAsync(); + } + + private RepeatAttribute GetRepeatAttribute(MethodInfo methodInfo) + { + var attributeCandidate = methodInfo.GetCustomAttribute(); + if (attributeCandidate != null) + { + return attributeCandidate; + } + + attributeCandidate = methodInfo.DeclaringType.GetCustomAttribute(); + if (attributeCandidate != null) + { + return attributeCandidate; + } + + return methodInfo.DeclaringType.Assembly.GetCustomAttribute(); + } + } +} diff --git a/src/Testing/src/xunit/AspNetTheoryTestCaseRunner.cs b/src/Testing/src/xunit/AspNetTheoryTestCaseRunner.cs new file mode 100644 index 000000000000..a09a17cf69f3 --- /dev/null +++ b/src/Testing/src/xunit/AspNetTheoryTestCaseRunner.cs @@ -0,0 +1,33 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing.xunit +{ + internal class AspNetTheoryTestCaseRunner : XunitTheoryTestCaseRunner + { + public AspNetTheoryTestCaseRunner( + IXunitTestCase testCase, + string displayName, + string skipReason, + object[] constructorArguments, + IMessageSink diagnosticMessageSink, + IMessageBus messageBus, + ExceptionAggregator aggregator, + CancellationTokenSource cancellationTokenSource) + : base(testCase, displayName, skipReason, constructorArguments, diagnosticMessageSink, messageBus, aggregator, cancellationTokenSource) + { + } + + protected override XunitTestRunner CreateTestRunner(ITest test, IMessageBus messageBus, Type testClass, object[] constructorArguments, MethodInfo testMethod, object[] testMethodArguments, string skipReason, IReadOnlyList beforeAfterAttributes, ExceptionAggregator aggregator, CancellationTokenSource cancellationTokenSource) + { + return new AspNetTestRunner(test, messageBus, testClass, constructorArguments, testMethod, testMethodArguments, skipReason, beforeAfterAttributes, aggregator, cancellationTokenSource); + } + } +} diff --git a/src/Testing/src/xunit/AssemblyFixtureAttribute.cs b/src/Testing/src/xunit/AssemblyFixtureAttribute.cs new file mode 100644 index 000000000000..c3b9eba31d55 --- /dev/null +++ b/src/Testing/src/xunit/AssemblyFixtureAttribute.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public class AssemblyFixtureAttribute : Attribute + { + public AssemblyFixtureAttribute(Type fixtureType) + { + FixtureType = fixtureType; + } + + public Type FixtureType { get; private set; } + } +} diff --git a/src/Testing/src/xunit/ConditionalFactAttribute.cs b/src/Testing/src/xunit/ConditionalFactAttribute.cs new file mode 100644 index 000000000000..538a055792e9 --- /dev/null +++ b/src/Testing/src/xunit/ConditionalFactAttribute.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + [XunitTestCaseDiscoverer("Microsoft.AspNetCore.Testing." + nameof(ConditionalFactDiscoverer), "Microsoft.AspNetCore.Testing")] + public class ConditionalFactAttribute : FactAttribute + { + } +} diff --git a/src/Testing/src/xunit/ConditionalFactDiscoverer.cs b/src/Testing/src/xunit/ConditionalFactDiscoverer.cs new file mode 100644 index 000000000000..e9a6b895ae89 --- /dev/null +++ b/src/Testing/src/xunit/ConditionalFactDiscoverer.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit.Abstractions; +using Xunit.Sdk; + +// Do not change this namespace without changing the usage in ConditionalFactAttribute +namespace Microsoft.AspNetCore.Testing +{ + internal class ConditionalFactDiscoverer : FactDiscoverer + { + private readonly IMessageSink _diagnosticMessageSink; + + public ConditionalFactDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + _diagnosticMessageSink = diagnosticMessageSink; + } + + protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new SkippedTestCase(skipReason, _diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod) + : base.CreateTestCase(discoveryOptions, testMethod, factAttribute); + } + } +} diff --git a/src/Testing/src/xunit/ConditionalTheoryAttribute.cs b/src/Testing/src/xunit/ConditionalTheoryAttribute.cs new file mode 100644 index 000000000000..2fbac5d90c81 --- /dev/null +++ b/src/Testing/src/xunit/ConditionalTheoryAttribute.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + [XunitTestCaseDiscoverer("Microsoft.AspNetCore.Testing." + nameof(ConditionalTheoryDiscoverer), "Microsoft.AspNetCore.Testing")] + public class ConditionalTheoryAttribute : TheoryAttribute + { + } +} diff --git a/src/Testing/src/xunit/ConditionalTheoryDiscoverer.cs b/src/Testing/src/xunit/ConditionalTheoryDiscoverer.cs new file mode 100644 index 000000000000..e7f655a5bec0 --- /dev/null +++ b/src/Testing/src/xunit/ConditionalTheoryDiscoverer.cs @@ -0,0 +1,87 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Xunit.Abstractions; +using Xunit.Sdk; + +// Do not change this namespace without changing the usage in ConditionalTheoryAttribute +namespace Microsoft.AspNetCore.Testing +{ + internal class ConditionalTheoryDiscoverer : TheoryDiscoverer + { + public ConditionalTheoryDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + } + + private sealed class OptionsWithPreEnumerationEnabled : ITestFrameworkDiscoveryOptions + { + private const string PreEnumerateTheories = "xunit.discovery.PreEnumerateTheories"; + + private readonly ITestFrameworkDiscoveryOptions _original; + + public OptionsWithPreEnumerationEnabled(ITestFrameworkDiscoveryOptions original) + => _original = original; + + public TValue GetValue(string name) + => (name == PreEnumerateTheories) ? (TValue)(object)true : _original.GetValue(name); + + public void SetValue(string name, TValue value) + => _original.SetValue(name, value); + } + + public override IEnumerable Discover(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) + => base.Discover(new OptionsWithPreEnumerationEnabled(discoveryOptions), testMethod, theoryAttribute); + + protected override IEnumerable CreateTestCasesForTheory(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new[] { new SkippedTestCase(skipReason, DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod) } + : base.CreateTestCasesForTheory(discoveryOptions, testMethod, theoryAttribute); + } + + protected override IEnumerable CreateTestCasesForDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow) + { + var skipReason = testMethod.EvaluateSkipConditions(); + if (skipReason == null && dataRow?.Length > 0) + { + var obj = dataRow[0]; + if (obj != null) + { + var type = obj.GetType(); + var property = type.GetProperty("Skip"); + if (property != null && property.PropertyType.Equals(typeof(string))) + { + skipReason = property.GetValue(obj) as string; + } + } + } + + return skipReason != null ? + base.CreateTestCasesForSkippedDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow, skipReason) + : base.CreateTestCasesForDataRow(discoveryOptions, testMethod, theoryAttribute, dataRow); + } + + protected override IEnumerable CreateTestCasesForSkippedDataRow( + ITestFrameworkDiscoveryOptions discoveryOptions, + ITestMethod testMethod, + IAttributeInfo theoryAttribute, + object[] dataRow, + string skipReason) + { + return new[] + { + new WORKAROUND_SkippedDataRowTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, skipReason, dataRow), + }; + } + + [Obsolete] + protected override IXunitTestCase CreateTestCaseForSkippedDataRow(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo theoryAttribute, object[] dataRow, string skipReason) + { + return new WORKAROUND_SkippedDataRowTestCase(DiagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), discoveryOptions.MethodDisplayOptionsOrDefault(), testMethod, skipReason, dataRow); + } + } +} diff --git a/src/Testing/src/xunit/DockerOnlyAttribute.cs b/src/Testing/src/xunit/DockerOnlyAttribute.cs new file mode 100644 index 000000000000..7d809884d6ca --- /dev/null +++ b/src/Testing/src/xunit/DockerOnlyAttribute.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Testing +{ + [AttributeUsage(AttributeTargets.Method, Inherited = true, AllowMultiple = false)] + public sealed class DockerOnlyAttribute : Attribute, ITestCondition + { + public string SkipReason { get; } = "This test can only run in a Docker container."; + + public bool IsMet + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // we currently don't have a good way to detect if running in a Windows container + return false; + } + + const string procFile = "/proc/1/cgroup"; + if (!File.Exists(procFile)) + { + return false; + } + + var lines = File.ReadAllLines(procFile); + // typically the last line in the file is "1:name=openrc:/docker" + return lines.Reverse().Any(l => l.EndsWith("name=openrc:/docker", StringComparison.Ordinal)); + } + } + } +} diff --git a/src/Testing/src/xunit/EnvironmentVariableSkipConditionAttribute.cs b/src/Testing/src/xunit/EnvironmentVariableSkipConditionAttribute.cs new file mode 100644 index 000000000000..0599e319011c --- /dev/null +++ b/src/Testing/src/xunit/EnvironmentVariableSkipConditionAttribute.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Skips a test when the value of an environment variable matches any of the supplied values. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] + public class EnvironmentVariableSkipConditionAttribute : Attribute, ITestCondition + { + private readonly string _variableName; + private readonly string[] _values; + private string _currentValue; + private readonly IEnvironmentVariable _environmentVariable; + + /// + /// Creates a new instance of . + /// + /// Name of the environment variable. + /// Value(s) of the environment variable to match for the test to be skipped + public EnvironmentVariableSkipConditionAttribute(string variableName, params string[] values) + : this(new EnvironmentVariable(), variableName, values) + { + } + + // To enable unit testing + internal EnvironmentVariableSkipConditionAttribute( + IEnvironmentVariable environmentVariable, + string variableName, + params string[] values) + { + if (environmentVariable == null) + { + throw new ArgumentNullException(nameof(environmentVariable)); + } + if (variableName == null) + { + throw new ArgumentNullException(nameof(variableName)); + } + if (values == null) + { + throw new ArgumentNullException(nameof(values)); + } + + _variableName = variableName; + _values = values; + _environmentVariable = environmentVariable; + } + + /// + /// Runs the test only if the value of the variable matches any of the supplied values. Default is True. + /// + public bool RunOnMatch { get; set; } = true; + + public bool IsMet + { + get + { + _currentValue = _environmentVariable.Get(_variableName); + var hasMatched = _values.Any(value => string.Compare(value, _currentValue, ignoreCase: true) == 0); + + if (RunOnMatch) + { + return hasMatched; + } + else + { + return !hasMatched; + } + } + } + + public string SkipReason + { + get + { + var value = _currentValue == null ? "(null)" : _currentValue; + return $"Test skipped on environment variable with name '{_variableName}' and value '{value}' " + + $"for the '{nameof(RunOnMatch)}' value of '{RunOnMatch}'."; + } + } + + private struct EnvironmentVariable : IEnvironmentVariable + { + public string Get(string name) + { + return Environment.GetEnvironmentVariable(name); + } + } + } +} diff --git a/src/Testing/src/xunit/FlakyAttribute.cs b/src/Testing/src/xunit/FlakyAttribute.cs new file mode 100644 index 000000000000..f77c56dfc0cf --- /dev/null +++ b/src/Testing/src/xunit/FlakyAttribute.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Marks a test as "Flaky" so that the build will sequester it and ignore failures. + /// + /// + /// + /// This attribute works by applying xUnit.net "Traits" based on the criteria specified in the attribute + /// properties. Once these traits are applied, build scripts can include/exclude tests based on them. + /// + /// + /// All flakiness-related traits start with Flaky: and are grouped first by the process running the tests: Azure Pipelines (AzP) or Helix. + /// Then there is a segment specifying the "selector" which indicates where the test is flaky. Finally a segment specifying the value of that selector. + /// The value of these traits is always either "true" or the trait is not present. We encode the entire selector in the name of the trait because xUnit.net only + /// provides "==" and "!=" operators for traits, there is no way to check if a trait "contains" or "does not contain" a value. VSTest does support "contains" checks + /// but does not appear to support "does not contain" checks. Using this pattern means we can use simple "==" and "!=" checks to either only run flaky tests, or exclude + /// flaky tests. + /// + /// + /// + /// + /// [Fact] + /// [Flaky("...", HelixQueues.Fedora28Amd64, AzurePipelines.macOS)] + /// public void FlakyTest() + /// { + /// // Flakiness + /// } + /// + /// + /// + /// The above example generates the following facets: + /// + /// + /// + /// + /// Flaky:Helix:Queue:Fedora.28.Amd64.Open = true + /// + /// + /// Flaky:AzP:OS:Darwin = true + /// + /// + /// + /// + /// Given the above attribute, the Azure Pipelines macOS run can easily filter this test out by passing -notrait "Flaky:AzP:OS:all=true" -notrait "Flaky:AzP:OS:Darwin=true" + /// to xunit.console.exe. Similarly, it can run only flaky tests using -trait "Flaky:AzP:OS:all=true" -trait "Flaky:AzP:OS:Darwin=true" + /// + /// + [TraitDiscoverer("Microsoft.AspNetCore.Testing." + nameof(FlakyTraitDiscoverer), "Microsoft.AspNetCore.Testing")] + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly)] + public sealed class FlakyAttribute : Attribute, ITraitAttribute + { + /// + /// Gets a URL to a GitHub issue tracking this flaky test. + /// + public string GitHubIssueUrl { get; } + + public IReadOnlyList Filters { get; } + + /// + /// Initializes a new instance of the class with the specified and a list of . If no + /// filters are provided, the test is considered flaky in all environments. + /// + /// + /// At least one filter is required. + /// + /// The URL to a GitHub issue tracking this flaky test. + /// The first filter that indicates where the test is flaky. Use a value from . + /// A list of additional filters that define where this test is flaky. Use values in . + public FlakyAttribute(string gitHubIssueUrl, string firstFilter, params string[] additionalFilters) + { + if (string.IsNullOrEmpty(gitHubIssueUrl)) + { + throw new ArgumentNullException(nameof(gitHubIssueUrl)); + } + + if (string.IsNullOrEmpty(firstFilter)) + { + throw new ArgumentNullException(nameof(firstFilter)); + } + + GitHubIssueUrl = gitHubIssueUrl; + var filters = new List(); + filters.Add(firstFilter); + filters.AddRange(additionalFilters); + Filters = filters; + } + } +} diff --git a/src/Testing/src/xunit/FlakyTraitDiscoverer.cs b/src/Testing/src/xunit/FlakyTraitDiscoverer.cs new file mode 100644 index 000000000000..4e6bc27b1bbe --- /dev/null +++ b/src/Testing/src/xunit/FlakyTraitDiscoverer.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using Xunit.Abstractions; +using Xunit.Sdk; + +// Do not change this namespace without changing the usage in FlakyAttribute +namespace Microsoft.AspNetCore.Testing +{ + public class FlakyTraitDiscoverer : ITraitDiscoverer + { + public IEnumerable> GetTraits(IAttributeInfo traitAttribute) + { + if (traitAttribute is ReflectionAttributeInfo attribute && attribute.Attribute is FlakyAttribute flakyAttribute) + { + return GetTraitsCore(flakyAttribute); + } + else + { + throw new InvalidOperationException("The 'Flaky' attribute is only supported via reflection."); + } + } + + private IEnumerable> GetTraitsCore(FlakyAttribute attribute) + { + if (attribute.Filters.Count > 0) + { + foreach (var filter in attribute.Filters) + { + yield return new KeyValuePair($"Flaky:{filter}", "true"); + } + } + else + { + yield return new KeyValuePair($"Flaky:All", "true"); + } + } + } +} diff --git a/src/Testing/src/xunit/FrameworkSkipConditionAttribute.cs b/src/Testing/src/xunit/FrameworkSkipConditionAttribute.cs new file mode 100644 index 000000000000..b7719848a690 --- /dev/null +++ b/src/Testing/src/xunit/FrameworkSkipConditionAttribute.cs @@ -0,0 +1,57 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class FrameworkSkipConditionAttribute : Attribute, ITestCondition + { + private readonly RuntimeFrameworks _excludedFrameworks; + + public FrameworkSkipConditionAttribute(RuntimeFrameworks excludedFrameworks) + { + _excludedFrameworks = excludedFrameworks; + } + + public bool IsMet + { + get + { + return CanRunOnThisFramework(_excludedFrameworks); + } + } + + public string SkipReason { get; set; } = "Test cannot run on this runtime framework."; + + private static bool CanRunOnThisFramework(RuntimeFrameworks excludedFrameworks) + { + if (excludedFrameworks == RuntimeFrameworks.None) + { + return true; + } + +#if NET461 || NET46 + if (excludedFrameworks.HasFlag(RuntimeFrameworks.Mono) && + TestPlatformHelper.IsMono) + { + return false; + } + + if (excludedFrameworks.HasFlag(RuntimeFrameworks.CLR)) + { + return false; + } +#elif NETSTANDARD2_0 + if (excludedFrameworks.HasFlag(RuntimeFrameworks.CoreCLR)) + { + return false; + } +#else +#error Target frameworks need to be updated. +#endif + return true; + } + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/IEnvironmentVariable.cs b/src/Testing/src/xunit/IEnvironmentVariable.cs new file mode 100644 index 000000000000..ed06ed65055b --- /dev/null +++ b/src/Testing/src/xunit/IEnvironmentVariable.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Testing +{ + internal interface IEnvironmentVariable + { + string Get(string name); + } +} diff --git a/src/Testing/src/xunit/ITestCondition.cs b/src/Testing/src/xunit/ITestCondition.cs new file mode 100644 index 000000000000..34767b8574e1 --- /dev/null +++ b/src/Testing/src/xunit/ITestCondition.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Testing +{ + public interface ITestCondition + { + bool IsMet { get; } + + string SkipReason { get; } + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/MaximumOSVersionAttribute.cs b/src/Testing/src/xunit/MaximumOSVersionAttribute.cs new file mode 100644 index 000000000000..19ee1098d2c2 --- /dev/null +++ b/src/Testing/src/xunit/MaximumOSVersionAttribute.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Skips a test if the OS is the given type (Windows) and the OS version is greater than specified. + /// E.g. Specifying Window 8 skips on Win 10, but not on Linux. Combine with OSSkipConditionAttribute as needed. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] + public class MaximumOSVersionAttribute : Attribute, ITestCondition + { + private readonly OperatingSystems _targetOS; + private readonly Version _maxVersion; + private readonly OperatingSystems _currentOS; + private readonly Version _currentVersion; + private readonly bool _skip; + + public MaximumOSVersionAttribute(OperatingSystems operatingSystem, string maxVersion) : + this(operatingSystem, Version.Parse(maxVersion), GetCurrentOS(), GetCurrentOSVersion()) + { + } + + // to enable unit testing + internal MaximumOSVersionAttribute(OperatingSystems targetOS, Version maxVersion, OperatingSystems currentOS, Version currentVersion) + { + if (targetOS != OperatingSystems.Windows) + { + throw new NotImplementedException("Max version support is only implemented for Windows."); + } + _targetOS = targetOS; + _maxVersion = maxVersion; + _currentOS = currentOS; + // We drop the 4th field because it is not significant and it messes up the comparisons. + _currentVersion = new Version(currentVersion.Major, currentVersion.Minor, + // Major and Minor are required by the parser, but if Build isn't specified then it returns -1 + // which the constructor rejects. + currentVersion.Build == -1 ? 0 : currentVersion.Build); + + // Do not skip other OS's, Use OSSkipConditionAttribute or a separate MaximumOsVersionAttribute for that. + _skip = _targetOS == _currentOS && _maxVersion < _currentVersion; + SkipReason = $"This test requires {_targetOS} {_maxVersion} or earlier."; + } + + // Since a test would be executed only if 'IsMet' is true, return false if we want to skip + public bool IsMet => !_skip; + + public string SkipReason { get; set; } + + private static OperatingSystems GetCurrentOS() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OperatingSystems.Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OperatingSystems.Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OperatingSystems.MacOSX; + } + throw new PlatformNotSupportedException(); + } + + static private Version GetCurrentOSVersion() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Environment.OSVersion.Version; + } + else + { + // Not implemented, but this will still be called before the OS check happens so don't throw. + return new Version(0, 0); + } + } + } +} diff --git a/src/Testing/src/xunit/MinimumOsVersionAttribute.cs b/src/Testing/src/xunit/MinimumOsVersionAttribute.cs new file mode 100644 index 000000000000..4d016a07e784 --- /dev/null +++ b/src/Testing/src/xunit/MinimumOsVersionAttribute.cs @@ -0,0 +1,79 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Skips a test if the OS is the given type (Windows) and the OS version is less than specified. + /// E.g. Specifying Window 10.0 skips on Win 8, but not on Linux. Combine with OSSkipConditionAttribute as needed. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] + public class MinimumOSVersionAttribute : Attribute, ITestCondition + { + private readonly OperatingSystems _targetOS; + private readonly Version _minVersion; + private readonly OperatingSystems _currentOS; + private readonly Version _currentVersion; + private readonly bool _skip; + + public MinimumOSVersionAttribute(OperatingSystems operatingSystem, string minVersion) : + this(operatingSystem, Version.Parse(minVersion), GetCurrentOS(), GetCurrentOSVersion()) + { + } + + // to enable unit testing + internal MinimumOSVersionAttribute(OperatingSystems targetOS, Version minVersion, OperatingSystems currentOS, Version currentVersion) + { + if (targetOS != OperatingSystems.Windows) + { + throw new NotImplementedException("Min version support is only implemented for Windows."); + } + _targetOS = targetOS; + _minVersion = minVersion; + _currentOS = currentOS; + _currentVersion = currentVersion; + + // Do not skip other OS's, Use OSSkipConditionAttribute or a separate MinimumOSVersionAttribute for that. + _skip = _targetOS == _currentOS && _minVersion > _currentVersion; + SkipReason = $"This test requires {_targetOS} {_minVersion} or later."; + } + + // Since a test would be executed only if 'IsMet' is true, return false if we want to skip + public bool IsMet => !_skip; + + public string SkipReason { get; set; } + + private static OperatingSystems GetCurrentOS() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OperatingSystems.Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OperatingSystems.Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OperatingSystems.MacOSX; + } + throw new PlatformNotSupportedException(); + } + + static private Version GetCurrentOSVersion() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Environment.OSVersion.Version; + } + else + { + // Not implemented, but this will still be called before the OS check happens so don't throw. + return new Version(0, 0); + } + } + } +} diff --git a/src/Testing/src/xunit/OSSkipConditionAttribute.cs b/src/Testing/src/xunit/OSSkipConditionAttribute.cs new file mode 100644 index 000000000000..50e3cae19217 --- /dev/null +++ b/src/Testing/src/xunit/OSSkipConditionAttribute.cs @@ -0,0 +1,62 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; + +namespace Microsoft.AspNetCore.Testing +{ + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] + public class OSSkipConditionAttribute : Attribute, ITestCondition + { + private readonly OperatingSystems _excludedOperatingSystem; + private readonly OperatingSystems _osPlatform; + + public OSSkipConditionAttribute(OperatingSystems operatingSystem) : + this(operatingSystem, GetCurrentOS()) + { + } + + [Obsolete("Use the Minimum/MaximumOSVersionAttribute for version checks.", error: true)] + public OSSkipConditionAttribute(OperatingSystems operatingSystem, params string[] versions) : + this(operatingSystem, GetCurrentOS()) + { + } + + // to enable unit testing + internal OSSkipConditionAttribute(OperatingSystems operatingSystem, OperatingSystems osPlatform) + { + _excludedOperatingSystem = operatingSystem; + _osPlatform = osPlatform; + } + + public bool IsMet + { + get + { + var skip = (_excludedOperatingSystem & _osPlatform) == _osPlatform; + // Since a test would be excuted only if 'IsMet' is true, return false if we want to skip + return !skip; + } + } + + public string SkipReason { get; set; } = "Test cannot run on this operating system."; + + static private OperatingSystems GetCurrentOS() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OperatingSystems.Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OperatingSystems.Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OperatingSystems.MacOSX; + } + throw new PlatformNotSupportedException(); + } + } +} diff --git a/src/Testing/src/xunit/OperatingSystems.cs b/src/Testing/src/xunit/OperatingSystems.cs new file mode 100644 index 000000000000..2ddacacab98d --- /dev/null +++ b/src/Testing/src/xunit/OperatingSystems.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + [Flags] + public enum OperatingSystems + { + Linux = 1, + MacOSX = 2, + Windows = 4, + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/RuntimeFrameworks.cs b/src/Testing/src/xunit/RuntimeFrameworks.cs new file mode 100644 index 000000000000..3a69022b8857 --- /dev/null +++ b/src/Testing/src/xunit/RuntimeFrameworks.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + [Flags] + public enum RuntimeFrameworks + { + None = 0, + Mono = 1 << 0, + CLR = 1 << 1, + CoreCLR = 1 << 2 + } +} \ No newline at end of file diff --git a/src/Testing/src/xunit/SkipOnCIAttribute.cs b/src/Testing/src/xunit/SkipOnCIAttribute.cs new file mode 100644 index 000000000000..1ee0b8cde8b6 --- /dev/null +++ b/src/Testing/src/xunit/SkipOnCIAttribute.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Skip test if running on CI + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] + public class SkipOnCIAttribute : Attribute, ITestCondition + { + public SkipOnCIAttribute(string issueUrl = "") + { + IssueUrl = issueUrl; + } + + public string IssueUrl { get; } + + public bool IsMet + { + get + { + return !OnCI(); + } + } + + public string SkipReason + { + get + { + return $"This test is skipped on CI"; + } + } + + public static bool OnCI() => OnHelix() || OnAzdo(); + public static bool OnHelix() => !string.IsNullOrEmpty(GetTargetHelixQueue()); + public static string GetTargetHelixQueue() => Environment.GetEnvironmentVariable("helix"); + public static bool OnAzdo() => !string.IsNullOrEmpty(GetIfOnAzdo()); + public static string GetIfOnAzdo() => Environment.GetEnvironmentVariable("AGENT_OS"); + } +} diff --git a/src/Testing/src/xunit/SkipOnHelixAttribute.cs b/src/Testing/src/xunit/SkipOnHelixAttribute.cs new file mode 100644 index 000000000000..85e82c11547f --- /dev/null +++ b/src/Testing/src/xunit/SkipOnHelixAttribute.cs @@ -0,0 +1,50 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// Skip test if running on helix (or a particular helix queue). + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false)] + public class SkipOnHelixAttribute : Attribute, ITestCondition + { + public SkipOnHelixAttribute(string issueUrl) + { + if (string.IsNullOrEmpty(issueUrl)) + { + throw new ArgumentException(); + } + IssueUrl = issueUrl; + } + + public string IssueUrl { get; } + + public bool IsMet + { + get + { + var skip = OnHelix() && (Queues == null || Queues.ToLowerInvariant().Split(';').Contains(GetTargetHelixQueue().ToLowerInvariant())); + return !skip; + } + } + + // Queues that should be skipped on, i.e. "Windows.10.Amd64.ClientRS4.VS2017.Open;OSX.1012.Amd64.Open" + public string Queues { get; set; } + + public string SkipReason + { + get + { + return $"This test is skipped on helix"; + } + } + + public static bool OnHelix() => !string.IsNullOrEmpty(GetTargetHelixQueue()); + + public static string GetTargetHelixQueue() => Environment.GetEnvironmentVariable("helix"); + } +} diff --git a/src/Testing/src/xunit/SkippedTestCase.cs b/src/Testing/src/xunit/SkippedTestCase.cs new file mode 100644 index 000000000000..0fdf166f2b5a --- /dev/null +++ b/src/Testing/src/xunit/SkippedTestCase.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + public class SkippedTestCase : XunitTestCase + { + private string _skipReason; + + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippedTestCase() : base() + { + } + + public SkippedTestCase( + string skipReason, + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, + object[] testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + _skipReason = skipReason; + } + + protected override string GetSkipReason(IAttributeInfo factAttribute) + => _skipReason ?? base.GetSkipReason(factAttribute); + + public override void Deserialize(IXunitSerializationInfo data) + { + _skipReason = data.GetValue(nameof(_skipReason)); + + // We need to call base after reading our value, because Deserialize will call + // into GetSkipReason. + base.Deserialize(data); + } + + public override void Serialize(IXunitSerializationInfo data) + { + base.Serialize(data); + data.AddValue(nameof(_skipReason), _skipReason); + } + } +} diff --git a/src/Testing/src/xunit/TestMethodExtensions.cs b/src/Testing/src/xunit/TestMethodExtensions.cs new file mode 100644 index 000000000000..96dd93eb7cde --- /dev/null +++ b/src/Testing/src/xunit/TestMethodExtensions.cs @@ -0,0 +1,34 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Linq; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + public static class TestMethodExtensions + { + public static string EvaluateSkipConditions(this ITestMethod testMethod) + { + var testClass = testMethod.TestClass.Class; + var assembly = testMethod.TestClass.TestCollection.TestAssembly.Assembly; + var conditionAttributes = testMethod.Method + .GetCustomAttributes(typeof(ITestCondition)) + .Concat(testClass.GetCustomAttributes(typeof(ITestCondition))) + .Concat(assembly.GetCustomAttributes(typeof(ITestCondition))) + .OfType() + .Select(attributeInfo => attributeInfo.Attribute); + + foreach (ITestCondition condition in conditionAttributes) + { + if (!condition.IsMet) + { + return condition.SkipReason; + } + } + + return null; + } + } +} diff --git a/src/Testing/src/xunit/WORKAROUND_SkippedDataRowTestCase.cs b/src/Testing/src/xunit/WORKAROUND_SkippedDataRowTestCase.cs new file mode 100644 index 000000000000..a86f5645bf11 --- /dev/null +++ b/src/Testing/src/xunit/WORKAROUND_SkippedDataRowTestCase.cs @@ -0,0 +1,80 @@ +using System; +using System.ComponentModel; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + // This is a workaround for https://github.com/xunit/xunit/issues/1782 - as such, this code is a copy-paste + // from xUnit with the exception of fixing the bug. + // + // This will only work with [ConditionalTheory]. + internal class WORKAROUND_SkippedDataRowTestCase : XunitTestCase + { + string skipReason; + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public WORKAROUND_SkippedDataRowTestCase() { } + + /// + /// Initializes a new instance of the class. + /// + /// The message sink used to send diagnostic messages + /// Default method display to use (when not customized). + /// The test method this test case belongs to. + /// The reason that this test case will be skipped + /// The arguments for the test method. + [Obsolete("Please call the constructor which takes TestMethodDisplayOptions")] + public WORKAROUND_SkippedDataRowTestCase(IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + ITestMethod testMethod, + string skipReason, + object[] testMethodArguments = null) + : this(diagnosticMessageSink, defaultMethodDisplay, TestMethodDisplayOptions.None, testMethod, skipReason, testMethodArguments) { } + + /// + /// Initializes a new instance of the class. + /// + /// The message sink used to send diagnostic messages + /// Default method display to use (when not customized). + /// Default method display options to use (when not customized). + /// The test method this test case belongs to. + /// The reason that this test case will be skipped + /// The arguments for the test method. + public WORKAROUND_SkippedDataRowTestCase(IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, + string skipReason, + object[] testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + this.skipReason = skipReason; + } + + /// + public override void Deserialize(IXunitSerializationInfo data) + { + // SkipReason has to be read before we call base.Deserialize, this is the workaround. + this.skipReason = data.GetValue("SkipReason"); + + base.Deserialize(data); + } + + /// + protected override string GetSkipReason(IAttributeInfo factAttribute) + { + return skipReason; + } + + /// + public override void Serialize(IXunitSerializationInfo data) + { + base.Serialize(data); + + data.AddValue("SkipReason", skipReason); + } + } +} diff --git a/src/Testing/src/xunit/WindowsVersions.cs b/src/Testing/src/xunit/WindowsVersions.cs new file mode 100644 index 000000000000..44448c74d191 --- /dev/null +++ b/src/Testing/src/xunit/WindowsVersions.cs @@ -0,0 +1,49 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Testing +{ + /// + /// https://en.wikipedia.org/wiki/Windows_10_version_history + /// + public static class WindowsVersions + { + public const string Win7 = "6.1"; + + [Obsolete("Use Win7 instead.", error: true)] + public const string Win2008R2 = Win7; + + public const string Win8 = "6.2"; + + public const string Win81 = "6.3"; + + public const string Win10 = "10.0"; + + /// + /// 1803, RS4, 17134 + /// + public const string Win10_RS4 = "10.0.17134"; + + /// + /// 1809, RS5, 17763 + /// + public const string Win10_RS5 = "10.0.17763"; + + /// + /// 1903, 19H1, 18362 + /// + public const string Win10_19H1 = "10.0.18362"; + + /// + /// 1909, 19H2, 18363 + /// + public const string Win10_19H2 = "10.0.18363"; + + /// + /// 2004, 20H1, 19033 + /// + public const string Win10_20H1 = "10.0.19033"; + } +} diff --git a/src/Testing/test/AlphabeticalOrderer.cs b/src/Testing/test/AlphabeticalOrderer.cs new file mode 100644 index 000000000000..24970cc281ef --- /dev/null +++ b/src/Testing/test/AlphabeticalOrderer.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Microsoft.AspNetCore.Testing +{ + public class AlphabeticalOrderer : ITestCaseOrderer + { + public IEnumerable OrderTestCases(IEnumerable testCases) + where TTestCase : ITestCase + { + var result = testCases.ToList(); + result.Sort((x, y) => StringComparer.OrdinalIgnoreCase.Compare(x.TestMethod.Method.Name, y.TestMethod.Method.Name)); + return result; + } + } +} diff --git a/src/Testing/test/AssemblyFixtureTest.cs b/src/Testing/test/AssemblyFixtureTest.cs new file mode 100644 index 000000000000..a5fa73019a08 --- /dev/null +++ b/src/Testing/test/AssemblyFixtureTest.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + // We include a collection and assembly fixture to verify that they both still work. + [Collection("MyCollection")] + [TestCaseOrderer("Microsoft.AspNetCore.Testing.AlphabeticalOrderer", "Microsoft.AspNetCore.Testing.Tests")] + public class AssemblyFixtureTest + { + public AssemblyFixtureTest(TestAssemblyFixture assemblyFixture, TestCollectionFixture collectionFixture) + { + AssemblyFixture = assemblyFixture; + CollectionFixture = collectionFixture; + } + + public TestAssemblyFixture AssemblyFixture { get; } + public TestCollectionFixture CollectionFixture { get; } + + [Fact] + public void A() + { + Assert.NotNull(AssemblyFixture); + Assert.Equal(0, AssemblyFixture.Count); + + Assert.NotNull(CollectionFixture); + Assert.Equal(0, CollectionFixture.Count); + + AssemblyFixture.Count++; + CollectionFixture.Count++; + } + + [Fact] + public void B() + { + Assert.Equal(1, AssemblyFixture.Count); + Assert.Equal(1, CollectionFixture.Count); + } + } + + [CollectionDefinition("MyCollection", DisableParallelization = true)] + public class MyCollection : ICollectionFixture + { + } +} diff --git a/src/Testing/test/CollectingEventListenerTest.cs b/src/Testing/test/CollectingEventListenerTest.cs new file mode 100644 index 000000000000..8f131982f0f0 --- /dev/null +++ b/src/Testing/test/CollectingEventListenerTest.cs @@ -0,0 +1,87 @@ +using System.Diagnostics.Tracing; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing.Tracing; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tests +{ + // We are verifying here that when event listener tests are spread among multiple classes, they still + // work, even when run in parallel. To do that we have a bunch of tests in different classes (since + // that affects parallelism) and do some Task.Yielding in them. + public class CollectingEventListenerTests + { + public abstract class CollectingTestBase : EventSourceTestBase + { + [Fact] + public async Task CollectingEventListenerTest() + { + CollectFrom("Microsoft-AspNetCore-Testing-Test"); + + await Task.Yield(); + TestEventSource.Log.Test(); + await Task.Yield(); + TestEventSource.Log.TestWithPayload(42, 4.2); + await Task.Yield(); + + var events = GetEvents(); + EventAssert.Collection(events, + EventAssert.Event(1, "Test", EventLevel.Informational), + EventAssert.Event(2, "TestWithPayload", EventLevel.Verbose) + .Payload("payload1", 42) + .Payload("payload2", 4.2)); + } + } + + // These tests are designed to interfere with the collecting ones by running in parallel and writing events + public abstract class NonCollectingTestBase + { + [Fact] + public async Task CollectingEventListenerTest() + { + await Task.Yield(); + TestEventSource.Log.Test(); + await Task.Yield(); + TestEventSource.Log.TestWithPayload(42, 4.2); + await Task.Yield(); + } + } + + public class CollectingTests + { + public class A : CollectingTestBase { } + public class B : CollectingTestBase { } + public class C : CollectingTestBase { } + public class D : CollectingTestBase { } + public class E : CollectingTestBase { } + public class F : CollectingTestBase { } + public class G : CollectingTestBase { } + } + + public class NonCollectingTests + { + public class A : NonCollectingTestBase { } + public class B : NonCollectingTestBase { } + public class C : NonCollectingTestBase { } + public class D : NonCollectingTestBase { } + public class E : NonCollectingTestBase { } + public class F : NonCollectingTestBase { } + public class G : NonCollectingTestBase { } + } + } + + [EventSource(Name = "Microsoft-AspNetCore-Testing-Test")] + public class TestEventSource : EventSource + { + public static readonly TestEventSource Log = new TestEventSource(); + + private TestEventSource() + { + } + + [Event(eventId: 1, Level = EventLevel.Informational, Message = "Test")] + public void Test() => WriteEvent(1); + + [Event(eventId: 2, Level = EventLevel.Verbose, Message = "Test")] + public void TestWithPayload(int payload1, double payload2) => WriteEvent(2, payload1, payload2); + } +} diff --git a/src/Testing/test/ConditionalFactTest.cs b/src/Testing/test/ConditionalFactTest.cs new file mode 100644 index 000000000000..fefe6c5a42d2 --- /dev/null +++ b/src/Testing/test/ConditionalFactTest.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + [TestCaseOrderer("Microsoft.AspNetCore.Testing.AlphabeticalOrderer", "Microsoft.AspNetCore.Testing.Tests")] + public class ConditionalFactTest : IClassFixture + { + public ConditionalFactTest(ConditionalFactAsserter collector) + { + Asserter = collector; + } + + private ConditionalFactAsserter Asserter { get; } + + [Fact] + public void TestAlwaysRun() + { + // This is required to ensure that the type at least gets initialized. + Assert.True(true); + } + + [ConditionalFact(Skip = "Test is always skipped.")] + public void ConditionalFactSkip() + { + Assert.True(false, "This test should always be skipped."); + } + +#if NETCOREAPP + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR)] + public void ThisTestMustRunOnCoreCLR() + { + Asserter.TestRan = true; + } +#elif NET472 + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CoreCLR)] + public void ThisTestMustRunOnCLR() + { + Asserter.TestRan = true; + } +#else +#error Target frameworks need to be updated. +#endif + + // Test is named this way to be the lowest test in the alphabet, it relies on test ordering + [Fact] + public void ZzzzzzzEnsureThisIsTheLastTest() + { + Assert.True(Asserter.TestRan); + } + + public class ConditionalFactAsserter : IDisposable + { + public bool TestRan { get; set; } + + public void Dispose() + { + } + } + } +} diff --git a/src/Testing/test/ConditionalTheoryTest.cs b/src/Testing/test/ConditionalTheoryTest.cs new file mode 100644 index 000000000000..e88a3334f29d --- /dev/null +++ b/src/Testing/test/ConditionalTheoryTest.cs @@ -0,0 +1,162 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Testing +{ + [TestCaseOrderer("Microsoft.AspNetCore.Testing.AlphabeticalOrderer", "Microsoft.AspNetCore.Testing.Tests")] + public class ConditionalTheoryTest : IClassFixture + { + public ConditionalTheoryTest(ConditionalTheoryAsserter asserter) + { + Asserter = asserter; + } + + public ConditionalTheoryAsserter Asserter { get; } + + [ConditionalTheory(Skip = "Test is always skipped.")] + [InlineData(0)] + public void ConditionalTheorySkip(int arg) + { + Assert.True(false, "This test should always be skipped."); + } + + private static int _conditionalTheoryRuns = 0; + + [ConditionalTheory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2, Skip = "Skip these data")] + public void ConditionalTheoryRunOncePerDataLine(int arg) + { + _conditionalTheoryRuns++; + Assert.True(_conditionalTheoryRuns <= 2, $"Theory should run 2 times, but ran {_conditionalTheoryRuns} times."); + } + + [ConditionalTheory, Trait("Color", "Blue")] + [InlineData(1)] + public void ConditionalTheoriesShouldPreserveTraits(int arg) + { + Assert.True(true); + } + + [ConditionalTheory(Skip = "Skip this")] + [MemberData(nameof(GetInts))] + public void ConditionalTheoriesWithSkippedMemberData(int arg) + { + Assert.True(false, "This should never run"); + } + + private static int _conditionalMemberDataRuns = 0; + + [ConditionalTheory] + [InlineData(4)] + [MemberData(nameof(GetInts))] + public void ConditionalTheoriesWithMemberData(int arg) + { + _conditionalMemberDataRuns++; + Assert.True(_conditionalTheoryRuns <= 3, $"Theory should run 2 times, but ran {_conditionalMemberDataRuns} times."); + } + + public static TheoryData GetInts + => new TheoryData { 0, 1 }; + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Windows)] + [OSSkipCondition(OperatingSystems.MacOSX)] + [OSSkipCondition(OperatingSystems.Linux)] + [MemberData(nameof(GetActionTestData))] + public void ConditionalTheoryWithFuncs(Func func) + { + Assert.True(false, "This should never run"); + } + + [Fact] + public void TestAlwaysRun() + { + // This is required to ensure that this type at least gets initialized. + Assert.True(true); + } + +#if NETCOREAPP + [ConditionalTheory] + [FrameworkSkipCondition(RuntimeFrameworks.CLR)] + [MemberData(nameof(GetInts))] + public void ThisTestMustRunOnCoreCLR(int value) + { + Asserter.TestRan = true; + } +#elif NET472 + [ConditionalTheory] + [FrameworkSkipCondition(RuntimeFrameworks.CoreCLR)] + [MemberData(nameof(GetInts))] + public void ThisTestMustRunOnCLR(int value) + { + Asserter.TestRan = true; + } +#else +#error Target frameworks need to be updated. +#endif + + // Test is named this way to be the lowest test in the alphabet, it relies on test ordering + [Fact] + public void ZzzzzzzEnsureThisIsTheLastTest() + { + Assert.True(Asserter.TestRan); + } + + public static TheoryData> GetActionTestData + => new TheoryData> + { + (i) => i * 1 + }; + + public class ConditionalTheoryAsserter : IDisposable + { + public bool TestRan { get; set; } + + public void Dispose() + { + } + } + + [ConditionalTheory] + [MemberData(nameof(SkippableData))] + public void WithSkipableData(Skippable skippable) + { + Assert.Null(skippable.Skip); + Assert.Equal(1, skippable.Data); + } + + public static TheoryData SkippableData => new TheoryData + { + new Skippable() { Data = 1 }, + new Skippable() { Data = 2, Skip = "This row should be skipped." } + }; + + public class Skippable : IXunitSerializable + { + public Skippable() { } + public int Data { get; set; } + public string Skip { get; set; } + + public void Serialize(IXunitSerializationInfo info) + { + info.AddValue(nameof(Data), Data, typeof(int)); + } + + public void Deserialize(IXunitSerializationInfo info) + { + Data = info.GetValue(nameof(Data)); + } + + public override string ToString() + { + return Data.ToString(); + } + } + } +} diff --git a/src/Testing/test/DockerTests.cs b/src/Testing/test/DockerTests.cs new file mode 100644 index 000000000000..12735057d3a7 --- /dev/null +++ b/src/Testing/test/DockerTests.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class DockerTests + { + [ConditionalFact] + [DockerOnly] + [Trait("Docker", "true")] + public void DoesNotRunOnWindows() + { + Assert.False(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + } + } +} diff --git a/src/Testing/test/EnvironmentVariableSkipConditionTest.cs b/src/Testing/test/EnvironmentVariableSkipConditionTest.cs new file mode 100644 index 000000000000..cbc8e9adadc0 --- /dev/null +++ b/src/Testing/test/EnvironmentVariableSkipConditionTest.cs @@ -0,0 +1,173 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class EnvironmentVariableSkipConditionTest + { + private readonly string _skipReason = "Test skipped on environment variable with name '{0}' and value '{1}'" + + $" for the '{nameof(EnvironmentVariableSkipConditionAttribute.RunOnMatch)}' value of '{{2}}'."; + + [Theory] + [InlineData("false")] + [InlineData("")] + [InlineData(null)] + public void IsMet_DoesNotMatch(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("Run", environmentVariableValue), + "Run", + "true"); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.False(isMet); + } + + [Theory] + [InlineData("True")] + [InlineData("TRUE")] + [InlineData("true")] + public void IsMet_DoesCaseInsensitiveMatch_OnValue(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("Run", environmentVariableValue), + "Run", + "true"); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + Assert.Equal( + string.Format(_skipReason, "Run", environmentVariableValue, attribute.RunOnMatch), + attribute.SkipReason); + } + + [Fact] + public void IsMet_DoesSuccessfulMatch_OnNull() + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("Run", null), + "Run", + "true", null); // skip the test when the variable 'Run' is explicitly set to 'true' or is null (default) + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + Assert.Equal( + string.Format(_skipReason, "Run", "(null)", attribute.RunOnMatch), + attribute.SkipReason); + } + + [Theory] + [InlineData("false")] + [InlineData("")] + [InlineData(null)] + public void IsMet_MatchesOnMultipleSkipValues(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("Run", environmentVariableValue), + "Run", + "false", "", null); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + } + + [Fact] + public void IsMet_DoesNotMatch_OnMultipleSkipValues() + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("Build", "100"), + "Build", + "125", "126"); + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.False(isMet); + } + + [Theory] + [InlineData("CentOS")] + [InlineData(null)] + [InlineData("")] + public void IsMet_Matches_WhenRunOnMatchIsFalse(string environmentVariableValue) + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("LinuxFlavor", environmentVariableValue), + "LinuxFlavor", + "Ubuntu14.04") + { + // Example: Run this test on all OSes except on "Ubuntu14.04" + RunOnMatch = false + }; + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.True(isMet); + } + + [Fact] + public void IsMet_DoesNotMatch_WhenRunOnMatchIsFalse() + { + // Arrange + var attribute = new EnvironmentVariableSkipConditionAttribute( + new TestEnvironmentVariable("LinuxFlavor", "Ubuntu14.04"), + "LinuxFlavor", + "Ubuntu14.04") + { + // Example: Run this test on all OSes except on "Ubuntu14.04" + RunOnMatch = false + }; + + // Act + var isMet = attribute.IsMet; + + // Assert + Assert.False(isMet); + } + + private struct TestEnvironmentVariable : IEnvironmentVariable + { + private readonly string _varName; + + public TestEnvironmentVariable(string varName, string value) + { + _varName = varName; + Value = value; + } + + public string Value { get; private set; } + + public string Get(string name) + { + if(string.Equals(name, _varName, System.StringComparison.OrdinalIgnoreCase)) + { + return Value; + } + return string.Empty; + } + } + } +} diff --git a/src/Testing/test/ExceptionAssertTest.cs b/src/Testing/test/ExceptionAssertTest.cs new file mode 100644 index 000000000000..aa7354dca88d --- /dev/null +++ b/src/Testing/test/ExceptionAssertTest.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class ExceptionAssertTest + { + [Fact] + [ReplaceCulture("fr-FR", "fr-FR")] + public void AssertArgumentNullOrEmptyString_WorksInNonEnglishCultures() + { + // Arrange + Action action = () => + { + throw new ArgumentException("Value cannot be null or an empty string.", "foo"); + }; + + // Act and Assert + ExceptionAssert.ThrowsArgumentNullOrEmptyString(action, "foo"); + } + + [Fact] + [ReplaceCulture("fr-FR", "fr-FR")] + public void AssertArgumentOutOfRangeException_WorksInNonEnglishCultures() + { + // Arrange + Action action = () => + { + throw new ArgumentOutOfRangeException("foo", 10, "exception message."); + }; + + // Act and Assert + ExceptionAssert.ThrowsArgumentOutOfRange(action, "foo", "exception message.", 10); + } + } +} \ No newline at end of file diff --git a/src/Testing/test/FlakyAttributeTest.cs b/src/Testing/test/FlakyAttributeTest.cs new file mode 100644 index 000000000000..ae06e5cf5054 --- /dev/null +++ b/src/Testing/test/FlakyAttributeTest.cs @@ -0,0 +1,99 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tests +{ + public class FlakyAttributeTest + { + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.All)] + public void AlwaysFlakyInCI() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HELIX")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AGENT_OS"))) + { + throw new Exception("Flaky!"); + } + } + + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.Helix.All)] + public void FlakyInHelixOnly() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HELIX"))) + { + throw new Exception("Flaky on Helix!"); + } + } + + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.Helix.macOS1012Amd64, FlakyOn.Helix.Fedora28Amd64)] + public void FlakyInSpecificHelixQueue() + { + // Today we don't run Extensions tests on Helix, but this test should light up when we do. + var queueName = Environment.GetEnvironmentVariable("HELIX"); + if (!string.IsNullOrEmpty(queueName)) + { + var failingQueues = new HashSet(StringComparer.OrdinalIgnoreCase) { HelixQueues.macOS1012Amd64, HelixQueues.Fedora28Amd64 }; + if (failingQueues.Contains(queueName)) + { + throw new Exception($"Flaky on Helix Queue '{queueName}' !"); + } + } + } + + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.AzP.All)] + public void FlakyInAzPOnly() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AGENT_OS"))) + { + throw new Exception("Flaky on AzP!"); + } + } + + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.AzP.Windows)] + public void FlakyInAzPWindowsOnly() + { + if (string.Equals(Environment.GetEnvironmentVariable("AGENT_OS"), "Windows_NT")) + { + throw new Exception("Flaky on AzP Windows!"); + } + } + + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.AzP.macOS)] + public void FlakyInAzPmacOSOnly() + { + if (string.Equals(Environment.GetEnvironmentVariable("AGENT_OS"), "Darwin")) + { + throw new Exception("Flaky on AzP macOS!"); + } + } + + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.AzP.Linux)] + public void FlakyInAzPLinuxOnly() + { + if (string.Equals(Environment.GetEnvironmentVariable("AGENT_OS"), "Linux")) + { + throw new Exception("Flaky on AzP Linux!"); + } + } + + [Fact(Skip = "These tests are nice when you need them but annoying when on all the time.")] + [Flaky("http://example.com", FlakyOn.AzP.Linux, FlakyOn.AzP.macOS)] + public void FlakyInAzPNonWindowsOnly() + { + var agentOs = Environment.GetEnvironmentVariable("AGENT_OS"); + if (string.Equals(agentOs, "Linux") || string.Equals(agentOs, "Darwin")) + { + throw new Exception("Flaky on AzP non-Windows!"); + } + } + } +} diff --git a/src/Testing/test/HttpClientSlimTest.cs b/src/Testing/test/HttpClientSlimTest.cs new file mode 100644 index 000000000000..ede48243e5ab --- /dev/null +++ b/src/Testing/test/HttpClientSlimTest.cs @@ -0,0 +1,116 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class HttpClientSlimTest + { + private static readonly byte[] _defaultResponse = Encoding.ASCII.GetBytes("test"); + + [Fact] + public async Task GetStringAsyncHttp() + { + using (var host = StartHost(out var address)) + { + Assert.Equal("test", await HttpClientSlim.GetStringAsync(address)); + } + } + + [Fact] + public async Task GetStringAsyncThrowsForErrorResponse() + { + using (var host = StartHost(out var address, statusCode: 500)) + { + await Assert.ThrowsAnyAsync(() => HttpClientSlim.GetStringAsync(address)); + } + } + + [Fact] + public async Task PostAsyncHttp() + { + using (var host = StartHost(out var address, handler: context => context.Request.InputStream.CopyToAsync(context.Response.OutputStream))) + { + Assert.Equal("test post", await HttpClientSlim.PostAsync(address, new StringContent("test post"))); + } + } + + [Fact] + public async Task PostAsyncThrowsForErrorResponse() + { + using (var host = StartHost(out var address, statusCode: 500)) + { + await Assert.ThrowsAnyAsync( + () => HttpClientSlim.PostAsync(address, new StringContent(""))); + } + } + + [Fact] + public void Ipv6ScopeIdsFilteredOut() + { + var requestUri = new Uri("http://[fe80::5d2a:d070:6fd6:1bac%7]:5003/"); + Assert.Equal("[fe80::5d2a:d070:6fd6:1bac]:5003", HttpClientSlim.GetHost(requestUri)); + } + + [Fact] + public void GetHostExcludesDefaultPort() + { + var requestUri = new Uri("http://[fe80::5d2a:d070:6fd6:1bac%7]:80/"); + Assert.Equal("[fe80::5d2a:d070:6fd6:1bac]", HttpClientSlim.GetHost(requestUri)); + } + + private HttpListener StartHost(out string address, int statusCode = 200, Func handler = null) + { + var listener = new HttpListener(); + var random = new Random(); + address = null; + + for (var i = 0; i < 10; i++) + { + try + { + // HttpListener doesn't support requesting port 0 (dynamic). + // Requesting port 0 from Sockets and then passing that to HttpListener is racy. + // Just keep trying until we find a free one. + address = $"http://localhost:{random.Next(1024, ushort.MaxValue)}/"; + listener.Prefixes.Add(address); + listener.Start(); + break; + } + catch (HttpListenerException) + { + // Address in use + listener.Close(); + listener = new HttpListener(); + } + } + + Assert.True(listener.IsListening, "IsListening"); + + _ = listener.GetContextAsync().ContinueWith(async task => + { + var context = task.Result; + context.Response.StatusCode = statusCode; + + if (handler == null) + { + await context.Response.OutputStream.WriteAsync(_defaultResponse, 0, _defaultResponse.Length); + } + else + { + await handler(context); + } + + context.Response.Close(); + }); + + return listener; + } + } +} diff --git a/src/Testing/test/MaximumOSVersionAttributeTest.cs b/src/Testing/test/MaximumOSVersionAttributeTest.cs new file mode 100644 index 000000000000..ca71d7063bd0 --- /dev/null +++ b/src/Testing/test/MaximumOSVersionAttributeTest.cs @@ -0,0 +1,89 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class MaximumOSVersionAttributeTest + { + [Fact] + public void Linux_ThrowsNotImplemeneted() + { + Assert.Throws(() => new MaximumOSVersionAttribute(OperatingSystems.Linux, "2.5")); + } + + [Fact] + public void Mac_ThrowsNotImplemeneted() + { + Assert.Throws(() => new MaximumOSVersionAttribute(OperatingSystems.MacOSX, "2.5")); + } + + [Fact] + public void WindowsOrLinux_ThrowsNotImplemeneted() + { + Assert.Throws(() => new MaximumOSVersionAttribute(OperatingSystems.Linux | OperatingSystems.Windows, "2.5")); + } + + [Fact] + public void DoesNotSkip_ShortVersions() + { + var osSkipAttribute = new MaximumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.5"), + OperatingSystems.Windows, + new Version("2.0")); + + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_EarlierVersions() + { + var osSkipAttribute = new MaximumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.5.9"), + OperatingSystems.Windows, + new Version("2.0.10.12")); + + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_SameVersion() + { + var osSkipAttribute = new MaximumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.5.10"), + OperatingSystems.Windows, + new Version("2.5.10.12")); + + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void Skip_LaterVersion() + { + var osSkipAttribute = new MaximumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.5.11"), + OperatingSystems.Windows, + new Version("3.0.10.12")); + + Assert.False(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_WhenOnlyVersionsMatch() + { + var osSkipAttribute = new MaximumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.5.10.12"), + OperatingSystems.Linux, + new Version("2.5.10.12")); + + Assert.True(osSkipAttribute.IsMet); + } + } +} diff --git a/src/Testing/test/MaximumOSVersionTest.cs b/src/Testing/test/MaximumOSVersionTest.cs new file mode 100644 index 000000000000..e18d828fbf4b --- /dev/null +++ b/src/Testing/test/MaximumOSVersionTest.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; +using Microsoft.Win32; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public class MaximumOSVersionTest + { + [ConditionalFact] + [MaximumOSVersion(OperatingSystems.Windows, WindowsVersions.Win7)] + public void RunTest_Win7DoesRunOnWin7() + { + Assert.True( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should only be running on Win7 or Win2008R2."); + } + + [ConditionalTheory] + [MaximumOSVersion(OperatingSystems.Windows, WindowsVersions.Win7)] + [InlineData(1)] + public void RunTheory_Win7DoesRunOnWin7(int arg) + { + Assert.True( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should only be running on Win7 or Win2008R2."); + } + + [ConditionalFact] + [MaximumOSVersion(OperatingSystems.Windows, WindowsVersions.Win10_RS4)] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void RunTest_Win10_RS4() + { + Assert.True(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + var versionKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + Assert.NotNull(versionKey); + var currentVersion = (string)versionKey.GetValue("CurrentBuildNumber"); + Assert.NotNull(currentVersion); + Assert.True(17134 >= int.Parse(currentVersion)); + } + + [ConditionalFact] + [MaximumOSVersion(OperatingSystems.Windows, WindowsVersions.Win10_19H2)] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void RunTest_Win10_19H2() + { + Assert.True(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + var versionKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + Assert.NotNull(versionKey); + var currentVersion = (string)versionKey.GetValue("CurrentBuildNumber"); + Assert.NotNull(currentVersion); + Assert.True(18363 >= int.Parse(currentVersion)); + } + } + + [MaximumOSVersion(OperatingSystems.Windows, WindowsVersions.Win7)] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public class OSMaxVersionClassTest + { + [ConditionalFact] + public void TestSkipClass_Win7DoesRunOnWin7() + { + Assert.True( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should only be running on Win7 or Win2008R2."); + } + } + + // Let this one run cross plat just to check the constructor logic. + [MaximumOSVersion(OperatingSystems.Windows, WindowsVersions.Win7)] + public class OSMaxVersionCrossPlatTest + { + [ConditionalFact] + public void TestSkipClass_Win7DoesRunOnWin7() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.True(Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should only be running on Win7 or Win2008R2."); + } + } + } +} diff --git a/src/Testing/test/Microsoft.AspNetCore.Testing.Tests.csproj b/src/Testing/test/Microsoft.AspNetCore.Testing.Tests.csproj new file mode 100644 index 000000000000..5a7366503d6f --- /dev/null +++ b/src/Testing/test/Microsoft.AspNetCore.Testing.Tests.csproj @@ -0,0 +1,24 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + $(NoWarn);xUnit1004 + + $(NoWarn);xUnit1026 + + + + + + + + + + + + + + + diff --git a/src/Testing/test/MinimumOSVersionAttributeTest.cs b/src/Testing/test/MinimumOSVersionAttributeTest.cs new file mode 100644 index 000000000000..a0a6e84d7df8 --- /dev/null +++ b/src/Testing/test/MinimumOSVersionAttributeTest.cs @@ -0,0 +1,77 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class MinimumOSVersionAttributeTest + { + [Fact] + public void Linux_ThrowsNotImplemeneted() + { + Assert.Throws(() => new MinimumOSVersionAttribute(OperatingSystems.Linux, "2.5")); + } + + [Fact] + public void Mac_ThrowsNotImplemeneted() + { + Assert.Throws(() => new MinimumOSVersionAttribute(OperatingSystems.MacOSX, "2.5")); + } + + [Fact] + public void WindowsOrLinux_ThrowsNotImplemeneted() + { + Assert.Throws(() => new MinimumOSVersionAttribute(OperatingSystems.Linux | OperatingSystems.Windows, "2.5")); + } + + [Fact] + public void DoesNotSkip_LaterVersions() + { + var osSkipAttribute = new MinimumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.0"), + OperatingSystems.Windows, + new Version("2.5")); + + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_SameVersion() + { + var osSkipAttribute = new MinimumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.5"), + OperatingSystems.Windows, + new Version("2.5")); + + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void Skip_EarlierVersion() + { + var osSkipAttribute = new MinimumOSVersionAttribute( + OperatingSystems.Windows, + new Version("3.0"), + OperatingSystems.Windows, + new Version("2.5")); + + Assert.False(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_WhenOnlyVersionsMatch() + { + var osSkipAttribute = new MinimumOSVersionAttribute( + OperatingSystems.Windows, + new Version("2.5"), + OperatingSystems.Linux, + new Version("2.5")); + + Assert.True(osSkipAttribute.IsMet); + } + } +} diff --git a/src/Testing/test/MinimumOSVersionTest.cs b/src/Testing/test/MinimumOSVersionTest.cs new file mode 100644 index 000000000000..b218cd1ec5ef --- /dev/null +++ b/src/Testing/test/MinimumOSVersionTest.cs @@ -0,0 +1,73 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; +using Microsoft.Win32; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class MinimumOSVersionTest + { + [ConditionalFact] + [MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win8)] + public void RunTest_Win8DoesNotRunOnWin7() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should not be running on Win7 or Win2008R2."); + } + + [ConditionalTheory] + [MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win8)] + [InlineData(1)] + public void RunTheory_Win8DoesNotRunOnWin7(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should not be running on Win7 or Win2008R2."); + } + + [ConditionalFact] + [MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win10_RS4)] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void RunTest_Win10_RS4() + { + Assert.True(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + var versionKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + Assert.NotNull(versionKey); + var currentVersion = (string)versionKey.GetValue("CurrentBuildNumber"); + Assert.NotNull(currentVersion); + Assert.True(17134 <= int.Parse(currentVersion)); + } + + [ConditionalFact] + [MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win10_19H2)] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void RunTest_Win10_19H2() + { + Assert.True(RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); + var versionKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + Assert.NotNull(versionKey); + var currentVersion = (string)versionKey.GetValue("CurrentBuildNumber"); + Assert.NotNull(currentVersion); + Assert.True(18363 <= int.Parse(currentVersion)); + } + } + + [MinimumOSVersion(OperatingSystems.Windows, WindowsVersions.Win8)] + public class OSMinVersionClassTest + { + [ConditionalFact] + public void TestSkipClass_Win8DoesNotRunOnWin7() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + Environment.OSVersion.Version.ToString().StartsWith("6.1"), + "Test should not be running on Win7 or Win2008R2."); + } + } +} diff --git a/src/Testing/test/OSSkipConditionAttributeTest.cs b/src/Testing/test/OSSkipConditionAttributeTest.cs new file mode 100644 index 000000000000..d4bc4f2b7418 --- /dev/null +++ b/src/Testing/test/OSSkipConditionAttributeTest.cs @@ -0,0 +1,72 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class OSSkipConditionAttributeTest + { + [Fact] + public void Skips_WhenOperatingSystemMatches() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Windows, + OperatingSystems.Windows); + + // Assert + Assert.False(osSkipAttribute.IsMet); + } + + [Fact] + public void DoesNotSkip_WhenOperatingSystemDoesNotMatch() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute( + OperatingSystems.Linux, + OperatingSystems.Windows); + + // Assert + Assert.True(osSkipAttribute.IsMet); + } + + [Fact] + public void Skips_BothMacOSXAndLinux() + { + // Act + var osSkipAttributeLinux = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.MacOSX, OperatingSystems.Linux); + var osSkipAttributeMacOSX = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.MacOSX, OperatingSystems.MacOSX); + + // Assert + Assert.False(osSkipAttributeLinux.IsMet); + Assert.False(osSkipAttributeMacOSX.IsMet); + } + + [Fact] + public void Skips_BothMacOSXAndWindows() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute(OperatingSystems.Windows | OperatingSystems.MacOSX, OperatingSystems.Windows); + var osSkipAttributeMacOSX = new OSSkipConditionAttribute(OperatingSystems.Windows | OperatingSystems.MacOSX, OperatingSystems.MacOSX); + + // Assert + Assert.False(osSkipAttribute.IsMet); + Assert.False(osSkipAttributeMacOSX.IsMet); + } + + [Fact] + public void Skips_BothWindowsAndLinux() + { + // Act + var osSkipAttribute = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.Windows, OperatingSystems.Windows); + var osSkipAttributeLinux = new OSSkipConditionAttribute(OperatingSystems.Linux | OperatingSystems.Windows, OperatingSystems.Linux); + + // Assert + Assert.False(osSkipAttribute.IsMet); + Assert.False(osSkipAttributeLinux.IsMet); + } + } +} diff --git a/src/Testing/test/OSSkipConditionTest.cs b/src/Testing/test/OSSkipConditionTest.cs new file mode 100644 index 000000000000..6aeecaddccb6 --- /dev/null +++ b/src/Testing/test/OSSkipConditionTest.cs @@ -0,0 +1,105 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.InteropServices; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class OSSkipConditionTest + { + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + public void TestSkipLinux() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux"); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX)] + public void TestSkipMacOSX() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Windows)] + public void TestSkipWindows() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Test should not be running on Windows."); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + public void TestSkipLinuxAndMacOSX() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux."); + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Linux)] + [InlineData(1)] + public void TestTheorySkipLinux(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux"); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.MacOSX)] + [InlineData(1)] + public void TestTheorySkipMacOS(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Windows)] + [InlineData(1)] + public void TestTheorySkipWindows(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Test should not be running on Windows."); + } + + [ConditionalTheory] + [OSSkipCondition(OperatingSystems.Linux | OperatingSystems.MacOSX)] + [InlineData(1)] + public void TestTheorySkipLinuxAndMacOSX(int arg) + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Linux), + "Test should not be running on Linux."); + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.OSX), + "Test should not be running on MacOSX."); + } + } + + [OSSkipCondition(OperatingSystems.Windows)] + public class OSSkipConditionClassTest + { + [ConditionalFact] + public void TestSkipClassWindows() + { + Assert.False( + RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + "Test should not be running on Windows."); + } + } +} diff --git a/src/Testing/test/Properties/AssemblyInfo.cs b/src/Testing/test/Properties/AssemblyInfo.cs new file mode 100644 index 000000000000..d585b5ed95b8 --- /dev/null +++ b/src/Testing/test/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using Microsoft.AspNetCore.Testing; +using Xunit; + +[assembly: Repeat(1)] +[assembly: AssemblyFixture(typeof(TestAssemblyFixture))] +[assembly: TestFramework("Microsoft.AspNetCore.Testing.AspNetTestFramework", "Microsoft.AspNetCore.Testing")] diff --git a/src/Testing/test/RepeatTest.cs b/src/Testing/test/RepeatTest.cs new file mode 100644 index 000000000000..0d995fad5902 --- /dev/null +++ b/src/Testing/test/RepeatTest.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + [Repeat] + public class RepeatTest + { + public static int _runCount = 0; + + [Fact] + [Repeat(5)] + public void RepeatLimitIsSetCorrectly() + { + Assert.Equal(5, RepeatContext.Current.Limit); + } + + [Fact] + [Repeat(5)] + public void RepeatRunsTestSpecifiedNumberOfTimes() + { + Assert.Equal(RepeatContext.Current.CurrentIteration, _runCount); + _runCount++; + } + + [Fact] + public void RepeatCanBeSetOnClass() + { + Assert.Equal(10, RepeatContext.Current.Limit); + } + } + + public class LoggedTestXunitRepeatAssemblyTests + { + [Fact] + public void RepeatCanBeSetOnAssembly() + { + Assert.Equal(1, RepeatContext.Current.Limit); + } + } +} diff --git a/src/Testing/test/ReplaceCultureAttributeTest.cs b/src/Testing/test/ReplaceCultureAttributeTest.cs new file mode 100644 index 000000000000..6b8df346c93e --- /dev/null +++ b/src/Testing/test/ReplaceCultureAttributeTest.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Globalization; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class RepalceCultureAttributeTest + { + [Fact] + public void DefaultsTo_EnGB_EnUS() + { + // Arrange + var culture = new CultureInfo("en-GB"); + var uiCulture = new CultureInfo("en-US"); + + // Act + var replaceCulture = new ReplaceCultureAttribute(); + + // Assert + Assert.Equal(culture, replaceCulture.Culture); + Assert.Equal(uiCulture, replaceCulture.UICulture); + } + + [Fact] + public void UsesSuppliedCultureAndUICulture() + { + // Arrange + var culture = "de-DE"; + var uiCulture = "fr-CA"; + + // Act + var replaceCulture = new ReplaceCultureAttribute(culture, uiCulture); + + // Assert + Assert.Equal(new CultureInfo(culture), replaceCulture.Culture); + Assert.Equal(new CultureInfo(uiCulture), replaceCulture.UICulture); + } + + [Fact] + public void BeforeAndAfterTest_ReplacesCulture() + { + // Arrange + var originalCulture = CultureInfo.CurrentCulture; + var originalUICulture = CultureInfo.CurrentUICulture; + var culture = "de-DE"; + var uiCulture = "fr-CA"; + var replaceCulture = new ReplaceCultureAttribute(culture, uiCulture); + + // Act + replaceCulture.Before(methodUnderTest: null); + + // Assert + Assert.Equal(new CultureInfo(culture), CultureInfo.CurrentCulture); + Assert.Equal(new CultureInfo(uiCulture), CultureInfo.CurrentUICulture); + + // Act + replaceCulture.After(methodUnderTest: null); + + // Assert + Assert.Equal(originalCulture, CultureInfo.CurrentCulture); + Assert.Equal(originalUICulture, CultureInfo.CurrentUICulture); + } + } +} \ No newline at end of file diff --git a/src/Testing/test/SkipOnCITests.cs b/src/Testing/test/SkipOnCITests.cs new file mode 100644 index 000000000000..8df5e73c3027 --- /dev/null +++ b/src/Testing/test/SkipOnCITests.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Testing.Tests +{ + public class SkipOnCITests + { + [ConditionalFact] + [SkipOnCI] + public void AlwaysSkipOnCI() + { + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HELIX")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AGENT_OS"))) + { + throw new Exception("Flaky!"); + } + } + } +} diff --git a/src/Testing/test/TaskExtensionsTest.cs b/src/Testing/test/TaskExtensionsTest.cs new file mode 100644 index 000000000000..f7ad603df551 --- /dev/null +++ b/src/Testing/test/TaskExtensionsTest.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class TaskExtensionsTest + { + [Fact] + public async Task TimeoutAfterTest() + { + await Assert.ThrowsAsync(async () => await Task.Delay(1000).TimeoutAfter(TimeSpan.FromMilliseconds(50))); + } + } +} diff --git a/src/Testing/test/TestAssemblyFixture.cs b/src/Testing/test/TestAssemblyFixture.cs new file mode 100644 index 000000000000..44308160bd44 --- /dev/null +++ b/src/Testing/test/TestAssemblyFixture.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Testing +{ + public class TestAssemblyFixture + { + public int Count { get; set; } + } +} diff --git a/src/Testing/test/TestCollectionFixture.cs b/src/Testing/test/TestCollectionFixture.cs new file mode 100644 index 000000000000..b9aed01e4138 --- /dev/null +++ b/src/Testing/test/TestCollectionFixture.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Testing +{ + public class TestCollectionFixture + { + public int Count { get; set; } + } +} diff --git a/src/Testing/test/TestContextTest.cs b/src/Testing/test/TestContextTest.cs new file mode 100644 index 000000000000..944d706477d6 --- /dev/null +++ b/src/Testing/test/TestContextTest.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class TestContextTest : ITestMethodLifecycle + { + public TestContext Context { get; private set; } + + [Fact] + public void FullName_IsUsed_ByDefault() + { + Assert.Equal(GetType().FullName, Context.FileOutput.TestClassName); + } + + Task ITestMethodLifecycle.OnTestStartAsync(TestContext context, CancellationToken cancellationToken) + { + Context = context; + return Task.CompletedTask; + } + + Task ITestMethodLifecycle.OnTestEndAsync(TestContext context, Exception exception, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} + +namespace Microsoft.AspNetCore.Testing.Tests +{ + public class TestContextNameShorteningTest : ITestMethodLifecycle + { + public TestContext Context { get; private set; } + + [Fact] + public void NameIsShortenedWhenAssemblyNameIsAPrefix() + { + Assert.Equal(GetType().Name, Context.FileOutput.TestClassName); + } + + Task ITestMethodLifecycle.OnTestStartAsync(TestContext context, CancellationToken cancellationToken) + { + Context = context; + return Task.CompletedTask; + } + + Task ITestMethodLifecycle.OnTestEndAsync(TestContext context, Exception exception, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} + +namespace Microsoft.AspNetCore.Testing +{ + [ShortClassName] + public class TestContextTestClassShortNameAttributeTest : ITestMethodLifecycle + { + public TestContext Context { get; private set; } + + [Fact] + public void ShortClassNameUsedWhenShortClassNameAttributeSpecified() + { + Assert.Equal(GetType().Name, Context.FileOutput.TestClassName); + } + + Task ITestMethodLifecycle.OnTestStartAsync(TestContext context, CancellationToken cancellationToken) + { + Context = context; + return Task.CompletedTask; + } + + Task ITestMethodLifecycle.OnTestEndAsync(TestContext context, Exception exception, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/Testing/test/TestPathUtilitiesTest.cs b/src/Testing/test/TestPathUtilitiesTest.cs new file mode 100644 index 000000000000..024f476f07f6 --- /dev/null +++ b/src/Testing/test/TestPathUtilitiesTest.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class TestPathUtilitiesTest + { + // Entire test pending removal - see https://github.com/dotnet/extensions/issues/1697 +#pragma warning disable 0618 + + [Fact] + public void GetSolutionRootDirectory_ResolvesSolutionRoot() + { + // Directory.GetCurrentDirectory() gives: + // Testing\test\Microsoft.AspNetCore.Testing.Tests\bin\Debug\netcoreapp2.0 + // Testing\test\Microsoft.AspNetCore.Testing.Tests\bin\Debug\net461 + // Testing\test\Microsoft.AspNetCore.Testing.Tests\bin\Debug\net46 + var expectedPath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "..", "..")); + + Assert.Equal(expectedPath, TestPathUtilities.GetSolutionRootDirectory("Extensions")); + } + + [Fact] + public void GetSolutionRootDirectory_Throws_IfNotFound() + { + var exception = Assert.Throws(() => TestPathUtilities.GetSolutionRootDirectory("NotTesting")); + Assert.Equal($"Solution file NotTesting.sln could not be found in {AppContext.BaseDirectory} or its parent directories.", exception.Message); + } +#pragma warning restore 0618 + } +} diff --git a/src/Testing/test/TestPlatformHelperTest.cs b/src/Testing/test/TestPlatformHelperTest.cs new file mode 100644 index 000000000000..b1c2fbf2f82d --- /dev/null +++ b/src/Testing/test/TestPlatformHelperTest.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Testing; +using Xunit; + +namespace Microsoft.AspNetCore.Testing +{ + public class TestPlatformHelperTest + { + [ConditionalFact] + [OSSkipCondition(OperatingSystems.MacOSX)] + [OSSkipCondition(OperatingSystems.Windows)] + public void IsLinux_TrueOnLinux() + { + Assert.True(TestPlatformHelper.IsLinux); + Assert.False(TestPlatformHelper.IsMac); + Assert.False(TestPlatformHelper.IsWindows); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + [OSSkipCondition(OperatingSystems.Windows)] + public void IsMac_TrueOnMac() + { + Assert.False(TestPlatformHelper.IsLinux); + Assert.True(TestPlatformHelper.IsMac); + Assert.False(TestPlatformHelper.IsWindows); + } + + [ConditionalFact] + [OSSkipCondition(OperatingSystems.Linux)] + [OSSkipCondition(OperatingSystems.MacOSX)] + public void IsWindows_TrueOnWindows() + { + Assert.False(TestPlatformHelper.IsLinux); + Assert.False(TestPlatformHelper.IsMac); + Assert.True(TestPlatformHelper.IsWindows); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.CLR | RuntimeFrameworks.CoreCLR | RuntimeFrameworks.None)] + public void IsMono_TrueOnMono() + { + Assert.True(TestPlatformHelper.IsMono); + } + + [ConditionalFact] + [FrameworkSkipCondition(RuntimeFrameworks.Mono)] + public void IsMono_FalseElsewhere() + { + Assert.False(TestPlatformHelper.IsMono); + } + } +} diff --git a/src/WebEncoders/Directory.Build.props b/src/WebEncoders/Directory.Build.props new file mode 100644 index 000000000000..81557e1bcaf6 --- /dev/null +++ b/src/WebEncoders/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + true + + + diff --git a/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.csproj b/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.csproj new file mode 100644 index 000000000000..5beee97dd6fd --- /dev/null +++ b/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0;$(DefaultNetCoreTargetFramework) + + + + + + + + + + + + + diff --git a/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.netcoreapp.cs b/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.netcoreapp.cs new file mode 100644 index 000000000000..ad8e11a40eae --- /dev/null +++ b/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.netcoreapp.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class EncoderServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddWebEncoders(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddWebEncoders(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) { throw null; } + } +} +namespace Microsoft.Extensions.WebEncoders +{ + public sealed partial class WebEncoderOptions + { + public WebEncoderOptions() { } + public System.Text.Encodings.Web.TextEncoderSettings TextEncoderSettings { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } +} +namespace Microsoft.Extensions.WebEncoders.Testing +{ + public sealed partial class HtmlTestEncoder : System.Text.Encodings.Web.HtmlEncoder + { + public HtmlTestEncoder() { } + public override int MaxOutputCharactersPerInputCharacter { get { throw null; } } + public override void Encode(System.IO.TextWriter output, char[] value, int startIndex, int characterCount) { } + public override void Encode(System.IO.TextWriter output, string value, int startIndex, int characterCount) { } + public override string Encode(string value) { throw null; } + public unsafe override int FindFirstCharacterToEncode(char* text, int textLength) { throw null; } + public unsafe override bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) { throw null; } + public override bool WillEncode(int unicodeScalar) { throw null; } + } + public partial class JavaScriptTestEncoder : System.Text.Encodings.Web.JavaScriptEncoder + { + public JavaScriptTestEncoder() { } + public override int MaxOutputCharactersPerInputCharacter { get { throw null; } } + public override void Encode(System.IO.TextWriter output, char[] value, int startIndex, int characterCount) { } + public override void Encode(System.IO.TextWriter output, string value, int startIndex, int characterCount) { } + public override string Encode(string value) { throw null; } + public unsafe override int FindFirstCharacterToEncode(char* text, int textLength) { throw null; } + public unsafe override bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) { throw null; } + public override bool WillEncode(int unicodeScalar) { throw null; } + } + public partial class UrlTestEncoder : System.Text.Encodings.Web.UrlEncoder + { + public UrlTestEncoder() { } + public override int MaxOutputCharactersPerInputCharacter { get { throw null; } } + public override void Encode(System.IO.TextWriter output, char[] value, int startIndex, int characterCount) { } + public override void Encode(System.IO.TextWriter output, string value, int startIndex, int characterCount) { } + public override string Encode(string value) { throw null; } + public unsafe override int FindFirstCharacterToEncode(char* text, int textLength) { throw null; } + public unsafe override bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) { throw null; } + public override bool WillEncode(int unicodeScalar) { throw null; } + } +} diff --git a/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.netstandard2.0.cs b/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.netstandard2.0.cs new file mode 100644 index 000000000000..ad8e11a40eae --- /dev/null +++ b/src/WebEncoders/ref/Microsoft.Extensions.WebEncoders.netstandard2.0.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.Extensions.DependencyInjection +{ + public static partial class EncoderServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddWebEncoders(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddWebEncoders(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action setupAction) { throw null; } + } +} +namespace Microsoft.Extensions.WebEncoders +{ + public sealed partial class WebEncoderOptions + { + public WebEncoderOptions() { } + public System.Text.Encodings.Web.TextEncoderSettings TextEncoderSettings { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + } +} +namespace Microsoft.Extensions.WebEncoders.Testing +{ + public sealed partial class HtmlTestEncoder : System.Text.Encodings.Web.HtmlEncoder + { + public HtmlTestEncoder() { } + public override int MaxOutputCharactersPerInputCharacter { get { throw null; } } + public override void Encode(System.IO.TextWriter output, char[] value, int startIndex, int characterCount) { } + public override void Encode(System.IO.TextWriter output, string value, int startIndex, int characterCount) { } + public override string Encode(string value) { throw null; } + public unsafe override int FindFirstCharacterToEncode(char* text, int textLength) { throw null; } + public unsafe override bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) { throw null; } + public override bool WillEncode(int unicodeScalar) { throw null; } + } + public partial class JavaScriptTestEncoder : System.Text.Encodings.Web.JavaScriptEncoder + { + public JavaScriptTestEncoder() { } + public override int MaxOutputCharactersPerInputCharacter { get { throw null; } } + public override void Encode(System.IO.TextWriter output, char[] value, int startIndex, int characterCount) { } + public override void Encode(System.IO.TextWriter output, string value, int startIndex, int characterCount) { } + public override string Encode(string value) { throw null; } + public unsafe override int FindFirstCharacterToEncode(char* text, int textLength) { throw null; } + public unsafe override bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) { throw null; } + public override bool WillEncode(int unicodeScalar) { throw null; } + } + public partial class UrlTestEncoder : System.Text.Encodings.Web.UrlEncoder + { + public UrlTestEncoder() { } + public override int MaxOutputCharactersPerInputCharacter { get { throw null; } } + public override void Encode(System.IO.TextWriter output, char[] value, int startIndex, int characterCount) { } + public override void Encode(System.IO.TextWriter output, string value, int startIndex, int characterCount) { } + public override string Encode(string value) { throw null; } + public unsafe override int FindFirstCharacterToEncode(char* text, int textLength) { throw null; } + public unsafe override bool TryEncodeUnicodeScalar(int unicodeScalar, char* buffer, int bufferLength, out int numberOfCharactersWritten) { throw null; } + public override bool WillEncode(int unicodeScalar) { throw null; } + } +} diff --git a/src/WebEncoders/src/EncoderServiceCollectionExtensions.cs b/src/WebEncoders/src/EncoderServiceCollectionExtensions.cs new file mode 100644 index 000000000000..72f5e369a1cc --- /dev/null +++ b/src/WebEncoders/src/EncoderServiceCollectionExtensions.cs @@ -0,0 +1,83 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Text.Encodings.Web; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.WebEncoders; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods for setting up web encoding services in an . + /// + public static class EncoderServiceCollectionExtensions + { + /// + /// Adds , and + /// to the specified . + /// + /// The . + /// The so that additional calls can be chained. + public static IServiceCollection AddWebEncoders(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddOptions(); + + // Register the default encoders + // We want to call the 'Default' property getters lazily since they perform static caching + services.TryAddSingleton( + CreateFactory(() => HtmlEncoder.Default, settings => HtmlEncoder.Create(settings))); + services.TryAddSingleton( + CreateFactory(() => JavaScriptEncoder.Default, settings => JavaScriptEncoder.Create(settings))); + services.TryAddSingleton( + CreateFactory(() => UrlEncoder.Default, settings => UrlEncoder.Create(settings))); + + return services; + } + + /// + /// Adds , and + /// to the specified . + /// + /// The . + /// An to configure the provided . + /// The so that additional calls can be chained. + public static IServiceCollection AddWebEncoders(this IServiceCollection services, Action setupAction) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (setupAction == null) + { + throw new ArgumentNullException(nameof(setupAction)); + } + + services.AddWebEncoders(); + services.Configure(setupAction); + + return services; + } + + private static Func CreateFactory( + Func defaultFactory, + Func customSettingsFactory) + { + return serviceProvider => + { + var settings = serviceProvider + ?.GetService>() + ?.Value + ?.TextEncoderSettings; + return (settings != null) ? customSettingsFactory(settings) : defaultFactory(); + }; + } + } +} diff --git a/src/WebEncoders/src/Microsoft.Extensions.WebEncoders.csproj b/src/WebEncoders/src/Microsoft.Extensions.WebEncoders.csproj new file mode 100644 index 000000000000..364f4d3d8663 --- /dev/null +++ b/src/WebEncoders/src/Microsoft.Extensions.WebEncoders.csproj @@ -0,0 +1,23 @@ + + + + Contains registration and configuration APIs to add the core framework encoders to a dependency injection container. + netstandard2.0;$(DefaultNetCoreTargetFramework) + $(DefaultNetCoreTargetFramework) + $(NoWarn);CS1591 + true + aspnetcore + true + true + + + + + + + + + + + + diff --git a/src/WebEncoders/src/Testing/HtmlTestEncoder.cs b/src/WebEncoders/src/Testing/HtmlTestEncoder.cs new file mode 100644 index 000000000000..162ce4f6c1cb --- /dev/null +++ b/src/WebEncoders/src/Testing/HtmlTestEncoder.cs @@ -0,0 +1,104 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text.Encodings.Web; + +namespace Microsoft.Extensions.WebEncoders.Testing +{ + /// + /// Encoder used for unit testing. + /// + public sealed class HtmlTestEncoder : HtmlEncoder + { + public override int MaxOutputCharactersPerInputCharacter + { + get { return 1; } + } + + public override string Encode(string value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (value.Length == 0) + { + return string.Empty; + } + + return $"HtmlEncode[[{value}]]"; + } + + public override void Encode(TextWriter output, char[] value, int startIndex, int characterCount) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (characterCount == 0) + { + return; + } + + output.Write("HtmlEncode[["); + output.Write(value, startIndex, characterCount); + output.Write("]]"); + } + + public override void Encode(TextWriter output, string value, int startIndex, int characterCount) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (characterCount == 0) + { + return; + } + + output.Write("HtmlEncode[["); + output.Write(value.Substring(startIndex, characterCount)); + output.Write("]]"); + } + + public override bool WillEncode(int unicodeScalar) + { + return false; + } + + public override unsafe int FindFirstCharacterToEncode(char* text, int textLength) + { + return -1; + } + + public override unsafe bool TryEncodeUnicodeScalar( + int unicodeScalar, + char* buffer, + int bufferLength, + out int numberOfCharactersWritten) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + + numberOfCharactersWritten = 0; + return false; + } + } +} \ No newline at end of file diff --git a/src/WebEncoders/src/Testing/JavaScriptTestEncoder.cs b/src/WebEncoders/src/Testing/JavaScriptTestEncoder.cs new file mode 100644 index 000000000000..bef44616760c --- /dev/null +++ b/src/WebEncoders/src/Testing/JavaScriptTestEncoder.cs @@ -0,0 +1,104 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text.Encodings.Web; + +namespace Microsoft.Extensions.WebEncoders.Testing +{ + /// + /// Encoder used for unit testing. + /// + public class JavaScriptTestEncoder : JavaScriptEncoder + { + public override int MaxOutputCharactersPerInputCharacter + { + get { return 1; } + } + + public override string Encode(string value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (value.Length == 0) + { + return string.Empty; + } + + return $"JavaScriptEncode[[{value}]]"; + } + + public override void Encode(TextWriter output, char[] value, int startIndex, int characterCount) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (characterCount == 0) + { + return; + } + + output.Write("JavaScriptEncode[["); + output.Write(value, startIndex, characterCount); + output.Write("]]"); + } + + public override void Encode(TextWriter output, string value, int startIndex, int characterCount) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (characterCount == 0) + { + return; + } + + output.Write("JavaScriptEncode[["); + output.Write(value.Substring(startIndex, characterCount)); + output.Write("]]"); + } + + public override bool WillEncode(int unicodeScalar) + { + return false; + } + + public override unsafe int FindFirstCharacterToEncode(char* text, int textLength) + { + return -1; + } + + public override unsafe bool TryEncodeUnicodeScalar( + int unicodeScalar, + char* buffer, + int bufferLength, + out int numberOfCharactersWritten) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + + numberOfCharactersWritten = 0; + return false; + } + } +} \ No newline at end of file diff --git a/src/WebEncoders/src/Testing/UrlTestEncoder.cs b/src/WebEncoders/src/Testing/UrlTestEncoder.cs new file mode 100644 index 000000000000..295bda63e8d5 --- /dev/null +++ b/src/WebEncoders/src/Testing/UrlTestEncoder.cs @@ -0,0 +1,104 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Text.Encodings.Web; + +namespace Microsoft.Extensions.WebEncoders.Testing +{ + /// + /// Encoder used for unit testing. + /// + public class UrlTestEncoder : UrlEncoder + { + public override int MaxOutputCharactersPerInputCharacter + { + get { return 1; } + } + + public override string Encode(string value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (value.Length == 0) + { + return string.Empty; + } + + return $"UrlEncode[[{value}]]"; + } + + public override void Encode(TextWriter output, char[] value, int startIndex, int characterCount) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (characterCount == 0) + { + return; + } + + output.Write("UrlEncode[["); + output.Write(value, startIndex, characterCount); + output.Write("]]"); + } + + public override void Encode(TextWriter output, string value, int startIndex, int characterCount) + { + if (output == null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (characterCount == 0) + { + return; + } + + output.Write("UrlEncode[["); + output.Write(value.Substring(startIndex, characterCount)); + output.Write("]]"); + } + + public override bool WillEncode(int unicodeScalar) + { + return false; + } + + public override unsafe int FindFirstCharacterToEncode(char* text, int textLength) + { + return -1; + } + + public override unsafe bool TryEncodeUnicodeScalar( + int unicodeScalar, + char* buffer, + int bufferLength, + out int numberOfCharactersWritten) + { + if (buffer == null) + { + throw new ArgumentNullException(nameof(buffer)); + } + + numberOfCharactersWritten = 0; + return false; + } + } +} \ No newline at end of file diff --git a/src/WebEncoders/src/WebEncoderOptions.cs b/src/WebEncoders/src/WebEncoderOptions.cs new file mode 100644 index 000000000000..2f5e770a0c37 --- /dev/null +++ b/src/WebEncoders/src/WebEncoderOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Encodings.Web; + +namespace Microsoft.Extensions.WebEncoders +{ + /// + /// Specifies options common to all three encoders (HtmlEncode, JavaScriptEncode, UrlEncode). + /// + public sealed class WebEncoderOptions + { + /// + /// Specifies which code points are allowed to be represented unescaped by the encoders. + /// + /// + /// If this property is null, then the encoders will use their default allow lists. + /// + public TextEncoderSettings TextEncoderSettings { get; set; } + } +} diff --git a/src/WebEncoders/test/EncoderServiceCollectionExtensionsTests.cs b/src/WebEncoders/test/EncoderServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000000..0178bba2d5b9 --- /dev/null +++ b/src/WebEncoders/test/EncoderServiceCollectionExtensionsTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Text.Encodings.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.WebEncoders.Testing; +using Xunit; + +namespace Microsoft.Extensions.WebEncoders +{ + public class EncoderServiceCollectionExtensionsTests + { + [Fact] + public void AddWebEncoders_WithoutOptions_RegistersDefaultEncoders() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + serviceCollection.AddWebEncoders(); + + // Assert + var serviceProvider = serviceCollection.BuildServiceProvider(); + Assert.Same(HtmlEncoder.Default, serviceProvider.GetRequiredService()); // default encoder + Assert.Same(HtmlEncoder.Default, serviceProvider.GetRequiredService()); // as singleton instance + Assert.Same(JavaScriptEncoder.Default, serviceProvider.GetRequiredService()); // default encoder + Assert.Same(JavaScriptEncoder.Default, serviceProvider.GetRequiredService()); // as singleton instance + Assert.Same(UrlEncoder.Default, serviceProvider.GetRequiredService()); // default encoder + Assert.Same(UrlEncoder.Default, serviceProvider.GetRequiredService()); // as singleton instance + } + + [Fact] + public void AddWebEncoders_WithOptions_RegistersEncodersWithCustomCodeFilter() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + serviceCollection.AddWebEncoders(options => + { + options.TextEncoderSettings = new TextEncoderSettings(); + options.TextEncoderSettings.AllowCharacters("ace".ToCharArray()); // only these three chars are allowed + }); + + // Assert + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var htmlEncoder = serviceProvider.GetRequiredService(); + Assert.Equal("abcde", htmlEncoder.Encode("abcde")); + Assert.Same(htmlEncoder, serviceProvider.GetRequiredService()); // as singleton instance + + var javaScriptEncoder = serviceProvider.GetRequiredService(); + Assert.Equal(@"a\u0062c\u0064e", javaScriptEncoder.Encode("abcde")); + Assert.Same(javaScriptEncoder, serviceProvider.GetRequiredService()); // as singleton instance + + var urlEncoder = serviceProvider.GetRequiredService(); + Assert.Equal("a%62c%64e", urlEncoder.Encode("abcde")); + Assert.Same(urlEncoder, serviceProvider.GetRequiredService()); // as singleton instance + } + + [Fact] + public void AddWebEncoders_DoesNotOverrideExistingRegisteredEncoders() + { + // Arrange + var serviceCollection = new ServiceCollection(); + + // Act + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + // we don't register an existing URL encoder + serviceCollection.AddWebEncoders(options => + { + options.TextEncoderSettings = new TextEncoderSettings(); + options.TextEncoderSettings.AllowCharacters("ace".ToCharArray()); // only these three chars are allowed + }); + + // Assert + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var htmlEncoder = serviceProvider.GetRequiredService(); + Assert.Equal("HtmlEncode[[abcde]]", htmlEncoder.Encode("abcde")); + + var javaScriptEncoder = serviceProvider.GetRequiredService(); + Assert.Equal("JavaScriptEncode[[abcde]]", javaScriptEncoder.Encode("abcde")); + + var urlEncoder = serviceProvider.GetRequiredService(); + Assert.Equal("a%62c%64e", urlEncoder.Encode("abcde")); + } + } +} diff --git a/src/WebEncoders/test/HtmlTestEncoderTest.cs b/src/WebEncoders/test/HtmlTestEncoderTest.cs new file mode 100644 index 000000000000..baafedc4de96 --- /dev/null +++ b/src/WebEncoders/test/HtmlTestEncoderTest.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Xunit; + +namespace Microsoft.Extensions.WebEncoders.Testing +{ + public class HtmlTestEncoderTest + { + [Theory] + [InlineData("", "")] + [InlineData("abcd", "HtmlEncode[[abcd]]")] + [InlineData("<<''\"\">>", "HtmlEncode[[<<''\"\">>]]")] + public void StringEncode_EncodesAsExpected(string input, string expectedOutput) + { + // Arrange + var encoder = new HtmlTestEncoder(); + + // Act + var output = encoder.Encode(input); + + // Assert + Assert.Equal(expectedOutput, output); + } + } +} diff --git a/src/WebEncoders/test/Microsoft.Extensions.WebEncoders.Tests.csproj b/src/WebEncoders/test/Microsoft.Extensions.WebEncoders.Tests.csproj new file mode 100755 index 000000000000..3bf6fc1c9cd1 --- /dev/null +++ b/src/WebEncoders/test/Microsoft.Extensions.WebEncoders.Tests.csproj @@ -0,0 +1,14 @@ + + + + $(DefaultNetCoreTargetFramework);net472 + + + + + + + + + +