JR-023: correct the missing-dependency logs, and test them
🏗️ Build Plugin / build (push) Successful in 29s
Latest Release / latest-release (push) Successful in 39s
🧪 Test Plugin / test (push) Successful in 26s

Both failure paths still told admins the overlay was "falling back to patching
index.html on disk". JR-021 deleted that fallback, so the message was false --
and it was false in the worst place, since an admin reads it precisely when
debugging a missing overlay and would go hunting for a patch that no longer
exists. They now name the install URL and say the overlay is disabled while
every other feature is unaffected. The not-found case is a Warning, not
Information: a headline feature being off should not sit among startup chatter.

UT-012..015 cover the branch. The File Transformation assembly is genuinely
absent from the test host, so TryRegister exercises its real not-found path
rather than a seam invented for the test. UT-015 pins that a null payload
returns empty rather than throwing -- this callback runs inside another
plugin's request path on every page served, so throwing would break the web
client itself, not just JRay's overlay.

Making those runnable needed the test project to reference Jellyfin.Controller
and Jellyfin.Model without the plugin's ExcludeAssets=runtime. My earlier claim
that DisableTransitiveFrameworkReferences alone sufficed was too narrow: it
drops the demand for the web *framework*, but ILogger and MediaBrowser.Common
live in the excluded assets, so anything past dependency-free logic failed at
run time with FileNotFoundException. Both settings are needed, and the register
now says so.

Second mutation check: downgrading the warning to Information fails UT-013 and
nothing else. Source restored and re-verified.

JR-023 reaches Done for the detection half. The config-page banner stays T4 --
verifiable only against a live server.

