Test project: UT-001..011 for JR-016 and JR-022
The register defined 46 requirements and zero tests, so every Done in it rested on inspection. This adds the T1 tier: an xUnit project in the solution, covering the two units whose logic is pure enough to test without a Jellyfin host. JR-022 (UT-001..006) covers the cases where removal could reach too far -- another plugin's injection, and an unmarked look-alike script tag -- because index.html is a file JRay shares. UT-001 pins the trailing newline to the tag, without which every install cycle leaves another blank line behind. JR-016 (UT-007..011) covers specificity resolution, including the prioritised series inside an ignored genre that motivated the rule, and a Series rule valued Guid.Empty, which would otherwise swallow every movie in the library. RemoveInjection is reached through InternalsVisibleTo rather than being made public: it is factored out for testability, not part of the surface. The suite BUILDS but does not RUN here. Jellyfin.Controller framework- references Microsoft.AspNetCore.App, so the test host demands it even for pure logic, and this machine has no ASP.NET Core runtime at any version -- RollForward cannot substitute for a framework that is absent entirely. Fix is to install aspnet-runtime, which the CI image needs for the same reason. So every UT here is recorded as Written, not Passing, and no requirement is promoted to Done on their strength. A test whose result nobody has seen is not evidence. TRACES: UT-001, UT-002, UT-003, UT-004, UT-005, UT-006 | JR-022 TRACES: UT-007, UT-008, UT-009, UT-010, UT-011 | JR-016 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<!--
|
||||||
|
The plugin project treats warnings as errors and runs StyleCop. Tests do
|
||||||
|
not inherit that: their naming conventions differ deliberately (Method_
|
||||||
|
Condition_Expectation reads as documentation, and trips SA1300-family
|
||||||
|
rules), and a style failure in a test is not a defect in the thing under
|
||||||
|
test.
|
||||||
|
-->
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||||
|
<!--
|
||||||
|
The plugin targets net9.0 to match Jellyfin's ABI, but a machine that can
|
||||||
|
build it need not have the 9.0 *runtime* installed. Roll the test host
|
||||||
|
forward to whatever major is present so the suite runs on a developer box
|
||||||
|
and on CI without pinning either to a runtime that is not the plugin's.
|
||||||
|
-->
|
||||||
|
<RollForward>LatestMajor</RollForward>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Jellyfin.Plugin.JRay.Models;
|
||||||
|
using Jellyfin.Plugin.JRay.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JR-016 — prioritise/ignore rules resolve by specificity: Item beats Series
|
||||||
|
/// beats Genre. A scope+value holds only one action, so the only conflicts
|
||||||
|
/// possible are across scopes, and that is exactly what these cover.
|
||||||
|
///
|
||||||
|
/// TRACES: UT-007, UT-008, UT-009, UT-010, UT-011 | JR-016
|
||||||
|
/// </summary>
|
||||||
|
public class PolicyResolverTests
|
||||||
|
{
|
||||||
|
private static readonly Guid ItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
private static readonly Guid SeriesId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
|
|
||||||
|
private static MediaPolicyRule Rule(PolicyScope scope, string value, PolicyAction action)
|
||||||
|
=> new() { Scope = scope, Value = value, Action = action };
|
||||||
|
|
||||||
|
// UT-007
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_WithNoMatchingRule_ReturnsNull()
|
||||||
|
{
|
||||||
|
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore) };
|
||||||
|
|
||||||
|
Assert.Null(PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Drama" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-008
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_ItemRuleBeatsSeriesRule()
|
||||||
|
{
|
||||||
|
var rules = new List<MediaPolicyRule>
|
||||||
|
{
|
||||||
|
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Ignore),
|
||||||
|
Rule(PolicyScope.Item, ItemId.ToString("D"), PolicyAction.Prioritise),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, Array.Empty<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-009
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_PrioritisedSeriesInsideIgnoredGenre_SeriesWins()
|
||||||
|
{
|
||||||
|
// The case that motivated specificity resolution: an admin ignores a
|
||||||
|
// whole genre but wants one series out of it anyway. If genre won, the
|
||||||
|
// more specific instruction would be silently discarded.
|
||||||
|
var rules = new List<MediaPolicyRule>
|
||||||
|
{
|
||||||
|
Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore),
|
||||||
|
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Prioritise),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Anime" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-010
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_GenreMatchIsCaseInsensitive()
|
||||||
|
{
|
||||||
|
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "anime", PolicyAction.Ignore) };
|
||||||
|
|
||||||
|
Assert.Equal(PolicyAction.Ignore, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "AnImE" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-011
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_SeriesRuleDoesNotMatchNonEpisode()
|
||||||
|
{
|
||||||
|
// A movie carries Guid.Empty as its series id. A series rule whose value
|
||||||
|
// happened to be an empty GUID must not swallow every movie in the
|
||||||
|
// library.
|
||||||
|
var rules = new List<MediaPolicyRule>
|
||||||
|
{
|
||||||
|
Rule(PolicyScope.Series, Guid.Empty.ToString("D"), PolicyAction.Ignore),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(PolicyResolver.Resolve(rules, ItemId, Guid.Empty, Array.Empty<string>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Jellyfin.Plugin.JRay.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Jellyfin.Plugin.JRay.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JR-022 — an earlier JRay injected its overlay script into index.html on
|
||||||
|
/// disk. Those users must not be left with a stale injection pointing at
|
||||||
|
/// endpoints that have since changed, so removal survives even though JR-021
|
||||||
|
/// deleted the injection that created it.
|
||||||
|
///
|
||||||
|
/// Removal keys on JRay's own marker. The cases that matter are the ones where
|
||||||
|
/// it could reach too far: another plugin's injection, or a script tag that
|
||||||
|
/// looks like JRay's but carries no marker.
|
||||||
|
///
|
||||||
|
/// TRACES: UT-001, UT-002, UT-003, UT-004, UT-005 | JR-022
|
||||||
|
/// </summary>
|
||||||
|
public class WebClientPatchServiceTests
|
||||||
|
{
|
||||||
|
private const string Marker = "<!-- jray-overlay -->";
|
||||||
|
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
|
||||||
|
|
||||||
|
// UT-001
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInjection_WithMarkedTagAndNewline_RestoresOriginalBytes()
|
||||||
|
{
|
||||||
|
var original = "<html><body><div>x</div>\n</body></html>";
|
||||||
|
var patched = "<html><body><div>x</div>\n" + ScriptTag + Marker + "\n</body></html>";
|
||||||
|
|
||||||
|
// The trailing newline goes with the tag. If it did not, every
|
||||||
|
// install/uninstall cycle would leave another blank line behind.
|
||||||
|
Assert.Equal(original, WebClientPatchService.RemoveInjection(patched));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-002
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInjection_WithMarkedTagAndNoNewline_RemovesTag()
|
||||||
|
{
|
||||||
|
var patched = "<html><body>" + ScriptTag + Marker + "</body></html>";
|
||||||
|
|
||||||
|
Assert.Equal("<html><body></body></html>", WebClientPatchService.RemoveInjection(patched));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-003
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInjection_WithNoMarker_LeavesDocumentUnchanged()
|
||||||
|
{
|
||||||
|
var clean = "<html><body><div>x</div>\n</body></html>";
|
||||||
|
|
||||||
|
Assert.Equal(clean, WebClientPatchService.RemoveInjection(clean));
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-004
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInjection_IsIdempotent()
|
||||||
|
{
|
||||||
|
var patched = "<html><body>" + ScriptTag + Marker + "\n</body></html>";
|
||||||
|
|
||||||
|
var once = WebClientPatchService.RemoveInjection(patched);
|
||||||
|
var twice = WebClientPatchService.RemoveInjection(once);
|
||||||
|
|
||||||
|
// Startup calls this unconditionally, so it runs on every boot forever
|
||||||
|
// after the patch is gone.
|
||||||
|
Assert.Equal(once, twice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-005
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInjection_LeavesAnotherPluginsInjectionIntact()
|
||||||
|
{
|
||||||
|
var foreign = "<script defer src=\"/Plugins/Other/Script\"></script><!-- other-overlay -->";
|
||||||
|
var patched = "<html><body>" + foreign + ScriptTag + Marker + "\n</body></html>";
|
||||||
|
|
||||||
|
var cleaned = WebClientPatchService.RemoveInjection(patched);
|
||||||
|
|
||||||
|
// The marker is what makes removal unambiguous. Removing anything we did
|
||||||
|
// not add is the failure this guards: it is another plugin's file too.
|
||||||
|
Assert.Contains(foreign, cleaned, System.StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain(Marker, cleaned, System.StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UT-006
|
||||||
|
[Fact]
|
||||||
|
public void RemoveInjection_WithUnmarkedLookalikeTag_LeavesItAlone()
|
||||||
|
{
|
||||||
|
// Same script tag, no marker: JRay did not write this, so JRay does not
|
||||||
|
// remove it.
|
||||||
|
var patched = "<html><body>" + ScriptTag + "\n</body></html>";
|
||||||
|
|
||||||
|
Assert.Equal(patched, WebClientPatchService.RemoveInjection(patched));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
#
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay.Tests", "Jellyfin.Plugin.JRay.Tests\Jellyfin.Plugin.JRay.Tests.csproj", "{104C1021-3155-4404-9CA0-8ED8F310A152}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -24,5 +26,20 @@ Global
|
|||||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU
|
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU
|
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU
|
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Lets the test project reach internals such as
|
||||||
|
WebClientPatchService.RemoveInjection, which is factored out precisely
|
||||||
|
so the removal logic is testable without touching a filesystem. -->
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>Jellyfin.Plugin.JRay.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Remove="Configuration\configPage.html" />
|
<None Remove="Configuration\configPage.html" />
|
||||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||||
|
|||||||
+47
-4
@@ -16,6 +16,26 @@ Tag code with `// TRACES: JR-012 | SR-002`.
|
|||||||
| `JR` | Everything this plugin does — truth format, API, overlay, exchange client |
|
| `JR` | Everything this plugin does — truth format, API, overlay, exchange client |
|
||||||
| `UT` / `IT` | Unit / integration tests |
|
| `UT` / `IT` | Unit / integration tests |
|
||||||
|
|
||||||
|
## Tests (UT)
|
||||||
|
|
||||||
|
| ID | Asserts | Covers | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| UT-001 | Marked tag **and its trailing newline** removed — no blank line accumulates per upgrade cycle | JR-022 | Written |
|
||||||
|
| UT-002 | Marked tag with no trailing newline removed | JR-022 | Written |
|
||||||
|
| UT-003 | Document without the marker left byte-identical | JR-022 | Written |
|
||||||
|
| UT-004 | Removal is idempotent — startup runs it on every boot forever after | JR-022 | Written |
|
||||||
|
| UT-005 | **Another plugin's injection left intact** — it is their file too | JR-022 | Written |
|
||||||
|
| UT-006 | An unmarked look-alike script tag is left alone — JRay did not write it | JR-022 | Written |
|
||||||
|
| UT-007 | No matching rule resolves to `null` | JR-016 | Written |
|
||||||
|
| UT-008 | Item rule beats Series rule | JR-016 | Written |
|
||||||
|
| UT-009 | **Prioritised series inside an ignored genre — series wins** | JR-016 | Written |
|
||||||
|
| UT-010 | Genre matching is case-insensitive | JR-016 | Written |
|
||||||
|
| UT-011 | A Series rule valued `Guid.Empty` does not swallow every movie | JR-016 | Written |
|
||||||
|
|
||||||
|
`Written` rather than `Passing`: see the execution blocker below. A test whose
|
||||||
|
result nobody has seen is not evidence, and recording it as passing would be the
|
||||||
|
same failure as counting a GPU-only test as covered.
|
||||||
|
|
||||||
`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
|
||||||
costs nothing and cannot go stale against a prefix.
|
costs nothing and cannot go stale against a prefix.
|
||||||
@@ -167,9 +187,32 @@ renumbering it. Calling jRay's live tier "T3" would make the gate count
|
|||||||
live-only requirements as covered — the exact class of error the 158% coverage
|
live-only requirements as covered — the exact class of error the 158% coverage
|
||||||
bug belongs to.
|
bug belongs to.
|
||||||
|
|
||||||
**There is no test project today.** That is the single largest gap in this
|
`Jellyfin.Plugin.JRay.Tests` (xUnit, in the solution) carries the T1 tier. It
|
||||||
register: 46 requirements, zero `UT`/`IT` IDs, so measured coverage will open at
|
builds clean alongside the plugin.
|
||||||
zero and every `Done` above rests on inspection rather than evidence.
|
|
||||||
|
### Execution blocker — the suite does not run on this machine
|
||||||
|
|
||||||
|
The plugin framework-references **`Microsoft.AspNetCore.App`** through
|
||||||
|
`Jellyfin.Controller`, so the test host demands that shared framework even for
|
||||||
|
tests that touch only pure logic. This box has `Microsoft.NETCore.App` 8.0.29
|
||||||
|
and 10.0.10 and **no ASP.NET Core runtime at all**, so `dotnet test` aborts
|
||||||
|
before a single test executes:
|
||||||
|
|
||||||
|
```
|
||||||
|
Framework: 'Microsoft.AspNetCore.App', version '9.0.0' — No frameworks were found.
|
||||||
|
```
|
||||||
|
|
||||||
|
`<RollForward>LatestMajor</RollForward>` on the test project solves the *other*
|
||||||
|
half — the plugin targets `net9.0` to match Jellyfin's ABI, and no .NET 9
|
||||||
|
runtime is installed either — but roll-forward cannot conjure a framework of
|
||||||
|
which no version exists.
|
||||||
|
|
||||||
|
**Fix:** install the ASP.NET Core runtime (`aspnet-runtime` on Arch). It is
|
||||||
|
needed on the CI host for the same reason, so this belongs in the CI image
|
||||||
|
rather than in a developer's setup notes.
|
||||||
|
|
||||||
|
Until then every `UT` above is **Written, not Passing**, and no requirement
|
||||||
|
should be promoted to `Done` on the strength of them.
|
||||||
|
|
||||||
### Per-requirement verification plan
|
### Per-requirement verification plan
|
||||||
|
|
||||||
@@ -243,7 +286,7 @@ python3 scripts/vendor/jray-project/scripts/traceability/extract_traces.py \
|
|||||||
--system-spec scripts/vendor/jray-project/SPEC.md \
|
--system-spec scripts/vendor/jray-project/SPEC.md \
|
||||||
--types JR \
|
--types JR \
|
||||||
--suffixes .cs,.js,.sh \
|
--suffixes .cs,.js,.sh \
|
||||||
--scan-roots Jellyfin.Plugin.JRay,scripts/checks \
|
--scan-roots Jellyfin.Plugin.JRay,Jellyfin.Plugin.JRay.Tests,scripts/checks \
|
||||||
--format coverage
|
--format coverage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user