diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs index ee6df5eb..371662bc 100644 --- a/SubathonManager.Server/WebServer.Overlays.cs +++ b/SubathonManager.Server/WebServer.Overlays.cs @@ -57,17 +57,19 @@ internal async Task HandleWidgetRequest(IHttpContext ctx) { if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) { string html = WidgetFiles.Current.ReadAllText(filePath) ?? string.Empty; + string clientScript = GetWebsocketInjectionScript(); + var cssOverrides = new StringBuilder(); - cssOverrides.AppendLine(GetWebsocketInjectionScript()); cssOverrides.AppendLine(""); + if (html.Contains("", StringComparison.OrdinalIgnoreCase)) - html = html.Replace("", cssOverrides + "\n", + html = html.Replace("", clientScript + "\n" + cssOverrides + "\n", StringComparison.OrdinalIgnoreCase); else - html += cssOverrides; + html = clientScript + "\n" + html + cssOverrides; var jsOverrides = new StringBuilder(); jsOverrides.AppendLine("\n - "; - return script; - } -} \ No newline at end of file + string route = routeId ?? string.Empty; + bool isOverlay = !string.IsNullOrWhiteSpace(route); + + return ClientScriptTemplate + .Replace("__WS_URL__", $"ws://localhost:{Port}/ws") + .Replace("__ROUTE_ID__", route) + .Replace("__EMPTY_GUID__", Guid.Empty.ToString()) + .Replace("__IS_OVERLAY__", isOverlay ? "true" : "false"); + } + + private const string ClientScriptTemplate = """ + + """; +} diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs index 37170a56..15ba8f83 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs @@ -491,6 +491,56 @@ await File.WriteAllTextAsync(tempHtml, "" AppServices.Provider = null!; } + [Fact] + public async Task HandleWidgetRequestAsync_Puts_Client_Script_Before_A_Fragment_Widgets_Script() { + WebServer server = CreateServer(); + SetupServices(); + var route = new Route { Name = "TestRoute" }; + + string tempHtml = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".html"); + await File.WriteAllTextAsync(tempHtml, + "
\n", + TestContext.Current.CancellationToken); + + var widget = new Widget("Widget1", tempHtml) { + Route = route, + RouteId = route.Id, + JsVariables = new List { + new() { Name = "testVar", Value = "42", Type = WidgetVariableType.Int, WidgetId = Guid.NewGuid() } + } + }; + route.Widgets.Add(widget); + + await using AppDbContext db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken); + db.Routes.Add(route); + db.Widgets.Add(widget); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + + var ctx = new MockHttpContext { + Method = "GET", + Path = $"/widget/{widget.Id}" + }; + + await server.HandleWidgetRequest(ctx); + + Assert.Equal(200, ctx.StatusCode); + string body = ctx.GetResponseText(); + + int clientAt = body.IndexOf("data-subathon-client", StringComparison.Ordinal); + int varsAt = body.IndexOf("const testVar = 42", StringComparison.Ordinal); + int widgetAt = body.IndexOf("window.__probe", StringComparison.Ordinal); + + Assert.True(clientAt >= 0, "client script was not injected"); + Assert.True(varsAt >= 0, "widget variables were not injected"); + Assert.True(widgetAt >= 0, "widget markup was not served"); + + Assert.True(clientAt < widgetAt, "client script must come before the widget's own script"); + Assert.True(varsAt < widgetAt, "widget variables must come before the widget's own script"); + + File.Delete(tempHtml); + AppServices.Provider = null!; + } + [Fact] public async Task HandleWidgetRequest_Returns_404_For_Invalid_Widget() { WebServer server = CreateServer(); diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs index c3e2b8bf..66b46a1d 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs @@ -215,6 +215,74 @@ public void Generate_Injection_Script() { AppServices.Provider = null!; } + [Fact] + public void Injection_Script_Widget_Mode_Is_Not_An_Overlay_Host() { + SetupServices(); + WebServer server = CreateServer(); + string script = server.GetWebsocketInjectionScript(); + + Assert.Contains("var IS_OVERLAY = false;", script); + Assert.Contains("var ROUTE_ID = '';", script); + Assert.DoesNotContain("__IS_OVERLAY__", script); + Assert.DoesNotContain("__ROUTE_ID__", script); + Assert.DoesNotContain("__WS_URL__", script); + Assert.DoesNotContain("__EMPTY_GUID__", script); + + AppServices.Provider = null!; + } + + [Fact] + public void Injection_Script_Overlay_Mode_Carries_Route_Id() { + SetupServices(); + WebServer server = CreateServer(); + var routeId = Guid.NewGuid(); + string script = server.GetWebsocketInjectionScript(routeId.ToString()); + + Assert.Contains("var IS_OVERLAY = true;", script); + Assert.Contains($"var ROUTE_ID = '{routeId}';", script); + Assert.Contains($"var EMPTY_GUID = '{Guid.Empty}';", script); + Assert.Contains("ws_type: 'Overlay'", script); + Assert.Contains("ws_type: 'Widget'", script); + + AppServices.Provider = null!; + } + + [Fact] + public void Injection_Script_Still_Dispatches_Every_Legacy_Global() { + SetupServices(); + WebServer server = CreateServer(); + string script = server.GetWebsocketInjectionScript(); + foreach (string handler in new[] { + "handleSubathonUpdate", "handleSubathonEvent", "handlePromptUpdate", + "handleGoalsUpdate", "handleGoalCompleted", "handleValueConfig", + "handleTotalsUpdate", "handleSubscriptionTotalsUpdate", "handleWheelSpinResult", + "handleWheelData", "handleWheelSpinStart", "handleWheelSpinStatus", + "handleVarsUpdate", "handleSubathonDisconnect" + }) + Assert.Contains(handler, script); + + AppServices.Provider = null!; + } + + [Fact] + public void Injection_Script_Replays_Cached_State_To_Late_Legacy_Handlers() { + SetupServices(); + WebServer server = CreateServer(); + string script = server.GetWebsocketInjectionScript(); + + Assert.Contains("DOMContentLoaded", script); + Assert.Contains("replayStateToLegacy", script); + Assert.Contains("legacyServed", script); + + foreach (string stateType in new[] { + "subathon_timer", "subathon_totals", "subscription_totals", + "goals_list", "value_config", "prompt_update", "wheel_data" + }) + Assert.Contains(stateType + ":", script); + + AppServices.Provider = null!; + } + [Fact] public async Task Non_WebSocket_Request_Is_Rejected_As_WebSocket() { var ctx = new MockHttpContext { diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index 197287af..6a4c302f 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -12,6 +12,8 @@ + + diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Event-Reader_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Event-Reader_1.0.0.smw new file mode 100644 index 00000000..78c768de Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Event-Reader_1.0.0.smw differ diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Goal-Viewer_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Goal-Viewer_1.0.0.smw new file mode 100644 index 00000000..333060b4 Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Goal-Viewer_1.0.0.smw differ diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Prompt_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Prompt_1.0.0.smw new file mode 100644 index 00000000..606537df Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Prompt_1.0.0.smw differ diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Timer_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Timer_1.0.0.smw new file mode 100644 index 00000000..e3ae65b9 Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Timer_1.0.0.smw differ diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Wheel-Spinner_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Wheel-Spinner_1.0.0.smw new file mode 100644 index 00000000..4c584c1f Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Wheel-Spinner_1.0.0.smw differ diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Event-Scroll_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Event-Scroll_1.0.0.smw new file mode 100644 index 00000000..25cf39b9 Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Event-Scroll_1.0.0.smw differ diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Goals-Mana-Bar_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Goals-Mana-Bar_1.0.0.smw new file mode 100644 index 00000000..f96af145 Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Goals-Mana-Bar_1.0.0.smw differ diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Timer_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Timer_1.0.0.smw new file mode 100644 index 00000000..a3260189 Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Timer_1.0.0.smw differ diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw new file mode 100644 index 00000000..414aceab Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw differ diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Prompt-Scroll_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Prompt-Scroll_1.0.0.smw new file mode 100644 index 00000000..4d37d25e Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Prompt-Scroll_1.0.0.smw differ