Files
LlamaCasty/ytLive.Tests/FfmpegLocatorTests.cs
T
gramps 4fcf76289c TASK 48 item 1: bundle ffmpeg — the app no longer downloads an executable
Store policy 10.2.2 forbids dynamic code inclusion, and this was the only
third-party binary the app ever downloaded and ran. It had already failed in
production: the pin aged out of BtbN's 14-day retention and the download 404'd
on the creator's first real recording attempt (2026-09-01). Bundling kills
that failure class permanently and deletes the startup network dependency.

- scripts/fetch-ffmpeg.sh populates a gitignored ./ffmpeg from a pinned
  month-end BtbN LGPL-shared artifact, SHA-256 verified before extraction,
  idempotent, FFMPEG_ZIP= override. 135 MB of DLLs in git would tax every
  commit on this 9p tree; the URL + hash are the audit trail LGPL §6 wants.
- ytLive.csproj copies ffmpeg\** to output and publish (Always), and a
  GuardBundledFfmpeg target fails the BUILD if the folder is missing, so a
  fresh clone finds out before a take rather than during one.
- FfmpegLocator loses the downloader, the zip extraction and the
  %APPDATA%\ytLlive\tools cache. Order is now bundle -> PATH, deliberately
  inverted: a PATH ffmpeg may be a different major version or a GPL build,
  and the bundle is what was pinned, license-vetted and tested. A missing
  bundle throws loudly; the old "found something on disk" path could satisfy
  a missing bundle with a stale copy, and silent drift is the worst failure
  mode for a dependency certification inspects.
- ffplay.exe (18 MB) is not shipped; nothing calls it.
- THIRD-PARTY-NOTICES.txt: "downloads" -> "bundled" throughout, and the stale
  build tag autobuild-2026-08-09-13-03 — the tag that 404'd — corrected to
  autobuild-2026-08-31-13-27 with the exact artifact name and archive hash.
  OpenH264 made precise: compiled INTO avcodec-63.dll, no separate binary.

Checked whether other third-party binaries needed bundling: no. e_sqlite3.dll
and WebView2Loader.dll already ship via NuGet into runtimes\win-x64\native\,
OpenH264 is inside avcodec-63.dll, the rest are managed. Two gaps remain that
bundling cannot fix, recorded in the map: the WebView2 Evergreen runtime is a
machine prerequisite (TASK 48 item 2), and Velopack's updater downloads and
executes, so its teardown is a compliance action.

Tests: 16 locator facts, suite 379/379, 0 warnings. The integration test runs
the bundled ffmpeg -version, which is the only assertion that proves the
sibling libav*.dll resolved — presence checks pass with the DLLs missing and a
shared build then fails to start. TheLocatorCarriesNoNetworkCapability keeps
10.2.2 from rotting back.

Analysis: TASKS/research-store-certification.md §4.
2026-09-28 09:38:21 -07:00

247 lines
11 KiB
C#

