diff --git a/src/Framework/Framework/Hosting/EmbeddedMarkupFileLoader.cs b/src/Framework/Framework/Hosting/EmbeddedMarkupFileLoader.cs index c424c37c7..05e8e29dc 100644 --- a/src/Framework/Framework/Hosting/EmbeddedMarkupFileLoader.cs +++ b/src/Framework/Framework/Hosting/EmbeddedMarkupFileLoader.cs @@ -1,12 +1,15 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Text; using DotVVM.Framework.Configuration; +using DotVVM.Framework.Utils; namespace DotVVM.Framework.Hosting { + /// Read markup files from embedded resources, if the virtual path has the following format: embedded://{AssemblyName}/{ResourceName} public class EmbeddedMarkupFileLoader : IMarkupFileLoader { /// @@ -23,7 +26,7 @@ public class EmbeddedMarkupFileLoader : IMarkupFileLoader if (resourceName.IndexOf('/') == -1 || resourceName.IndexOf('/') == 0) { - throw new ArgumentException("Wrong string format", "virtualPath"); + throw new ArgumentException("Wrong embedded file format. Use `embedded://{AssemblyName}/{ResourceName}`", "virtualPath"); } string assemblyName = resourceName.Substring(0, resourceName.IndexOf('/')); @@ -37,20 +40,22 @@ public class EmbeddedMarkupFileLoader : IMarkupFileLoader //no such assembly found catch (FileLoadException) { - throw new ArgumentException("No such assembly was found", "virtualPath"); + throw new ArgumentException($"Assembly '{assemblyName}' was not found", "virtualPath"); } //no such resource found resourceName = resourceName.Replace('/', '.'); if (assembly.GetManifestResourceInfo(resourceName) == null) { - throw new ArgumentException("No such resource was found", "virtualPath"); + throw new ArgumentException($"Resource '{resourceName}' was not found in assembly '{assembly.FullName}'", "virtualPath"); } - //load the file - using (Stream stream = assembly.GetManifestResourceStream(resourceName)!) - using (StreamReader sr = new StreamReader(stream)) - return new MarkupFile(virtualPath, virtualPath, sr.ReadToEnd()); + return new MarkupFile(virtualPath, virtualPath, () => { + //load the file + using (Stream stream = assembly.GetManifestResourceStream(resourceName)!) + using (var reader = new StreamReader(stream)) + return reader.ReadToEnd(); + }); } /// diff --git a/src/Framework/Framework/Hosting/MarkupFile.cs b/src/Framework/Framework/Hosting/MarkupFile.cs index 23d987073..e7b190a52 100644 --- a/src/Framework/Framework/Hosting/MarkupFile.cs +++ b/src/Framework/Framework/Hosting/MarkupFile.cs @@ -58,6 +58,14 @@ public MarkupFile(string fileName, string fullPath) }; } + public MarkupFile(string fileName, string fullPath, Func readContent, DateTime lastWriteDateTimeUtc = default) + { + FileName = fileName; + FullPath = fullPath; + ReadContent = readContent; + LastWriteDateTimeUtc = lastWriteDateTimeUtc; + } + public MarkupFile(string fileName, string fullPath, string contents, DateTime lastWriteDateTimeUtc = default) { FileName = fileName; diff --git a/src/Framework/Testing/DotvvmTestHelper.cs b/src/Framework/Testing/DotvvmTestHelper.cs index f265e2100..269b42981 100644 --- a/src/Framework/Testing/DotvvmTestHelper.cs +++ b/src/Framework/Testing/DotvvmTestHelper.cs @@ -100,11 +100,23 @@ public static void RegisterMockServices(IServiceCollection services) var config = CreateConfiguration(); config.ExperimentalFeatures.UseDotvvmSerializationForStaticCommandArguments.Enable(); config.RouteTable.Add("TestRoute", "TestRoute", "TestView.dothtml"); + config.Diagnostics.Apply(config); config.Freeze(); return config; }); public static DotvvmConfiguration DefaultConfig => _defaultConfig.Value; + private static Lazy _debugConfig = new Lazy(() => { + var config = CreateConfiguration(); + config.ExperimentalFeatures.UseDotvvmSerializationForStaticCommandArguments.Enable(); + config.RouteTable.Add("TestRoute", "TestRoute", "TestView.dothtml"); + config.Debug = true; + config.Diagnostics.Apply(config); + config.Freeze(); + return config; + }); + public static DotvvmConfiguration DebugConfig => _debugConfig.Value; + public static DotvvmConfiguration CreateConfiguration(Action? customServices = null) => DotvvmConfiguration.CreateDefault(s => { s.AddSingleton(); diff --git a/src/Tests/Runtime/MarkupLoaderTests.cs b/src/Tests/Runtime/MarkupLoaderTests.cs new file mode 100644 index 000000000..8a9b5e1c4 --- /dev/null +++ b/src/Tests/Runtime/MarkupLoaderTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using DotVVM.Framework.Compilation; +using DotVVM.Framework.Configuration; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Hosting; +using DotVVM.Framework.Runtime; +using DotVVM.Framework.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DotVVM.Framework.Tests.Runtime +{ + [TestClass] + public class MarkupLoaderTests: IDisposable + { + readonly string compilationPageResource = DotvvmTestHelper.DebugConfig.RouteTable[DotvvmCompilationPageConfiguration.DefaultRouteName].VirtualPath; + + readonly List tempFiles = []; + + [TestMethod] + public void EmbeddedResource() + { + var loader = new EmbeddedMarkupFileLoader(); + var file = loader.GetMarkup(DotvvmTestHelper.DebugConfig, compilationPageResource); + + XAssert.StartsWith("@viewModel DotVVM.Framework.Diagnostics.CompilationPageViewModel", file.ReadContent()); + } + +#if DotNetCore + [TestMethod] + public void EmbeddedResourceLazyAllocation() + { + var loader = new EmbeddedMarkupFileLoader(); + // warmup for assembly loading and such + loader.GetMarkup(DotvvmTestHelper.DebugConfig, compilationPageResource); + + // GetMarkup allocates constant memory, as it is being called repeatedly if file reloading is enabled + var a = GC.GetAllocatedBytesForCurrentThread(); + var file = loader.GetMarkup(DotvvmTestHelper.DebugConfig, compilationPageResource); + var b = GC.GetAllocatedBytesForCurrentThread(); + XAssert.InRange(b - a, 0, 1000); + + // ReadContent actually reads the file and allocates the string + a = GC.GetAllocatedBytesForCurrentThread(); + var content = file.ReadContent(); + b = GC.GetAllocatedBytesForCurrentThread(); + XAssert.InRange(content.Length, 1000, int.MaxValue); + XAssert.InRange(b - a, content.Length * 2, content.Length * 5); + } +#endif + + [TestMethod] + [DataRow(true)] + [DataRow(false)] + public void FileReloading(bool debug) + { + var directory = MakeTempDir(); + var file = Path.Combine(directory, "test.dotcontrol"); + File.WriteAllText(file, "@viewModel string\n\n"); + var changedTime1 = File.GetLastWriteTimeUtc(file); + + var config = debug ? DotvvmTestHelper.DebugConfig : DotvvmTestHelper.DefaultConfig; + + var controlBuilder = config.ServiceProvider.GetRequiredService(); + var builder0 = controlBuilder.GetControlBuilder(file); + Assert.AreEqual(typeof(string), builder0.descriptor.DataContextType); + + var builderUnchanged = controlBuilder.GetControlBuilder(file); + + Assert.AreSame(builder0.builder, builderUnchanged.builder); // same Lazy instance + + File.WriteAllText(file, "@viewModel int\n\n"); + + var builderChanged = controlBuilder.GetControlBuilder(file); + var control = builderChanged.builder.Value.BuildControl(config.ServiceProvider.GetRequiredService(), config.ServiceProvider); + if (debug) + { + var changedTime2 = File.GetLastWriteTimeUtc(file); + if (changedTime1 == changedTime2) + Assert.Inconclusive($"File system resolution is probably too low ({changedTime1:o} == {changedTime2:o}), ignores changes or something."); + + Assert.AreEqual(typeof(int), builderChanged.descriptor.DataContextType); + Assert.AreNotSame(builder0.builder, builderChanged.builder); // different Lazy instance + XAssert.Equal(["Changed"], control.GetThisAndAllDescendants().OfType().Select(c => c.Text)); + } + else + { + // not reloaded in Release mode by default + Assert.AreEqual(typeof(string), builderChanged.descriptor.DataContextType); + Assert.AreSame(builder0.builder, builderChanged.builder); // different Lazy instance + XAssert.Equal(["Initial"], control.GetThisAndAllDescendants().OfType().Select(c => c.Text)); + } + } + + public string MakeTempDir() + { + var path = Path.Combine(Path.GetTempPath(), "dotvvm-tests-tmp-" + Path.GetRandomFileName()); + Directory.CreateDirectory(path); + tempFiles.Add(path); + return path; + } + + public void Dispose() + { + foreach (var file in tempFiles) + { + if (Directory.Exists(file)) + Directory.Delete(file, true); + else if (File.Exists(file)) + File.Delete(file); + } + } + } +}