606 lines
24 KiB
C#
606 lines
24 KiB
C#
using System.Diagnostics;
|
|
using System.IO.Compression;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Windows;
|
|
using Paca.App;
|
|
using Paca.Browser;
|
|
using Paca.Browser.Extensions;
|
|
using Paca.Browser.Tabs;
|
|
using Paca.Core;
|
|
using Paca.Core.Hls;
|
|
using Paca.Server;
|
|
using Paca.Tests.Infrastructure;
|
|
using Xunit;
|
|
|
|
namespace Paca.Tests.E2E;
|
|
|
|
[Collection("E2E")]
|
|
public sealed class ComprehensiveE2EScenarioTests : IDisposable
|
|
{
|
|
private readonly string _testRoot;
|
|
private readonly string _dataDir;
|
|
private readonly string _libDir;
|
|
|
|
public ComprehensiveE2EScenarioTests()
|
|
{
|
|
_testRoot = Path.Combine(Path.GetTempPath(), "VD_ComprehensiveE2E_" + Guid.NewGuid().ToString("N"));
|
|
_dataDir = Path.Combine(_testRoot, "data");
|
|
_libDir = Path.Combine(_testRoot, "library");
|
|
|
|
Directory.CreateDirectory(_dataDir);
|
|
Directory.CreateDirectory(_libDir);
|
|
Environment.SetEnvironmentVariable("VD_DATA_DIR", _dataDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
if (Directory.Exists(_testRoot))
|
|
Directory.Delete(_testRoot, true);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시나리오 1: 크롬 확장 프로그램 대규모 병렬 설치, 악의적/손상 패키지 방어,
|
|
/// 10,000회 고부하 검색 스트레스 및 수명주기 무결성 E2E
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Scenario1_ChromeExtensions_MassiveLifecycle_And_Resilience_E2E()
|
|
{
|
|
var extStorage = Path.Combine(_dataDir, "extensions");
|
|
var host = new TestExtensionHost();
|
|
var manager = new ExtensionManager(extStorage, host);
|
|
|
|
// 1. 20개의 가상 크롬 확장 패키지 생성 및 병렬 설치 스트레스
|
|
const int extCount = 20;
|
|
var installTasks = Enumerable.Range(1, extCount).Select(async i =>
|
|
{
|
|
var extId = $"test_extension_{i:D4}_abcdefghijklmnop";
|
|
var extName = $"Super Extension {i}";
|
|
var extVer = $"1.{i}.0";
|
|
var desc = $"Heavy load test extension number {i}";
|
|
|
|
var crxBytes = CreateSampleCrx3Bytes(extName, extVer, desc,
|
|
("manifest.json", CreateManifestJson(extName, extVer, desc)),
|
|
("content.js", Encoding.UTF8.GetBytes($"console.log('ext_{i}');")),
|
|
("icons/icon128.png", new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x00, 0x01, 0x02, 0x03 }));
|
|
|
|
using var ms = new MemoryStream(crxBytes);
|
|
return await manager.InstallFromPackageStreamAsync(ms, explicitId: extId);
|
|
});
|
|
|
|
var installedItems = await Task.WhenAll(installTasks);
|
|
Assert.Equal(extCount, installedItems.Length);
|
|
Assert.Equal(extCount, manager.Extensions.Count);
|
|
|
|
// 2. 활성화/비활성화 100회 토글 스트레스
|
|
var rng = new Random(42);
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
var target = manager.Extensions[rng.Next(manager.Extensions.Count)];
|
|
var newState = !target.IsEnabled;
|
|
await manager.SetEnabledAsync(target.Id, newState);
|
|
Assert.Equal(newState, target.IsEnabled);
|
|
}
|
|
|
|
// 3. 악의적/손상된 패키지 주입 방어 (Resilience)
|
|
// 3-a) 잘못된 Magic Byte
|
|
using (var badMagic = new MemoryStream(new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04 }))
|
|
{
|
|
await Assert.ThrowsAnyAsync<Exception>(() => manager.InstallFromPackageStreamAsync(badMagic));
|
|
}
|
|
|
|
// 3-b) Zip Slip 공격 경로 포함 패키지
|
|
var maliciousZip = CreateMaliciousZipSlipBytes();
|
|
using (var maliciousStream = new MemoryStream(maliciousZip))
|
|
{
|
|
await Assert.ThrowsAnyAsync<Exception>(() => manager.InstallFromPackageStreamAsync(maliciousStream));
|
|
}
|
|
|
|
// 기존 20개 확장이 손상되지 않고 유지되는지 확인
|
|
Assert.Equal(extCount, manager.Extensions.Count);
|
|
|
|
// 4. 마켓플레이스 10,000회 동시 고속 검색 및 카테고리 필터링 스트레스
|
|
var searchQueries = new[] { "adblock", "youtube", "dark", "volume", "speed", "번역", "비밀번호", "cjpalhdlnbpafiamejdnhcphjbkeiagm", "none_existing_xyz" };
|
|
var installedIds = manager.Extensions.Select(x => x.Id).ToList();
|
|
|
|
var searchTasks = Enumerable.Range(0, 10_000).Select(i => Task.Run(() =>
|
|
{
|
|
var q = searchQueries[i % searchQueries.Length];
|
|
var results = ExtensionMarketplace.Search(q, installedIds: installedIds);
|
|
Assert.NotNull(results);
|
|
}));
|
|
|
|
await Task.WhenAll(searchTasks);
|
|
|
|
// 5. 일괄 삭제 및 스토리지 무결성 확인
|
|
foreach (var item in installedItems.ToList())
|
|
{
|
|
await manager.UninstallAsync(item.Id);
|
|
}
|
|
|
|
Assert.Empty(manager.Extensions);
|
|
var subDirs = Directory.GetDirectories(extStorage);
|
|
Assert.Empty(subDirs);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시나리오 2: HLS 다중 세그먼트 가상 스트리밍 서버 구축, 결함 주입(404, 500, Retry) 및 무결 복구 E2E
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Scenario2_HlsDownloader_FaultInjection_And_Recovery_E2E()
|
|
{
|
|
// 1. 가상 HLS 미디어 HTTP 서버 구동 (HttpListener)
|
|
const int segmentCount = 20;
|
|
var port = GetFreePort();
|
|
using var server = new SimpleTestHlsServer(port, segmentCount);
|
|
server.Start();
|
|
|
|
var playlistUrl = $"http://127.0.0.1:{port}/playlist.m3u8";
|
|
var outputDir = Path.Combine(_testRoot, "hls_output");
|
|
Directory.CreateDirectory(outputDir);
|
|
|
|
var outputPath = Path.Combine(outputDir, "output_video.ts");
|
|
|
|
// 2. 다운로드 엔진 설정 (FakeMediaMuxer 주입하여 순수 파이프라인 무결성 검증)
|
|
using var http = new HttpClient();
|
|
var config = new Paca.Core.Config.AppConfig { Concurrency = 4 };
|
|
var muxer = new FakeMediaMuxer();
|
|
var downloader = new HlsDownloader(http, config, muxer);
|
|
|
|
var progressUpdates = new List<double>();
|
|
var progress = new Progress<Paca.Core.Models.DownloadProgress>(p =>
|
|
{
|
|
progressUpdates.Add(p.Percent);
|
|
});
|
|
|
|
// 3. 결함 주입 환경에서 다운로드 실행
|
|
server.InjectFaults = true; // 일부 세그먼트 첫 요청 시 500 반환 후 재시도 시 200 반환
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
|
var outputBase = Path.Combine(outputDir, "output_video");
|
|
var resultPath = await downloader.DownloadAsync(new Uri(playlistUrl), outputBase, progress, cts.Token);
|
|
|
|
// 4. 검증: 최종 파일이 온전히 생성되었고 세그먼트 데이터가 모두 병합되었는지 확인
|
|
Assert.True(File.Exists(resultPath), "최종 다운로드 파일이 존재해야 합니다.");
|
|
var fileInfo = new FileInfo(resultPath);
|
|
Assert.True(fileInfo.Length >= segmentCount * 1000, "전체 세그먼트 데이터가 결함 복구 후 온전히 병합되어야 합니다.");
|
|
Assert.NotEmpty(progressUpdates);
|
|
Assert.True(progressUpdates.Max() >= 99.0, "진행률 최대치가 100%에 도달해야 합니다.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시나리오 3: 원격 Kestrel 서버 기동, 30개 동시 클라이언트 병렬 페어링,
|
|
/// 큐 CRUD 제어, 라이브러리 조회 및 HTTP 206 Partial Content 스트리밍 고부하 E2E
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Scenario3_RemoteServer_HighConcurrency_MobilePairing_And_Streaming_E2E()
|
|
{
|
|
// 1. 가짜 미디어 라이브러리 파일 생성 (2MB)
|
|
var sampleMedia = Path.Combine(_libDir, "test_movie.mp4");
|
|
var testBytes = new byte[2 * 1024 * 1024];
|
|
new Random(1234).NextBytes(testBytes);
|
|
File.WriteAllBytes(sampleMedia, testBytes);
|
|
|
|
var queue = new TestScenarioQueueAdapter();
|
|
const string authToken = "super-secret-e2e-token-2026";
|
|
|
|
await using var server = await ServerHost.StartAsync(new ServerOptions
|
|
{
|
|
Port = 0,
|
|
Token = authToken,
|
|
LibraryFolder = _libDir,
|
|
Queue = queue
|
|
});
|
|
|
|
// 2. 30개의 동시 가상 클라이언트 병렬 고부하 요청
|
|
const int clientCount = 30;
|
|
var clientTasks = Enumerable.Range(1, clientCount).Select(async clientId =>
|
|
{
|
|
using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
|
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
|
|
|
|
// A) 큐 추가 (POST /api/queue)
|
|
var addResp = await http.PostAsJsonAsync("/api/queue", new { url = $"https://youtube.com/watch?v=client_{clientId}", title = $"Video from Client {clientId}" });
|
|
Assert.True(addResp.IsSuccessStatusCode);
|
|
var queueJson = await addResp.Content.ReadFromJsonAsync<JsonElement>();
|
|
var queueId = queueJson.GetProperty("id").GetString()!;
|
|
|
|
// B) 큐 일시정지 & 재개 (POST /api/queue/{id}/pause, resume)
|
|
var pauseResp = await http.PostAsync($"/api/queue/{queueId}/pause", null);
|
|
Assert.True(pauseResp.IsSuccessStatusCode);
|
|
var resumeResp = await http.PostAsync($"/api/queue/{queueId}/resume", null);
|
|
Assert.True(resumeResp.IsSuccessStatusCode);
|
|
|
|
// C) 라이브러리 목록 조회 (GET /api/library)
|
|
var libResp = await http.GetAsync("/api/library");
|
|
Assert.True(libResp.IsSuccessStatusCode);
|
|
var libItems = await libResp.Content.ReadFromJsonAsync<List<LibraryItemDto>>();
|
|
Assert.NotNull(libItems);
|
|
Assert.NotEmpty(libItems);
|
|
var mediaId = libItems[0].Id;
|
|
|
|
// D) HTTP 206 Partial Content 미디어 범위 스트리밍 (GET /stream/{id})
|
|
var rangeRequest = new HttpRequestMessage(HttpMethod.Get, $"/stream/{mediaId}");
|
|
rangeRequest.Headers.Range = new RangeHeaderValue(1000, 5000); // 4001 bytes
|
|
var streamResp = await http.SendAsync(rangeRequest);
|
|
|
|
Assert.Equal(HttpStatusCode.PartialContent, streamResp.StatusCode);
|
|
var streamBytes = await streamResp.Content.ReadAsByteArrayAsync();
|
|
Assert.Equal(4001, streamBytes.Length);
|
|
|
|
// 원본 바이트와 범위 데이터 일치 검증
|
|
var expectedSlice = testBytes.AsSpan(1000, 4001).ToArray();
|
|
Assert.Equal(expectedSlice, streamBytes);
|
|
|
|
// E) 큐 취소 (POST /api/queue/{id}/cancel)
|
|
var cancelResp = await http.PostAsync($"/api/queue/{queueId}/cancel", null);
|
|
Assert.True(cancelResp.IsSuccessStatusCode);
|
|
});
|
|
|
|
await Task.WhenAll(clientTasks);
|
|
Assert.Equal(clientCount * 4, queue.TotalOperations);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시나리오 4: 브라우저 대규모 탭 생성, 닫은 탭 복원(LIFO), 탭 아카이브 500개 누적 및 세션 영속 복원 E2E
|
|
/// </summary>
|
|
[Fact]
|
|
public void Scenario4_BrowserTabSystem_MassiveTabs_Archive_And_SessionRecovery_E2E()
|
|
{
|
|
var tabManager = new TabManager(() => new TabItem());
|
|
|
|
// 1. 50개 탭 생성 및 속성 설정
|
|
for (int i = 1; i <= 50; i++)
|
|
{
|
|
var tab = tabManager.NewTab(isPrivate: i % 5 == 0);
|
|
tab.Url = $"https://example.com/page/{i}";
|
|
tab.Title = $"Example Page {i}";
|
|
}
|
|
|
|
Assert.Equal(50, tabManager.Tabs.Count);
|
|
|
|
// 2. 20개 탭 닫기 및 닫은 탭 LIFO 복원 스택 검증
|
|
var closedUrls = new List<string>();
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
var target = tabManager.Tabs.Last();
|
|
closedUrls.Add(target.Url);
|
|
tabManager.CloseTab(target);
|
|
}
|
|
|
|
Assert.Equal(30, tabManager.Tabs.Count);
|
|
|
|
// 10개 탭 복원
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
var reopened = tabManager.ReopenClosedTab();
|
|
Assert.NotNull(reopened);
|
|
Assert.Equal(closedUrls[closedUrls.Count - 1 - i], reopened.Url);
|
|
}
|
|
|
|
Assert.Equal(40, tabManager.Tabs.Count);
|
|
|
|
// 3. 세션 저장(session.json) 및 완전 복원 무결성 검증
|
|
var sessionPath = Path.Combine(_dataDir, "session.json");
|
|
var snapshot = new SessionSnapshot(
|
|
tabManager.Tabs.Select(t => t.Url).ToList(),
|
|
0,
|
|
tabManager.Tabs.Select(t => t.IsPinned).ToList()
|
|
);
|
|
TabSessionStore.Save(sessionPath, snapshot);
|
|
|
|
var restoredSnap = TabSessionStore.Load(sessionPath);
|
|
Assert.NotNull(restoredSnap);
|
|
Assert.Equal(tabManager.Tabs.Count, restoredSnap.Urls.Count);
|
|
|
|
for (int i = 0; i < tabManager.Tabs.Count; i++)
|
|
{
|
|
Assert.Equal(tabManager.Tabs[i].Url, restoredSnap.Urls[i]);
|
|
Assert.Equal(tabManager.Tabs[i].IsPinned, restoredSnap.Pinned[i]);
|
|
}
|
|
|
|
// 4. TabArchive 500개 누적 및 검색 속도/상한 검증
|
|
var archivePath = Path.Combine(_dataDir, "archive.json");
|
|
var archiveStore = new TabArchive(archivePath);
|
|
for (int i = 1; i <= 600; i++)
|
|
{
|
|
archiveStore.Add($"Archived Item {i}", $"https://site.org/item/{i}");
|
|
}
|
|
|
|
var searchHits = archiveStore.Search("Item 250");
|
|
Assert.NotEmpty(searchHits);
|
|
Assert.Contains(searchHits, x => x.Title.Contains("Item 250"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 시나리오 5: 테마 핫 스위칭, ExtensionManagerWindow UI 상태 및 마켓플레이스 연동 E2E (STA)
|
|
/// </summary>
|
|
[Fact]
|
|
public void Scenario5_DesktopApp_ThemeSwitching_And_ExtensionUi_E2E()
|
|
{
|
|
using var sta = StaDispatcher.Start();
|
|
WpfTestApp.Ensure(sta.Dispatcher);
|
|
|
|
sta.Invoke(() =>
|
|
{
|
|
// 1. 다크 테마 ↔ 라이트 테마 10회 연속 핫 스위칭
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
Paca.App.App.ApplyTheme(i % 2 == 0 ? "dark" : "light");
|
|
Assert.NotNull(Application.Current.Resources["CardBg"]);
|
|
Assert.NotNull(Application.Current.Resources["TextPrimary"]);
|
|
Assert.NotNull(Application.Current.Resources["AccentBrush"]);
|
|
}
|
|
|
|
// 2. ExtensionManagerWindow 생성 및 데이터 바인딩 무결성 검증
|
|
var host = new TestExtensionHost();
|
|
var manager = new ExtensionManager(Path.Combine(_dataDir, "ui_exts"), host);
|
|
|
|
var window = new ExtensionManagerWindow(manager);
|
|
Assert.NotNull(window);
|
|
Assert.NotNull(window.ExtensionList);
|
|
Assert.NotNull(window.StoreList);
|
|
|
|
// 초기 상태: 설치된 확장 0개, EmptyState 표시
|
|
Assert.Equal(Visibility.Visible, window.EmptyState.Visibility);
|
|
Assert.Contains("설치된 확장 (0)", window.TabInstalledText.Text);
|
|
|
|
// 개발자 모드 토글
|
|
window.DevModeCheck.IsChecked = true;
|
|
window.DevModeCheck.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.Primitives.ToggleButton.ClickEvent));
|
|
Assert.Equal(Visibility.Visible, window.DevModeBar.Visibility);
|
|
|
|
// 스토어 탭 전환
|
|
window.TabStoreRadio.IsChecked = true;
|
|
window.TabStoreRadio.RaiseEvent(new RoutedEventArgs(System.Windows.Controls.RadioButton.CheckedEvent));
|
|
Assert.Equal(Visibility.Visible, window.StoreView.Visibility);
|
|
Assert.Equal(Visibility.Collapsed, window.InstalledView.Visibility);
|
|
|
|
// 스토어 검색창 입력
|
|
window.StoreSearchBox.Text = "uBlock";
|
|
var storeItems = window.StoreList.ItemsSource as IEnumerable<MarketplaceItem>;
|
|
Assert.NotNull(storeItems);
|
|
Assert.Contains(storeItems, x => x.Name.Contains("uBlock Origin"));
|
|
});
|
|
}
|
|
|
|
#region Helper Classes and Methods
|
|
|
|
private static int GetFreePort()
|
|
{
|
|
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
|
l.Start();
|
|
var port = ((IPEndPoint)l.LocalEndpoint).Port;
|
|
l.Stop();
|
|
return port;
|
|
}
|
|
|
|
private static byte[] CreateSampleCrx3Bytes(string name, string version, string description, params (string Path, byte[] Content)[] files)
|
|
{
|
|
using var zipMs = new MemoryStream();
|
|
using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true))
|
|
{
|
|
foreach (var (path, content) in files)
|
|
{
|
|
var entry = archive.CreateEntry(path);
|
|
using var s = entry.Open();
|
|
s.Write(content, 0, content.Length);
|
|
}
|
|
}
|
|
var zipBytes = zipMs.ToArray();
|
|
|
|
using var ms = new MemoryStream();
|
|
using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
|
|
bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 }); // "Cr24"
|
|
bw.Write((uint)3); // CRX3
|
|
bw.Write((uint)16); // Header length
|
|
bw.Write(new byte[16]); // Header
|
|
bw.Write(zipBytes);
|
|
bw.Flush();
|
|
return ms.ToArray();
|
|
}
|
|
|
|
private static byte[] CreateManifestJson(string name, string version, string description)
|
|
{
|
|
var dict = new Dictionary<string, object>
|
|
{
|
|
["manifest_version"] = 3,
|
|
["name"] = name,
|
|
["version"] = version,
|
|
["description"] = description
|
|
};
|
|
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(dict));
|
|
}
|
|
|
|
private static byte[] CreateMaliciousZipSlipBytes()
|
|
{
|
|
using var zipMs = new MemoryStream();
|
|
using (var archive = new ZipArchive(zipMs, ZipArchiveMode.Create, leaveOpen: true))
|
|
{
|
|
var attackEntry = archive.CreateEntry("../../../../../etc/passwd");
|
|
using var s = attackEntry.Open();
|
|
s.Write(Encoding.UTF8.GetBytes("attack payload"));
|
|
}
|
|
var zipBytes = zipMs.ToArray();
|
|
|
|
using var ms = new MemoryStream();
|
|
using var bw = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true);
|
|
bw.Write(new byte[] { 0x43, 0x72, 0x32, 0x34 });
|
|
bw.Write((uint)3);
|
|
bw.Write((uint)8);
|
|
bw.Write(new byte[8]);
|
|
bw.Write(zipBytes);
|
|
bw.Flush();
|
|
return ms.ToArray();
|
|
}
|
|
|
|
private sealed class TestExtensionHost : IBrowserExtensionHost
|
|
{
|
|
public Task<string?> AddExtensionAsync(string folderPath, string extensionId) => Task.FromResult<string?>(extensionId);
|
|
public Task<bool> RemoveExtensionAsync(string extensionId) => Task.FromResult(true);
|
|
public Task<bool> SetExtensionEnabledAsync(string extensionId, bool enabled) => Task.FromResult(true);
|
|
}
|
|
|
|
private sealed class TestScenarioQueueAdapter : IQueueAdapter
|
|
{
|
|
private readonly List<QueueItemDto> _items = new();
|
|
public int TotalOperations { get; private set; }
|
|
|
|
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(_items.ToList());
|
|
|
|
public Task<string> AddAsync(string url, string? title)
|
|
{
|
|
lock (_items)
|
|
{
|
|
var id = Guid.NewGuid().ToString("N");
|
|
_items.Add(new QueueItemDto(id, title ?? url, url, "다운로드중", 50));
|
|
TotalOperations++;
|
|
return Task.FromResult(id);
|
|
}
|
|
}
|
|
|
|
public Task PauseAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; }
|
|
public Task ResumeAsync(string id) { lock (_items) { TotalOperations++; } return Task.CompletedTask; }
|
|
public Task CancelAsync(string id) { lock (_items) { _items.RemoveAll(x => x.Id == id); TotalOperations++; } return Task.CompletedTask; }
|
|
|
|
public event Action? Changed { add { } remove { } }
|
|
}
|
|
|
|
private sealed class DirectPassthroughProcessRunner : Paca.Core.Platform.IProcessRunner
|
|
{
|
|
public Task<Paca.Core.Platform.ProcessResult> RunAsync(
|
|
Paca.Core.Platform.ProcessSpec spec,
|
|
Action<string, string>? onLine = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
return Task.FromResult(new Paca.Core.Platform.ProcessResult(0, "OK", ""));
|
|
}
|
|
}
|
|
|
|
private sealed class FakeMediaMuxer : Paca.Core.Platform.IMediaMuxer
|
|
{
|
|
public Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
|
{
|
|
File.Copy(input, output, true);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task DownloadDashAsync(
|
|
string ffmpeg, Uri mpdUrl, string output, double durationSec,
|
|
HttpClient http, IProgress<Paca.Core.Models.DownloadProgress>? progress, CancellationToken ct)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public string? ResolveFfmpeg(Paca.Core.Config.AppConfig config) => "fake_ffmpeg.exe";
|
|
}
|
|
|
|
private sealed class SimpleTestHlsServer : IDisposable
|
|
{
|
|
private readonly HttpListener _listener;
|
|
private readonly int _segmentCount;
|
|
private readonly HashSet<int> _failedOnce = new();
|
|
private bool _disposed;
|
|
|
|
public bool InjectFaults { get; set; }
|
|
|
|
public SimpleTestHlsServer(int port, int segmentCount)
|
|
{
|
|
_segmentCount = segmentCount;
|
|
_listener = new HttpListener();
|
|
_listener.Prefixes.Add($"http://127.0.0.1:{port}/");
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
_listener.Start();
|
|
Task.Run(ListenLoop);
|
|
}
|
|
|
|
private async Task ListenLoop()
|
|
{
|
|
while (!_disposed && _listener.IsListening)
|
|
{
|
|
try
|
|
{
|
|
var ctx = await _listener.GetContextAsync();
|
|
ProcessRequest(ctx);
|
|
}
|
|
catch { break; }
|
|
}
|
|
}
|
|
|
|
private void ProcessRequest(HttpListenerContext ctx)
|
|
{
|
|
try
|
|
{
|
|
var path = ctx.Request.Url?.AbsolutePath ?? "";
|
|
if (path == "/playlist.m3u8")
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("#EXTM3U");
|
|
sb.AppendLine("#EXT-X-VERSION:3");
|
|
sb.AppendLine("#EXT-X-TARGETDURATION:2");
|
|
for (int i = 0; i < _segmentCount; i++)
|
|
{
|
|
sb.AppendLine("#EXTINF:2.0,");
|
|
sb.AppendLine($"segment_{i}.ts");
|
|
}
|
|
sb.AppendLine("#EXT-X-ENDLIST");
|
|
|
|
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
|
ctx.Response.ContentType = "application/vnd.apple.mpegurl";
|
|
ctx.Response.ContentLength64 = bytes.Length;
|
|
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
|
|
ctx.Response.Close();
|
|
return;
|
|
}
|
|
|
|
if (path.StartsWith("/segment_") && path.EndsWith(".ts"))
|
|
{
|
|
var segNumStr = path.Replace("/segment_", "").Replace(".ts", "");
|
|
if (int.TryParse(segNumStr, out var segNum))
|
|
{
|
|
if (InjectFaults && segNum % 4 == 0 && _failedOnce.Add(segNum))
|
|
{
|
|
// 결함 주입: 500 에러 반환 후 다음 재시도에서 정상 제공
|
|
ctx.Response.StatusCode = 500;
|
|
ctx.Response.Close();
|
|
return;
|
|
}
|
|
|
|
// 정상 세그먼트 데이터 반환 (1KB)
|
|
var dummyBytes = new byte[1024];
|
|
Array.Fill(dummyBytes, (byte)(segNum & 0xFF));
|
|
ctx.Response.StatusCode = 200;
|
|
ctx.Response.ContentType = "video/mp2t";
|
|
ctx.Response.ContentLength64 = dummyBytes.Length;
|
|
ctx.Response.OutputStream.Write(dummyBytes, 0, dummyBytes.Length);
|
|
ctx.Response.Close();
|
|
return;
|
|
}
|
|
}
|
|
|
|
ctx.Response.StatusCode = 404;
|
|
ctx.Response.Close();
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_disposed = true;
|
|
try { _listener.Stop(); _listener.Close(); } catch { }
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|