using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Xunit;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The FFmpeg locator (TASK 4 ship step 2, rewritten by TASK 48 item 1).
///
/// <para><b>What changed.</b> The locator used to pin a BtbN release URL, download the zip
/// on first run, extract it, and execute it. That is Windows Store policy 10.2.2 (dynamic
/// code inclusion — <c>TASKS/research-store-certification.md</c> §4) and it had already
/// failed in production: the pin aged out of BtbN's 14-day retention and the download
/// <b>404'd on the creator's first real recording attempt</b> (2026-09-01). FFmpeg is now
/// <b>bundled</b>, fetched at build time by <c>scripts/fetch-ffmpeg.sh</c> and copied into
/// <c>ffmpeg/</c> beside the executable. Nothing is downloaded at runtime, ever.</para>
///
/// <para><b>Why the order flipped.</b> PATH used to win ("the user's own install wins").
/// With a bundle in the package that is backwards: a PATH <c>ffmpeg</c> may be a different
/// major version or a GPL build, and the bundle is the one that was pinned, license-vetted
/// and tested. PATH remains the escape hatch for someone who deliberately wants their own.</para>
/// </summary>
public class FfmpegLocatorTests
{
private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
/// <summary>Repo root, found by walking up to a file only the repo has.</summary>
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
if (File.Exists(Path.Combine(dir.FullName, "ytLive.csproj"))) return dir.FullName;
dir = dir.Parent;
}
throw new FileNotFoundException($"could not locate ytLive.csproj above {AppContext.BaseDirectory}");
}
private static string RepoFile(params string[] parts)
{
var candidate = Path.Combine(new[] { RepoRoot() }.Concat(parts).ToArray());
if (File.Exists(candidate)) return candidate;
throw new FileNotFoundException($"could not locate {Path.Combine(parts)} under {RepoRoot()}");
}
/// <summary>
/// The Good Dog integration test for this unit: prove the whole chain end to end —
/// the build actually copied the bundle into the app's output, the locator resolves it
/// without any network, and the located binary <b>executes</b>. Running it is the part
/// that matters most: FFmpeg is an lgpl-<i>shared</i> build, so it only starts if every
/// <c>libav*.dll</c> sits beside the exe. A packaging change that dropped the DLLs would
/// pass every existence check and still hand the user a broken encoder.
/// </summary>
[Fact]
public async Task Locate_Integration_TheBuiltOutputShipsABundleThatRuns()
{
// NOTE: the output root is derived from the *project* directory, not from the
// repository folder name. The repo directory happens to also be called "ytLive",
// so naively appending "ytLive" would resolve to a path that only works on this
// machine's checkout.
var projectDir = Path.GetDirectoryName(RepoFile("ytLive.csproj"))!;
var binRoot = Path.Combine(projectDir, "bin");
Assert.True(Directory.Exists(binRoot),
$"no build output at {binRoot} — build ytLive.csproj before running this test");
var withBundle = Directory
.GetDirectories(binRoot, "*", SearchOption.AllDirectories)
.Where(d => File.Exists(Path.Combine(d, FfmpegLocator.BundleFolderName, FfmpegLocator.FileName)))
.ToArray();
Assert.True(withBundle.Length > 0,
$"No build output under {binRoot} carries ffmpeg/{FfmpegLocator.FileName}. " +
"Run ./scripts/fetch-ffmpeg.sh, then rebuild — the csproj GuardBundledFfmpeg " +
"target should have failed the build if the source folder was missing.");
var outputDir = withBundle[0];
var bundleDir = Path.Combine(outputDir, FfmpegLocator.BundleFolderName);
// The sibling DLLs are the shared-build's whole dependency story.
foreach (var required in new[] { FfmpegLocator.ProbeFileName, "avcodec-63.dll", "avformat-63.dll" })
Assert.True(File.Exists(Path.Combine(bundleDir, required)),
$"{required} must ship beside {FfmpegLocator.FileName} — a shared build resolves DLLs " +
"from the executable's own directory.");
// The locator resolves it with an empty PATH, i.e. nothing outside the bundle.
var resolved = await new FfmpegLocator(searchDirs: [], bundleRoot: outputDir).LocateAsync();
Assert.Equal(Path.Combine(bundleDir, FfmpegLocator.FileName), resolved);
// And it actually runs. This is what proves the DLLs resolve.
var info = new ProcessStartInfo
{
FileName = resolved,
Arguments = "-version",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = Process.Start(info)!;
var stdout = process.StandardOutput.ReadToEnd();
Assert.True(process.WaitForExit(milliseconds: 30_000), "bundled ffmpeg -version did not exit in 30s");
Assert.Equal(0, process.ExitCode);
Assert.Contains("ffmpeg version", stdout, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Locate_PrefersTheBundle_OverAPathInstall()
{
var root = TempDir();
try
{
var pathDir = Path.Combine(root, "on-path");
var bundleDir = Path.Combine(root, FfmpegLocator.BundleFolderName);
Directory.CreateDirectory(pathDir);
Directory.CreateDirectory(bundleDir);
File.WriteAllText(Path.Combine(pathDir, FfmpegLocator.FileName), "user's ffmpeg");
var bundled = Path.Combine(bundleDir, FfmpegLocator.FileName);
File.WriteAllText(bundled, "bundled ffmpeg");
var resolved = await new FfmpegLocator([pathDir], root).LocateAsync();
Assert.Equal(bundled, resolved);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task Locate_FallsBackToPath_WhenNoBundleIsPresent()
{
var root = TempDir();
try
{
var pathDir = Path.Combine(root, "on-path");
Directory.CreateDirectory(pathDir);
var onPath = Path.Combine(pathDir, FfmpegLocator.FileName);
File.WriteAllText(onPath, "user's ffmpeg");
var resolved = await new FfmpegLocator([pathDir], root).LocateAsync();
Assert.Equal(onPath, resolved);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public async Task Locate_ZeroByteBundle_IsNotUsable()
{
var root = TempDir();
try
{
var bundleDir = Path.Combine(root, FfmpegLocator.BundleFolderName);
Directory.CreateDirectory(bundleDir);
File.WriteAllText(Path.Combine(bundleDir, FfmpegLocator.FileName), "");
var ex = await Assert.ThrowsAsync<IOException>(
() => new FfmpegLocator([], root).LocateAsync());
Assert.Contains(FfmpegLocator.BundleFolderName, ex.Message, StringComparison.Ordinal);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// A missing bundle must be LOUD and must say where to look. The old ladder had a
/// "found something on disk" path that could quietly satisfy a missing bundle with a
/// stale cached copy — silent drift in exactly the dependency Store certification
/// inspects, and silent drift is how a packaging bug ships.
/// </summary>
[Fact]
public async Task Locate_NoBundleAndNoPath_ThrowsNamingTheBundleFolderAndTheFix()
{
var root = TempDir();
try
{
var ex = await Assert.ThrowsAsync<IOException>(
() => new FfmpegLocator([], root).LocateAsync());
Assert.Contains(FfmpegLocator.BundleFolderName, ex.Message, StringComparison.Ordinal);
Assert.Contains("fetch-ffmpeg.sh", ex.Message, StringComparison.Ordinal);
Assert.Contains("PATH", ex.Message, StringComparison.Ordinal);
}
finally
{
Directory.Delete(root, recursive: true);
}
}
/// <summary>
/// The Store-gate assertion, kept as a source-level fact so it cannot rot: the locator
/// must contain no network capability at all. Policy 10.2.2 forbids download-and-execute,
/// and this is the only type in the app that ever executed a downloaded binary. If a
/// future change reintroduces an HttpClient, a URL, or a downloader here, this fails —
/// which is the entire point.
///
/// <para>Patterns are <b>call-shaped</b> (<c>new HttpClient</c>, not <c>HttpClient</c>)
/// on purpose. A bare word match flags the file's own doc comment, which legitimately
/// says "nothing is downloaded" — a test that fails on the prose explaining the
/// guarantee is a test that gets deleted instead of obeyed.</para>
/// </summary>
[Theory]
[InlineData("new HttpClient")]
[InlineData("HttpClient()")]
[InlineData("GetByteArrayAsync(")]
[InlineData("GetStreamAsync(")]
[InlineData("WebRequest.Create")]
[InlineData("DownloadFileTaskAsync")]
[InlineData("http://")]
[InlineData("https://")]
[InlineData("PinnedUrl =")]
[InlineData("ExtractBinaries(")]
public void TheLocatorCarriesNoNetworkCapability(string forbidden)
{
var source = File.ReadAllText(
RepoFile("Services", "Encoder", "FfmpegLocator.cs"));
Assert.DoesNotContain(forbidden, source, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The guard that makes a forgotten bundle a build failure rather than a runtime one.
/// Without it, a fresh clone with no <c>ffmpeg/</c> builds cleanly and the app only
/// discovers the problem when the creator presses Record.
/// </summary>
[Fact]
public void TheBuildRefusesToCompileWithoutTheBundle()
{
var csproj = File.ReadAllText(RepoFile("ytLive.csproj"));
Assert.Contains("GuardBundledFfmpeg", csproj, StringComparison.Ordinal);
Assert.Contains("ffmpeg\\ffmpeg.exe", csproj, StringComparison.Ordinal);
Assert.Contains("CopyToPublishDirectory=\"Always\"", csproj, StringComparison.Ordinal);
}
}