Merge pull request 'feat(web): SPA cutover — root route (#91)' (#148) from feat/91-cutover into main
This commit was merged in pull request #148.
This commit is contained in:
@@ -274,8 +274,8 @@ jobs:
|
||||
done
|
||||
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
|
||||
}
|
||||
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv"; then
|
||||
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide"
|
||||
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv" && check "/app/" "ChicoryTV"; then
|
||||
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
|
||||
else
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests;
|
||||
|
||||
[TestFixture]
|
||||
public class LegacyUiRedirectsTests
|
||||
{
|
||||
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
|
||||
|
||||
[Test]
|
||||
public void Every_Mapping_Should_Resolve()
|
||||
{
|
||||
foreach ((string from, string to) in LegacyUiRedirects.Map)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(from), out string target).ShouldBeTrue();
|
||||
target.ShouldBe(to);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("/", "/app")]
|
||||
[TestCase("/channels", "/app/channels")]
|
||||
[TestCase("/channels/add", "/app/new-channel")]
|
||||
[TestCase("/schedules", "/app/schedules")]
|
||||
[TestCase("/playouts", "/app/playouts")]
|
||||
[TestCase("/media/libraries", "/app/libraries")]
|
||||
[TestCase("/settings/ffmpeg", "/app/settings/streaming")]
|
||||
[TestCase("/settings/hdhr", "/app/settings/system")]
|
||||
[TestCase("/settings/logging", "/app/settings/logging")]
|
||||
[TestCase("/settings/playout", "/app/settings/playout")]
|
||||
[TestCase("/settings/scanner", "/app/settings/scanner")]
|
||||
[TestCase("/settings/ui", "/app/settings/general")]
|
||||
[TestCase("/settings/xmltv", "/app/settings/xmltv")]
|
||||
public void Known_Route_Should_Redirect(string path, string expected)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
|
||||
target.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[TestCase("/channels/", "/app/channels")]
|
||||
[TestCase("/schedules/", "/app/schedules")]
|
||||
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")]
|
||||
public void Trailing_Slash_Should_Match(string path, string expected)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
|
||||
target.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Lookup_Should_Be_Case_Insensitive()
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString("/Channels"), out string target).ShouldBeTrue();
|
||||
target.ShouldBe("/app/channels");
|
||||
}
|
||||
|
||||
[TestCase("/channels/5")] // channel edit (Blazor-only)
|
||||
[TestCase("/channels/numbers")] // Blazor-only
|
||||
[TestCase("/system/health")] // Blazor home escape hatch
|
||||
[TestCase("/media/collections")] // Blazor-only media page
|
||||
[TestCase("/ffmpeg")] // Blazor-only
|
||||
[TestCase("/watermarks")] // Blazor-only
|
||||
[TestCase("/app")] // already the SPA
|
||||
[TestCase("/app/channels")] // already the SPA
|
||||
[TestCase("/iptv/channels.m3u")] // IPTV surface
|
||||
[TestCase("/api/health")] // API surface
|
||||
[TestCase("")] // empty
|
||||
[TestCase("//")] // all-slash path must not collapse to root "/"
|
||||
[TestCase("/channels//")] // double trailing slash is not normalized to a match
|
||||
public void Non_Migrated_Route_Should_Not_Redirect(string path)
|
||||
{
|
||||
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
|
||||
target.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
|
||||
{
|
||||
// The redirect middleware must be wired inside the blazor branch and run
|
||||
// before UseRouting so migrated routes never reach the Blazor fallback.
|
||||
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
|
||||
redirectIndex.ShouldBeGreaterThan(-1);
|
||||
|
||||
// The Blazor branch's UseRouting call that follows the redirect middleware.
|
||||
int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal);
|
||||
routingIndex.ShouldBeGreaterThan(-1);
|
||||
|
||||
redirectIndex.ShouldBeLessThan(routingIndex);
|
||||
|
||||
// 302 (temporary), not a permanent redirect.
|
||||
StartupSource.ShouldContain("context.Request.PathBase + target");
|
||||
StartupSource.ShouldNotContain("RedirectPermanent(target");
|
||||
}
|
||||
|
||||
private static string FindStartupPath()
|
||||
{
|
||||
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
|
||||
|
||||
while (directory is not null)
|
||||
{
|
||||
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace ErsatzTV;
|
||||
|
||||
// Phase (a) of the Blazor -> ChicoryTV SPA cutover (ersatztv#91).
|
||||
//
|
||||
// The React SPA (served under /app) is now the default UI: the legacy Blazor
|
||||
// routes below 302-redirect to their SPA equivalents. Only routes that already
|
||||
// have SPA parity are listed here. Blazor pages WITHOUT a SPA equivalent are
|
||||
// deliberately left reachable (no redirect) so their functionality stays
|
||||
// available while the SPA catches up:
|
||||
// /system/health (Blazor home escape hatch; Index.razor also lives here),
|
||||
// /channels/{id} edit, /channels/numbers, /media/* (collections etc.),
|
||||
// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates,
|
||||
// schedule/playout detail editors, /system/logs, /system/troubleshooting.
|
||||
//
|
||||
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
|
||||
// full SPA parity for every route in this map. Until then this map is the
|
||||
// single source of truth for "what has migrated" and is expected to grow.
|
||||
public static class LegacyUiRedirects
|
||||
{
|
||||
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
|
||||
// trailing slash on the request is normalized away before lookup.
|
||||
public static readonly IReadOnlyDictionary<string, string> Map =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["/"] = "/app",
|
||||
["/channels"] = "/app/channels",
|
||||
["/channels/add"] = "/app/new-channel",
|
||||
["/schedules"] = "/app/schedules",
|
||||
["/playouts"] = "/app/playouts",
|
||||
["/media/libraries"] = "/app/libraries",
|
||||
["/settings/ffmpeg"] = "/app/settings/streaming",
|
||||
["/settings/hdhr"] = "/app/settings/system",
|
||||
["/settings/logging"] = "/app/settings/logging",
|
||||
["/settings/playout"] = "/app/settings/playout",
|
||||
["/settings/scanner"] = "/app/settings/scanner",
|
||||
["/settings/ui"] = "/app/settings/general",
|
||||
["/settings/xmltv"] = "/app/settings/xmltv"
|
||||
};
|
||||
|
||||
public static bool TryGetRedirect(PathString path, out string target)
|
||||
{
|
||||
target = string.Empty;
|
||||
|
||||
string value = path.Value;
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize a single trailing slash so "/channels/" matches "/channels"
|
||||
// (but keep root "/" intact). Guard against a path of all slashes (e.g.
|
||||
// "//") collapsing down to "/" and falsely matching the root entry.
|
||||
if (value.Length > 1 && value.EndsWith('/'))
|
||||
{
|
||||
string trimmed = value[..^1];
|
||||
if (trimmed == "/")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = trimmed;
|
||||
}
|
||||
|
||||
if (Map.TryGetValue(value, out string mapped))
|
||||
{
|
||||
target = mapped;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -726,6 +726,28 @@ public class Startup
|
||||
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
|
||||
blazor =>
|
||||
{
|
||||
// ersatztv#91 phase (a): make the ChicoryTV SPA the default UI by
|
||||
// redirecting migrated legacy Blazor routes to their /app equivalents.
|
||||
// 302 (not 301): this map grows as pages migrate, and permanent-redirect
|
||||
// browser caching would make rollback painful. UsePathBase (ETV_BASE_URL)
|
||||
// only rewrites the request side (Request.Path/PathBase); it never touches
|
||||
// redirect Location headers, so the PathBase prefix must be re-applied here.
|
||||
blazor.Use(async (context, next) =>
|
||||
{
|
||||
if (HttpMethods.IsGet(context.Request.Method) ||
|
||||
HttpMethods.IsHead(context.Request.Method))
|
||||
{
|
||||
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
|
||||
{
|
||||
context.Response.Redirect(
|
||||
context.Request.PathBase + target + context.Request.QueryString);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next(context);
|
||||
});
|
||||
|
||||
blazor.UseRouting();
|
||||
|
||||
if (OidcHelper.IsEnabled)
|
||||
|
||||
Reference in New Issue
Block a user