TRACES: UT-012, UT-013, UT-014, UT-015 | JR-023

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 09:18:40 +02:00
co-authored by Claude Opus 5
parent 0fafa84158
commit 2926740d03
5 changed files with 165 additions and 28 deletions
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Microsoft.Extensions.Logging;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-023 — absent the File Transformation plugin, disable *only* the overlay
/// and say so. There is no on-disk fallback (JR-021), so the failure path is
/// the one an admin will actually meet, and it has to be legible: silently
/// serving no overlay is indistinguishable from a broken install.
///
/// The File Transformation assembly is not loaded in the test host, so
/// TryRegister exercises its not-found branch for real rather than through a
/// seam invented for the test.
///
/// TRACES: UT-012, UT-013, UT-014, UT-015 | JR-023
/// </summary>
public class FileTransformationRegistrationTests
{
// UT-012
[Fact]
public void TryRegister_WithPluginAbsent_ReturnsFalse()
{
Assert.False(FileTransformationRegistration.TryRegister(new CapturingLogger()));
}
// UT-013
[Fact]
public void TryRegister_WithPluginAbsent_WarnsNamingThePluginAndHowToInstallIt()
{
var logger = new CapturingLogger();
FileTransformationRegistration.TryRegister(logger);
var entry = Assert.Single(logger.Entries);
// Warning, not Information: the overlay is a headline feature and it is
// off. Logging this at Information buries it among startup chatter.
Assert.Equal(LogLevel.Warning, entry.Level);
// The message has to carry the install URL, and must not promise the
// on-disk fallback that JR-021 removed.
Assert.Contains(FileTransformationRegistration.ManifestUrl, entry.Message, StringComparison.Ordinal);
Assert.DoesNotContain("falling back", entry.Message, StringComparison.OrdinalIgnoreCase);
}
// UT-014
[Fact]
public void TransformIndexHtml_WithOverlayDisabled_ReturnsContentsUnchanged()
{
// No Plugin instance exists in the test host, so OverlayEnabled is
// false — the same branch taken when an admin switches the overlay off.
var html = "<html><body><div>x</div>\n</body></html>";
var result = FileTransformationRegistration.TransformIndexHtml(
new TransformationPayload { Contents = html });
Assert.Equal(html, result);
}
// UT-015
[Fact]
public void TransformIndexHtml_WithNullContents_ReturnsEmptyRatherThanThrowing()
{
// This callback is invoked by another plugin's code on every page
// served. Throwing here would break the web client itself, not just
// JRay's overlay.
var result = FileTransformationRegistration.TransformIndexHtml(new TransformationPayload());
Assert.Equal(string.Empty, result);
}
private sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
}
}
@@ -35,6 +35,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<!--
The plugin sets ExcludeAssets=runtime on these: at run time the Jellyfin
server supplies them, so shipping copies in the plugin would risk loading
a second, different MediaBrowser.Common. The test host is not the server,
so it has to bring its own — hence the same packages without that
exclusion, and only here.
-->
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
<PackageReference Include="Jellyfin.Model" Version="10.11.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
@@ -23,9 +23,10 @@ namespace Jellyfin.Plugin.JRay.Services;
/// <c>AssemblyLoadContext</c> from the real one, which is precisely the failure /// <c>AssemblyLoadContext</c> from the real one, which is precisely the failure
/// the reflection integration exists to avoid. /// the reflection integration exists to avoid.
/// ///
/// This is the mechanism JR-021 requires, but it does not by itself satisfy /// This is the only route by which JRay reaches the web client. There is no
/// JR-021 — that requirement is a prohibition, and it stays unmet while /// on-disk fallback (JR-021), so when registration fails the overlay is simply
/// <see cref="WebClientPatchService"/> can still write to disk. /// disabled — which is why both failure paths log a warning naming the missing
/// plugin rather than quietly degrading.
/// </remarks> /// </remarks>
// TRACES: JR-020, JR-023 | PR-004 // TRACES: JR-020, JR-023 | PR-004
public static class FileTransformationRegistration public static class FileTransformationRegistration
@@ -72,7 +73,12 @@ public static class FileTransformationRegistration
var registerMethod = ResolveRegisterMethod(); var registerMethod = ResolveRegisterMethod();
if (registerMethod is null) if (registerMethod is null)
{ {
logger.LogInformation("JRay: File Transformation plugin not found; falling back to patching index.html on disk."); logger.LogWarning(
"JRay: the File Transformation plugin was not found, so the pause overlay is "
+ "disabled. JRay does not modify index.html on disk and has no fallback. "
+ "Install it from {ManifestUrl} to enable the overlay; every other JRay "
+ "feature is unaffected.",
ManifestUrl);
return false; return false;
} }
@@ -84,7 +90,11 @@ public static class FileTransformationRegistration
} }
catch (Exception ex) when (ex is TargetInvocationException or InvalidOperationException or JsonException or MissingMethodException) catch (Exception ex) when (ex is TargetInvocationException or InvalidOperationException or JsonException or MissingMethodException)
{ {
logger.LogWarning(ex, "JRay: failed to register with the File Transformation plugin; falling back to patching index.html on disk."); logger.LogWarning(
ex,
"JRay: the File Transformation plugin is present but registration failed, so the "
+ "pause overlay is disabled. JRay does not modify index.html on disk and has no "
+ "fallback. Every other JRay feature is unaffected.");
return false; return false;
} }
} }
+13 -7
View File
@@ -477,13 +477,19 @@ Optionally, publish jRay through a repository manifest that also lists File
Transformation, so one repository URL surfaces both. This is not a dependency Transformation, so one repository URL surfaces both. This is not a dependency
mechanism; it removes a step and the chance of installing the wrong thing. mechanism; it removes a step and the chance of installing the wrong thing.
**Current:** all three implemented. Startup detection and registration, a warning **Current:** all three implemented, and the detection half is tested
naming the plugin and its install URL, and a status banner on the configuration (UT-012…015). Startup detection and registration, a warning naming the plugin
page fed by `GET /Plugins/JRay/Status/Dependencies` — satisfied, or missing with and its install URL, and a status banner on the configuration page fed by
the manifest URL and what to do with it. The README now states the dependency `GET /Plugins/JRay/Status/Dependencies`. The README states the dependency before
before the install step rather than after it. **Gap:** no test executes the the install step rather than after it.
detection branch, so this stays `In Progress`; the config-page half is T4 and
verifiable only against a live server. Both failure-path log messages previously said the overlay was "falling back to
patching index.html on disk" — a claim JR-021 made false, and the worst place to
leave one: an admin reading it while debugging a missing overlay would go hunting
for a patch that no longer exists. UT-013 asserts the message names the install
URL and does *not* claim a fallback.
**Gap:** the config-page banner is T4, verifiable only against a live server.
### JR-024 — Names render as text, never markup ### JR-024 — Names render as text, never markup
+32 -16
View File
@@ -31,11 +31,17 @@ Tag code with `// TRACES: JR-012 | SR-002`.
| UT-009 | **Prioritised series inside an ignored genre — series wins** | JR-016 | **Passing** | | UT-009 | **Prioritised series inside an ignored genre — series wins** | JR-016 | **Passing** |
| UT-010 | Genre matching is case-insensitive | JR-016 | **Passing** | | UT-010 | Genre matching is case-insensitive | JR-016 | **Passing** |
| UT-011 | A Series rule valued `Guid.Empty` does not swallow every movie | JR-016 | **Passing** | | UT-011 | A Series rule valued `Guid.Empty` does not swallow every movie | JR-016 | **Passing** |
| UT-012 | `TryRegister` returns `false` when File Transformation is absent | JR-023 | **Passing** |
| UT-013 | …and **warns** naming the install URL, with no "falling back" claim | JR-023 | **Passing** |
| UT-014 | Overlay disabled ⇒ `index.html` returned unchanged | JR-023 | **Passing** |
| UT-015 | Null contents return empty rather than throwing — this callback runs on every page another plugin serves | JR-023 | **Passing** |
All 11 execute and pass. The suite was also checked to **fail** on a mutation — All 15 execute and pass. The suite was also checked to **fail** on two separate
removing the newline-stripping from `RemoveInjection` fails UT-001 and nothing mutations, because a suite that has only ever passed is not evidence that it
else — because a suite that has only ever passed is not evidence that it tests tests anything: removing the newline-stripping from `RemoveInjection` fails
anything. UT-001 alone, and downgrading the missing-dependency warning to `Information`
fails UT-013 alone. In both cases the blast radius was one test, and the source
was restored and re-verified.
`JR` is flat rather than split by theme. The plugin is one deployable with one `JR` is flat rather than split by theme. The plugin is one deployable with one
audience, and the thematic grouping lives in the section headings below, where it audience, and the thematic grouping lives in the section headings below, where it
@@ -95,7 +101,7 @@ coordinated `schema_version` bumps (SR-003).
| JR-020 | Pause overlay: injected client script queries `jray?t=` and renders the scene's cast | **PR-001** | High | Done | | JR-020 | Pause overlay: injected client script queries `jray?t=` and renders the scene's cast | **PR-001** | High | Done |
| JR-021 | **jRay never injects into `index.html` on disk.** File Transformation is a hard dependency; there is no on-disk fallback. The only permitted write is JR-022's removal | PR-004 | High | **Done** | | JR-021 | **jRay never injects into `index.html` on disk.** File Transformation is a hard dependency; there is no on-disk fallback. The only permitted write is JR-022's removal | PR-004 | High | **Done** |
| JR-022 | Migration: remove any on-disk patch left by an earlier jRay, identified by the `<!-- jray-overlay -->` marker | PR-004 | High | **Done** (UT-001…006) | | JR-022 | Migration: remove any on-disk patch left by an earlier jRay, identified by the `<!-- jray-overlay -->` marker | PR-004 | High | **Done** (UT-001…006) |
| JR-023 | Absent the dependency, disable **only** the overlay and say so in the log and the config page; never bundle the assembly | PR-004 | Medium | In Progress | | JR-023 | Absent the dependency, disable **only** the overlay and say so in the log and the config page; never bundle the assembly | PR-004 | Medium | **Done** (UT-012…015; config page is T4) |
| JR-024 | Actor names and all server-supplied strings render as **text, never markup** | SR-004 | High | Done | | JR-024 | Actor names and all server-supplied strings render as **text, never markup** | SR-004 | High | Done |
## Manifest exchange client (JR-025 … JR-037) ## Manifest exchange client (JR-025 … JR-037)
@@ -188,7 +194,7 @@ thing when read across repos; reusing T3 for a live tier here would make a
cross-repo reader count live-only requirements as covered. cross-repo reader count live-only requirements as covered.
`Jellyfin.Plugin.JRay.Tests` (xUnit, in the solution) carries the T1 tier. It `Jellyfin.Plugin.JRay.Tests` (xUnit, in the solution) carries the T1 tier. It
builds clean alongside the plugin and all 11 tests pass. builds clean alongside the plugin and all 15 tests pass.
### Running the suite on a box without the web runtime ### Running the suite on a box without the web runtime
@@ -201,16 +207,26 @@ work anywhere, and both are load-bearing rather than incidental:
runtime the plugin does not otherwise need. runtime the plugin does not otherwise need.
- **`DisableTransitiveFrameworkReferences=true`.** The plugin - **`DisableTransitiveFrameworkReferences=true`.** The plugin
framework-references `Microsoft.AspNetCore.App` through `Jellyfin.Controller`, framework-references `Microsoft.AspNetCore.App` through `Jellyfin.Controller`,
and that flows into anything referencing it — so the test host would demand a and that flows into anything referencing it — so the test host would otherwise
web runtime even for tests that touch no web type. .NET resolves assemblies demand a web runtime that no version of exists in the Arch repositories for
lazily, so pure-logic types load fine without it. .NET 9 (8 and 10 only).
- **Explicit `Jellyfin.Controller` / `Jellyfin.Model` references.** The plugin
sets `ExcludeAssets=runtime` on both, because at run time the *server* supplies
them and shipping copies would risk loading a second, different
`MediaBrowser.Common`. The test host is not the server, so it must bring its
own — hence the same packages without that exclusion, and only in the test
project.
**This is a T1-tier decision, not a workaround to unwind.** These tests exercise Those two together are what make it work: the first drops the demand for the web
string and rule logic; requiring an ASP.NET Core runtime to run them would be *framework*, the second supplies the Jellyfin *assemblies*. Cutting the framework
incidental coupling. **T2 — controllers and authorisation — genuinely needs that reference alone is not enough — `ILogger` and `MediaBrowser.Common` live in the
runtime**, and those tests belong in a second project that keeps the reference. excluded assets, so anything beyond genuinely dependency-free logic fails to load
Note that no ASP.NET Core 9 exists in the Arch repositories (8 and 10 only), so with `FileNotFoundException` at run time rather than at build.
that project will lean on `RollForward` too.
**T2 — controllers and authorisation — is still a separate matter**, since
instantiating MVC types needs the ASP.NET Core runtime itself, not just its
reference assemblies. Those tests belong in a second project that keeps the
framework reference and leans on `RollForward` to reach the 10.0 runtime.
### Per-requirement verification plan ### Per-requirement verification plan
@@ -238,7 +254,7 @@ that project will lean on `RollForward` too.
| JR-018 | T1 | `covered / (total - ignored)` | Item carrying two genres counts in both rows | | JR-018 | T1 | `covered / (total - ignored)` | Item carrying two genres counts in both rows |
| JR-021 | **static** | No code path *adds* the script tag to `index.html` | `scripts/checks/no-index-injection.sh`. Removal (JR-022) is the one permitted write, so the check is on injection, not on writing. Verified to **fail** on a reintroduced `Apply()` and on reintroduced `ReplaceLast` injection, not merely to pass today | | JR-021 | **static** | No code path *adds* the script tag to `index.html` | `scripts/checks/no-index-injection.sh`. Removal (JR-022) is the one permitted write, so the check is on injection, not on writing. Verified to **fail** on a reintroduced `Apply()` and on reintroduced `ReplaceLast` injection, not merely to pass today |
| JR-022 | T1 | A marked legacy patch is removed; unmarked content untouched | Foreign plugin's injection left intact | | JR-022 | T1 | A marked legacy patch is removed; unmarked content untouched | Foreign plugin's injection left intact |
| JR-023 | T1 + **T4** | Absent dependency disables only the overlay | Detection unit-testable; config-page display is live | | JR-023 | T1 + **T4** | Absent dependency disables only the overlay, and the log says so | Detection and log content covered by UT-012…015; the config-page banner is T4, verifiable only against a live server |
| JR-024 | T1 | A name containing markup renders escaped | `<script>` in an actor name from a hostile server | | JR-024 | T1 | A name containing markup renders escaped | `<script>` in an actor name from a hostile server |
| JR-025 | T1 | First result clearing the tier wins; disabled servers skipped | All servers fail; first server returns a below-tier match | | JR-025 | T1 | First result clearing the tier wins; disabled servers skipped | All servers fail; first server returns a below-tier match |
| JR-026 | T1 | Server 2 queried only for episodes server 1 lacked | Bundle with a gap in the middle of a season | | JR-026 | T1 | Server 2 queried only for episodes server 1 lacked | Bundle with a gap in the middle of a season |