Files
gramps 22b780e075 fix(ffmpeg): re-pin dead BtbN tag (first real recording 404'd), wrap download failures in actionable IOException
The 2026-08-09 pin was a DAILY build; BtbN retention aged it out and the cold-cache
download 404'd on the creator's first native recording attempt (2026-09-01,
startup.log). Re-pinned to the MONTH-END build autobuild-2026-08-31-13-27
(N-126342, lgpl-shared win64 — 2-year retention; tag+variant recorded in TASKS.md
per the licensing rule). Verified alive: HEAD 200 + zip contents (bin/ffmpeg.exe,
bin/ffprobe.exe, 7 libav DLLs) match the name-agnostic extractor.
Also closes the wrap-gap: HttpRequestException escaped the locator untouched,
surfacing a raw 'Response status code... 404' from the frame pump; now wrapped in
IOException with the refresh-pin-or-install-ffmpeg message. FfmpegLocatorTests 7/7
(one updated, one added for the 404 case).
2026-09-01 20:52:23 -07:00

187 lines
7.1 KiB
C#

using System.IO;
using System.IO.Compression;
using Xunit;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The FFmpeg locator (TASK 4 ship step 2): resolves ffmpeg.exe by probing PATH
/// first, then the cache in the tools directory, then pulling the pinned BtbN
/// LGPL-shared zip. The integration test drives the full ladder against a temp
/// tools dir and a fake downloader that returns a real in-memory zip; the units
/// pin down the failure and edge cases. No network, no real binary.
/// </summary>
public class FfmpegLocatorTests
{
private static byte[] MakeZip(
string exePath = "ffmpeg-master-latest-win64-lgpl-shared/bin/ffmpeg.exe",
string[]? dllPaths = null)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(exePath);
using (var writer = new StreamWriter(entry.Open()))
writer.Write("dummy ffmpeg binary");
foreach (var dll in dllPaths ?? Array.Empty<string>())
{
var dllEntry = archive.CreateEntry(dll);
using var dllWriter = new StreamWriter(dllEntry.Open());
dllWriter.Write("dummy dll");
}
}
return ms.ToArray();
}
private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private sealed class RecordingDownloader
{
public int Calls { get; private set; }
public byte[] Payload { get; set; } = MakeZip();
public Exception? Error { get; set; }
public Task<byte[]> DownloadAsync(string url, CancellationToken ct)
{
Calls++;
if (Error != null) throw Error;
return Task.FromResult(Payload);
}
}
[Fact]
public async Task Locate_Integration_FullDecisionLadder()
{
var toolsDir = TempDir();
try
{
// 1. PATH hit wins, downloader never invoked.
var pathDir = TempDir();
var pathExe = Path.Combine(pathDir, FfmpegLocator.FileName);
File.WriteAllText(pathExe, "user's ffmpeg");
var downloader = new RecordingDownloader();
var fromPath = new FfmpegLocator([pathDir], toolsDir, downloader.DownloadAsync);
Assert.Equal(pathExe, await fromPath.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 2. Cache hit skips the network.
var cached = Path.Combine(toolsDir, FfmpegLocator.FileName);
Directory.CreateDirectory(toolsDir);
File.WriteAllText(cached, "cached ffmpeg");
var fromCache = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
Assert.Equal(cached, await fromCache.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 3. Cold cache downloads exactly once, extracts ffmpeg.exe, and the
// second call serves the cache without re-downloading.
File.Delete(cached);
var coldTools = TempDir();
var cold = new FfmpegLocator([], coldTools, downloader.DownloadAsync);
var resolved = await cold.LocateAsync();
Assert.Equal(Path.Combine(coldTools, FfmpegLocator.FileName), resolved);
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
Assert.Equal(resolved, await cold.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_ZeroByteCache_IsRefreshed()
{
var toolsDir = TempDir();
try
{
File.WriteAllText(Path.Combine(toolsDir, FfmpegLocator.FileName), "");
var downloader = new RecordingDownloader();
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_SharedBuild_ExtractsDllsAlongsideExe()
{
var toolsDir = TempDir();
try
{
var downloader = new RecordingDownloader
{
Payload = MakeZip(dllPaths:
[
"ffmpeg-master-latest-win64-lgpl-shared/bin/avcodec-61.dll",
"ffmpeg-master-latest-win64-lgpl-shared/bin/avformat-61.dll",
])
};
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.True(File.Exists(Path.Combine(toolsDir, "avcodec-61.dll")));
Assert.True(File.Exists(Path.Combine(toolsDir, "avformat-61.dll")));
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_EmptyPayload_Throws()
{
var downloader = new RecordingDownloader { Payload = Array.Empty<byte>() };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
[Fact]
public async Task Locate_ZipWithoutFfmpegEntry_Throws()
{
var downloader = new RecordingDownloader { Payload = MakeZip(exePath: "readme.txt") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<InvalidDataException>(() => locator.LocateAsync());
}
[Fact]
public async Task Locate_DownloaderFailure_Propagates()
{
var downloader = new RecordingDownloader { Error = new HttpRequestException("offline") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
var ex = await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.IsType<HttpRequestException>(ex.InnerException);
Assert.Contains("pinned build", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Locate_DeadPin404_ThrowsActionableLocatorError()
{
// 2026-09-01 incident: the pinned autobuild tag aged out of BtbN retention;
// the raw HttpRequestException must never surface from the pump again.
var downloader = new RecordingDownloader
{
Error = new HttpRequestException("Response status code does not indicate success: 404 (Not Found)."),
};
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
var ex = await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.Contains("PATH", ex.Message);
Assert.Contains("PinnedUrl", ex.Message);
}
}