diff --git a/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts b/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts index 95e55e9a48..32c04813a4 100644 --- a/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts +++ b/src/Framework/Framework/Resources/Scripts/tests/stateManagement.data.ts @@ -115,6 +115,16 @@ initDotvvm({ "C": 4, "D": 8, } + }, + tValidated: { + type: "object", + properties: { + RegexValidated: { + validationRules: [ + { ruleName: "regularExpression", errorMessage: "Must have even length", parameters: ["^(..)+$"] } + ] + } + } } } }) diff --git a/src/Framework/Framework/Resources/Scripts/tests/validation.test.ts b/src/Framework/Framework/Resources/Scripts/tests/validation.test.ts index 1e11d688d4..f0226ba970 100644 --- a/src/Framework/Framework/Resources/Scripts/tests/validation.test.ts +++ b/src/Framework/Framework/Resources/Scripts/tests/validation.test.ts @@ -3,6 +3,12 @@ import { globalValidationObject as validation, ValidationErrorDescriptor } from import { createComplexObservableSubViewmodel, createComplexObservableViewmodel, ObservableHierarchy, ObservableSubHierarchy } from "./observableHierarchies" import { getErrors } from "../validation/error" import { setLogger } from "../utils/logging"; +import { runClientSideValidation } from '../validation/validation' +import dotvvm from '../dotvvm-root' +import { getStateManager } from "../dotvvm-base"; +import { StateManager } from "../state-manager"; + +require("./stateManagement.data") describe("DotVVM.Validation - public API", () => { @@ -319,6 +325,21 @@ describe("DotVVM.Validation - public API", () => { }) }); + +describe("DotVVM.Validation - view model validation", () => { + const s = getStateManager() as StateManager + test("Validated object in dynamic", () => { + dotvvm.updateState(x => ({...x, Dynamic: { something: "abc", validatedObj: { $type: "tValidated", RegexValidated: "abcd" } } })) + s.doUpdateNow() + runClientSideValidation(s.stateObservable, {} as any) + expect(dotvvm.validation.errors).toHaveLength(0) + s.patchState({ Dynamic: { validatedObj: { RegexValidated: "abcde" }}}) + s.doUpdateNow() + runClientSideValidation(s.stateObservable, {} as any) + expect(dotvvm.validation.errors).toHaveLength(1) + }) +}) + function SetupComplexObservableViewmodelWithErrorsOnProp1AndProp21() { validation.removeErrors("/"); const vm = createComplexObservableViewmodel(); diff --git a/src/Framework/Framework/Resources/Scripts/validation/validation.ts b/src/Framework/Framework/Resources/Scripts/validation/validation.ts index 6c62f2b541..64d707d909 100644 --- a/src/Framework/Framework/Resources/Scripts/validation/validation.ts +++ b/src/Framework/Framework/Resources/Scripts/validation/validation.ts @@ -66,7 +66,7 @@ const createValidationHandler = (pathFunction: (context: KnockoutBindingContext) } }) -const runClientSideValidation = (validationTarget: any, options: PostbackOptions) => { +export const runClientSideValidation = (validationTarget: any, options: PostbackOptions) => { watchAndTriggerValidationErrorChanged(options, () => { @@ -171,6 +171,9 @@ function validateViewModel(viewModel: any, path: string): void { } function validateRecursive(observable: KnockoutObservable, propertyValue: any, type: TypeDefinition, propertyPath: string) { + if (compileConstants.debug && !ko.isObservable(observable)) { + throw Error(`Property ${propertyPath} isn't a knockout observable and cannot be validated.`) + } const lastSetError: CoerceResult = (observable as any)[lastSetErrorSymbol]; if (lastSetError && lastSetError.isError) { ValidationError.attach(lastSetError.message, propertyPath, observable); @@ -203,7 +206,7 @@ function validateRecursive(observable: KnockoutObservable, propertyValue: a validateViewModel(propertyValue, propertyPath); } else { for (const k of keys(propertyValue)) { - validateRecursive(ko.unwrap(propertyValue[k]), propertyValue[k], { type: "dynamic" }, propertyPath + "/" + k); + validateRecursive(propertyValue[k], ko.unwrap(propertyValue[k]), { type: "dynamic" }, propertyPath + "/" + k); } } } diff --git a/src/Framework/Hosting.AspNetCore/Hosting/DotvvmHealthCheck.cs b/src/Framework/Hosting.AspNetCore/Hosting/DotvvmHealthCheck.cs index 847f3c186b..de647fe0ae 100644 --- a/src/Framework/Hosting.AspNetCore/Hosting/DotvvmHealthCheck.cs +++ b/src/Framework/Hosting.AspNetCore/Hosting/DotvvmHealthCheck.cs @@ -47,14 +47,14 @@ public Task CheckHealthAsync(HealthCheckContext context, Canc public static void RegisterHealthCheck(IServiceCollection services) { - services.ConfigureWithServices((options, s) => { + services.Configure(options => { if (options.Registrations.Any(c => c.Name == "DotVVM")) return; options.Registrations.Add( new HealthCheckRegistration( "DotVVM", - ActivatorUtilities.CreateInstance(s), + s => ActivatorUtilities.CreateInstance(s), null, new [] { "dotvvm" } ) diff --git a/src/Samples/AspNetCore/Startup.cs b/src/Samples/AspNetCore/Startup.cs index 4c569954b1..097957df9d 100644 --- a/src/Samples/AspNetCore/Startup.cs +++ b/src/Samples/AspNetCore/Startup.cs @@ -94,6 +94,17 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerF } private string GetApplicationPath(IWebHostEnvironment env) - => Path.Combine(Path.GetDirectoryName(env.ContentRootPath), "Common"); + { + var common = Path.Combine(Path.GetDirectoryName(env.ContentRootPath), "Common"); + if (Directory.Exists(common)) + { + return common; + } + if (File.Exists(Path.Combine(env.ContentRootPath, "Views/Default.dothtml"))) + { + return env.ContentRootPath; + } + throw new DirectoryNotFoundException("Cannot find the 'Common' directory nor the 'Views' directory in the application root."); + } } } diff --git a/src/Samples/AspNetCoreLatest/Startup.cs b/src/Samples/AspNetCoreLatest/Startup.cs index cb24824f6f..4342847d57 100644 --- a/src/Samples/AspNetCoreLatest/Startup.cs +++ b/src/Samples/AspNetCoreLatest/Startup.cs @@ -105,6 +105,17 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerF } private string GetApplicationPath(IWebHostEnvironment env) - => Path.Combine(Path.GetDirectoryName(env.ContentRootPath), "Common"); + { + if (File.Exists(Path.Combine(env.ContentRootPath, "Views/Default.dothtml"))) + { + return env.ContentRootPath; + } + var common = Path.Combine(Path.GetDirectoryName(env.ContentRootPath), "Common"); + if (File.Exists(Path.Combine(common, "Views/Default.dothtml"))) + { + return common; + } + throw new DirectoryNotFoundException("Cannot find the 'Common' directory nor the 'Views' directory in the application root."); + } } } diff --git a/src/Samples/Common/DotVVM.Samples.Common.csproj b/src/Samples/Common/DotVVM.Samples.Common.csproj index ca21111814..a5cb54ad45 100644 --- a/src/Samples/Common/DotVVM.Samples.Common.csproj +++ b/src/Samples/Common/DotVVM.Samples.Common.csproj @@ -19,171 +19,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/Samples/Common/DotvvmStartup.cs b/src/Samples/Common/DotvvmStartup.cs index c82d11c777..b7391ea791 100644 --- a/src/Samples/Common/DotvvmStartup.cs +++ b/src/Samples/Common/DotvvmStartup.cs @@ -31,6 +31,7 @@ using DotVVM.Samples.Common.ViewModels.FeatureSamples.BindingVariables; using DotVVM.Samples.Common.Views.ControlSamples.TemplateHost; using DotVVM.Framework.ResourceManagement; +using DotVVM.Samples.Common.Presenters; using DotVVM.Samples.Common.ViewModels.FeatureSamples.CustomPrimitiveTypes; namespace DotVVM.Samples.BasicSamples @@ -250,6 +251,8 @@ private static void AddRoutes(DotvvmConfiguration config) config.RouteTable.Add("FeatureSamples_PostBack_PostBackHandlers_Localized", "FeatureSamples/PostBack/PostBackHandlers_Localized", "Views/FeatureSamples/PostBack/ConfirmPostBackHandler.dothtml", presenterFactory: LocalizablePresenter.BasedOnQuery("lang")); config.RouteTable.Add("Errors_UndefinedRouteLinkParameters-PageDetail", "Erros/UndefinedRouteLinkParameters/{Id}", "Views/Errors/UndefinedRouteLinkParameters.dothtml", new { Id = 0 }); + + config.RouteTable.Add("DumpExtensionsMethods", "dump-extension-methods", _ => new DumpExtensionMethodsPresenter()); } private static void AddControls(DotvvmConfiguration config) diff --git a/src/Samples/Common/Presenters/DumpExtensionMethodsPresenter.cs b/src/Samples/Common/Presenters/DumpExtensionMethodsPresenter.cs new file mode 100644 index 0000000000..6b418fb287 --- /dev/null +++ b/src/Samples/Common/Presenters/DumpExtensionMethodsPresenter.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using DotVVM.Framework.Compilation; +using DotVVM.Framework.Hosting; +using DotVVM.Framework.Utils; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; + +namespace DotVVM.Samples.Common.Presenters +{ + public class DumpExtensionMethodsPresenter : IDotvvmPresenter + { + public async Task ProcessRequest(IDotvvmRequestContext context) + { + var cache = context.Configuration.ServiceProvider.GetService(); + + var contents = typeof(ExtensionMethodsCache) + .GetField("methodsCache", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(cache) as ConcurrentDictionary>; + + var dump = contents.SelectMany(p => p.Value.Select(m => new { + Namespace = p.Key, + m.Name, + m.DeclaringType!.FullName, + Params = m.GetParameters().Select(p => new { + p.Name, + Type = p.ParameterType!.FullName + }), + m.IsGenericMethodDefinition, + GenericParameters = m.IsGenericMethodDefinition ? m.GetGenericArguments().Select(a => new { + a.Name + }) : null + })) + .OrderBy(m => m.Namespace).ThenBy(m => m.Name); + + await context.HttpContext.Response.WriteAsync("ExtensionMethodsCache dump: " + JsonConvert.SerializeObject(dump, Formatting.Indented)); + } + } +} diff --git a/src/Samples/Tests/Tests/Complex/SPAErrorReportingTests.cs b/src/Samples/Tests/Tests/Complex/SPAErrorReportingTests.cs index db5b0ea1e0..7bbfab9bf2 100644 --- a/src/Samples/Tests/Tests/Complex/SPAErrorReportingTests.cs +++ b/src/Samples/Tests/Tests/Complex/SPAErrorReportingTests.cs @@ -32,7 +32,7 @@ public void Complex_SPAErrorReporting_NavigationAndPostbacks() void SetOfflineMode(bool offline) { - ((ChromeDriver)browser.Driver).NetworkConditions = new ChromiumNetworkConditions() { + ((ChromiumDriver)browser.Driver).NetworkConditions = new ChromiumNetworkConditions() { IsOffline = offline, Latency = TimeSpan.FromMilliseconds(5), DownloadThroughput = 500 * 1024, diff --git a/src/Samples/Tests/Tests/Feature/StaticCommandTests.cs b/src/Samples/Tests/Tests/Feature/StaticCommandTests.cs index 164bf1fa71..3f9807656a 100644 --- a/src/Samples/Tests/Tests/Feature/StaticCommandTests.cs +++ b/src/Samples/Tests/Tests/Feature/StaticCommandTests.cs @@ -1,6 +1,10 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Http; using System.Threading; +using System.Threading.Tasks; using DotVVM.Samples.Tests.Base; using DotVVM.Testing.Abstractions; using OpenQA.Selenium; @@ -814,7 +818,7 @@ public void Feature_List_Translation_Remove_Range() Assert.Equal(new List { "1", "2", "8", "9", "10" }, column); }); } - + [Fact] public void Feature_List_Translation_Remove_Reverse() { @@ -828,6 +832,27 @@ public void Feature_List_Translation_Remove_Reverse() }); } + [Fact] + public async Task Feature_ExtensionMethodsNotResolvedOnStartup() + { + var client = new HttpClient(); + + // try to visit the page + var pageResponse = await client.GetAsync(TestSuiteRunner.Configuration.BaseUrls[0].TrimEnd('/') + "/" + SamplesRouteUrls.FeatureSamples_JavascriptTranslation_ListMethodTranslations); + TestOutput.WriteLine($"Page response: {(int)pageResponse.StatusCode}"); + var wasError = pageResponse.StatusCode != HttpStatusCode.OK; + + // dump extension methods on the output + var json = await client.GetStringAsync(TestSuiteRunner.Configuration.BaseUrls[0].TrimEnd('/') + "/dump-extension-methods"); + TestOutput.WriteLine(json); + + if (wasError) + { + // fail the test on error + throw new Exception("Extension methods were not resolved on application startup."); + } + } + protected IElementWrapperCollection GetSortedRow(IBrowserWrapper browser, string btn) { var orderByBtn = browser.First($"//input[@value='{btn}']", By.XPath); diff --git a/src/Tools/AppStartupInstabilityTester.py b/src/Tools/AppStartupInstabilityTester.py new file mode 100644 index 0000000000..80e6f77f32 --- /dev/null +++ b/src/Tools/AppStartupInstabilityTester.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +import subprocess, requests, os, time, argparse + +parser = argparse.ArgumentParser(description="Repeatedly starts the server and every time checks if some pages are working, use to find startup-time race condition bugs") +parser.add_argument("--port", type=int, default=16017, help="Port to run the server on") +parser.add_argument("--working-directory", type=str, default=".", help="Working directory to run the server in") +parser.add_argument("--server-path", type=str, default="bin/Debug/net8.0/DotVVM.Samples.BasicSamples.AspNetCoreLatest", help="Path to the server executable") +parser.add_argument("--environment", type=str, default="Development", help="Asp.Net Core environment (Development, Production)") +args = parser.parse_args() + +port = args.port + +def server_start() -> subprocess.Popen: + """Starts the server and returns the process object""" + server = subprocess.Popen([ + args.server_path, "--environment", args.environment, "--urls", f"http://localhost:{port}"], + cwd=args.working_directory, + ) + return server + +def req(path): + try: + response = requests.get(f"http://localhost:{port}{path}") + return response.status_code + except requests.exceptions.ConnectionError: + return None + +iteration = 0 +while True: + iteration += 1 + print(f"Starting iteration {iteration}") + server = server_start() + time.sleep(0.1) + while req("/") is None: + time.sleep(0.1) + + probes = [ + req("/"), + req("/FeatureSamples/LambdaExpressions/StaticCommands"), + req("/FeatureSamples/LambdaExpressions/ClientSideFiltering"), + req("/FeatureSamples/LambdaExpressions/LambdaExpressions") + ] + if set(probes) != {200}: + print(f"Iteration {iteration} failed: {probes}") + time.sleep(100000000) + + server.terminate() + server.wait() diff --git a/src/Tracing/ApplicationInsights.AspNetCore/OperationNameTelemetryInitializer.cs b/src/Tracing/ApplicationInsights.AspNetCore/OperationNameTelemetryInitializer.cs new file mode 100644 index 0000000000..bc39bc5823 --- /dev/null +++ b/src/Tracing/ApplicationInsights.AspNetCore/OperationNameTelemetryInitializer.cs @@ -0,0 +1,32 @@ +using DotVVM.Framework.Hosting; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.AspNetCore.Http; + +namespace DotVVM.Tracing.ApplicationInsights.AspNetCore; + +public class OperationNameTelemetryInitializer : ITelemetryInitializer +{ + private readonly IHttpContextAccessor _accessor; + + public OperationNameTelemetryInitializer(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + public void Initialize(ITelemetry telemetry) + { + var context = _accessor.HttpContext; + var url = context?.GetDotvvmContext()?.Route?.Url; + if (url != null && telemetry is RequestTelemetry) + { + var method = context.Request.Method; + var operationName = $"{method} /{url}"; + + var requestTelemetry = telemetry as RequestTelemetry; + requestTelemetry.Name = operationName; + requestTelemetry.Context.Operation.Name = operationName; + } + } +} diff --git a/src/Tracing/ApplicationInsights.AspNetCore/TracingBuilderExtensions.cs b/src/Tracing/ApplicationInsights.AspNetCore/TracingBuilderExtensions.cs index bf370072cf..114564f26d 100644 --- a/src/Tracing/ApplicationInsights.AspNetCore/TracingBuilderExtensions.cs +++ b/src/Tracing/ApplicationInsights.AspNetCore/TracingBuilderExtensions.cs @@ -30,6 +30,7 @@ public static IDotvvmServiceCollection AddApplicationInsightsTracing(this IDotvv services.AddDotvvmApplicationInsights(); services.Services.AddApplicationInsightsTelemetryProcessor(); + services.Services.AddSingleton(); services.Services.TryAddSingleton(); services.Services.AddTransient, ApplicationInsightSetup>(); diff --git a/src/Tracing/ApplicationInsights.Owin/DotVVM.Tracing.ApplicationInsights.Owin.csproj b/src/Tracing/ApplicationInsights.Owin/DotVVM.Tracing.ApplicationInsights.Owin.csproj index 80f82fc59a..25a52f73b1 100644 --- a/src/Tracing/ApplicationInsights.Owin/DotVVM.Tracing.ApplicationInsights.Owin.csproj +++ b/src/Tracing/ApplicationInsights.Owin/DotVVM.Tracing.ApplicationInsights.Owin.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Tracing/ApplicationInsights.Owin/OperationNameTelemetryInitializer.cs b/src/Tracing/ApplicationInsights.Owin/OperationNameTelemetryInitializer.cs new file mode 100644 index 0000000000..d0cda14c33 --- /dev/null +++ b/src/Tracing/ApplicationInsights.Owin/OperationNameTelemetryInitializer.cs @@ -0,0 +1,25 @@ +using System.Web; +using DotVVM.Framework.Hosting; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.ApplicationInsights.Channel; + +namespace DotVVM.Tracing.ApplicationInsights.Owin; + +public class OperationNameTelemetryInitializer : ITelemetryInitializer +{ + public void Initialize(ITelemetry telemetry) + { + var context = HttpContext.Current; + var url = context?.GetOwinContext()?.GetDotvvmContext()?.Route?.Url; + if (url != null && telemetry is RequestTelemetry) + { + var method = context.Request.HttpMethod; + var operationName = $"{method} /{url}"; + + var requestTelemetry = telemetry as RequestTelemetry; + requestTelemetry.Name = operationName; + requestTelemetry.Context.Operation.Name = operationName; + } + } +} diff --git a/src/Tracing/ApplicationInsights.Owin/TracingBuilderExtensions.cs b/src/Tracing/ApplicationInsights.Owin/TracingBuilderExtensions.cs index 5941e8717e..582d2bde0e 100644 --- a/src/Tracing/ApplicationInsights.Owin/TracingBuilderExtensions.cs +++ b/src/Tracing/ApplicationInsights.Owin/TracingBuilderExtensions.cs @@ -18,6 +18,7 @@ public static class TracingBuilderExtensions public static IDotvvmServiceCollection AddApplicationInsightsTracing(this IDotvvmServiceCollection services) { TelemetryConfiguration.Active.TelemetryProcessorChainBuilder.Use(next => new RequestTelemetryFilter(next)).Build(); + TelemetryConfiguration.Active.TelemetryInitializers.Add(new OperationNameTelemetryInitializer()); services.Services.TryAddSingleton(); services.AddDotvvmApplicationInsights();