using System.IO; using System.Text; using System.Windows; using System.Windows.Controls; using Microsoft.Web.WebView2.Core; using Paca.App; using Paca.App.Browser.Features; using Paca.App.ViewModels; using Paca.Browser; using Paca.Tests.Infrastructure; using Xunit; namespace Paca.Tests.E2E; /// /// UI 기능 상호작용 & 웹뷰 컨트롤 E2E 파이프라인 (레이아웃, 퍼미션 트래커, 리더 모드, 설정창 라이브 갱신, 검색창). /// [Collection("E2E")] public class LayoutAndInteractionE2ETests { [Fact] public void TabItem_IsPrivate_PropertyChanged_알림_발생() { var tab = new TabItem(); string? changedProp = null; tab.PropertyChanged += (s, e) => changedProp = e.PropertyName; tab.IsPrivate = true; Assert.True(tab.IsPrivate); Assert.Equal(nameof(TabItem.IsPrivate), changedProp); } [Fact] public void NavProgressTracker_로딩중_카운트_증감_및_리셋() { var tracker = new NavProgressTracker(); Assert.False(tracker.IsNavigating); tracker.Started(); Assert.True(tracker.IsNavigating); tracker.Started(); Assert.True(tracker.IsNavigating); tracker.Completed(); Assert.True(tracker.IsNavigating); tracker.Completed(); Assert.False(tracker.IsNavigating); // 초과 완료 호출에도 안전 tracker.Completed(); Assert.False(tracker.IsNavigating); } [Fact] public void CrashReportDialog_생성_시_오류_요약_및_상세_표시() { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); sta.Invoke(() => { var testEx = new InvalidOperationException("테스트용 치명적 오류 메시지", new ArgumentException("내부 예외")); var dlg = new CrashReportDialog(testEx); Assert.NotNull(dlg.DetailBox); Assert.Contains("InvalidOperationException", dlg.DetailBox.Text); Assert.Contains("테스트용 치명적 오류 메시지", dlg.DetailBox.Text); Assert.Contains("ArgumentException", dlg.DetailBox.Text); }); } [Fact] public async Task MainWindow_최대화_및_InPrivate_탭_생성_E2E() { var dataDir = Path.Combine(Path.GetTempPath(), $"vd_e2e_layout_{Guid.NewGuid():N}"); Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); try { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); await sta.InvokeAsync(async () => { var window = new MainWindow(); window.Show(); // 1. 최대화 토글 및 복원 검증 Assert.Equal(WindowState.Normal, window.WindowState); window.WindowState = WindowState.Maximized; Assert.Equal(WindowState.Maximized, window.WindowState); window.WindowState = WindowState.Normal; Assert.Equal(WindowState.Normal, window.WindowState); // 2. InPrivate 탭 생성 var privateTab = window.Tabs.NewTab(isPrivate: true); Assert.True(privateTab.IsPrivate, "생성된 탭의 IsPrivate은 true여야 합니다."); // 3. 닫기 시 안전 종료 window.Close(); }); } finally { try { if (Directory.Exists(dataDir)) Directory.Delete(dataDir, true); } catch { } } } } /// /// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증. /// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다. /// [Collection("E2E")] public class PermissionTrackerE2ETests { [Fact] public async Task 권한_요청이_정책을_거쳐_허용된다_T3_11() { using var sta = StaDispatcher.Start(); var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}"); var result = await sta.InvokeAsync(async () => { var window = new Window(); var browser = new Microsoft.Web.WebView2.Wpf.WebView2(); window.Content = browser; window.Show(); var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf); await browser.EnsureCoreWebView2Async(env); var cv = browser.CoreWebView2; // TabViewService 배선과 동일: 기억된 판정은 즉시 응답(출처는 이벤트에서 관측된 것으로 기억) var gate = new PermissionGate(); cv.PermissionRequested += (_, e) => { var o = Paca.App.Services.TabViewService.OriginOf(e.Uri); gate.Remember("Notifications", o, PermissionVerdict.Allow); // E2E: 알림은 허용 정책 var verdict = gate.Decide(e.PermissionKind.ToString(), o); e.State = verdict == PermissionVerdict.Allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny; e.Handled = true; }; var html = ""; cv.NavigateToString(html); await WaitUntilNavCompleted(cv); var title = await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""), t => t.StartsWith("PERM=", StringComparison.Ordinal), 10000); window.Close(); return title; }); try { Directory.Delete(udf, true); } catch { } Assert.Equal("PERM=granted", result); } [Fact] public async Task 블랙리스트_스크립트가_차단되고_카운터에_집계된다_T3_14() { using var sta = StaDispatcher.Start(); var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}"); var result = await sta.InvokeAsync(async () => { var window = new Window(); var browser = new Microsoft.Web.WebView2.Wpf.WebView2(); window.Content = browser; window.Show(); var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf); await browser.EnsureCoreWebView2Async(env); var cv = browser.CoreWebView2; var bl = new TrackerBlocklist(new[] { "blocked.test" }); var counter = new TrackerCounter(); cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All); cv.WebResourceRequested += (_, e) => { if (!bl.Matches(new Uri(e.Request.Uri))) return; e.Response = env.CreateWebResourceResponse(null, 204, "Blocked", ""); counter.Record(e.Request.Uri); }; var html = "

tracker-e2e

" + "" + "" + "" + ""; cv.NavigateToString(html); await WaitUntilNavCompleted(cv); await Wait.Until(() => Task.FromResult(counter.Total), t => t >= 2, 10000); window.Close(); return counter.Total; }); try { Directory.Delete(udf, true); } catch { } Assert.Equal(2, result); // 정상 도메인(ok.example.com)은 집계되지 않음 } private static async Task WaitUntilNavCompleted(CoreWebView2 cv) { var tcs = new TaskCompletionSource(); EventHandler h = (_, _) => tcs.TrySetResult(); cv.NavigationCompleted += h; if (tcs.Task.IsCompleted) return; await Task.WhenAny(tcs.Task, Task.Delay(15000)); cv.NavigationCompleted -= h; } } /// /// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로 /// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증. /// [Collection("E2E")] public class ReaderModeE2ETests { private const string ArticleHtml = """ 테스트 기사

리더 모드 E2E 기사

첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.

두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.

세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.

"""; [Fact] public async Task 기사를_리더로_추출하고_원본으로_복귀한다() { using var sta = StaDispatcher.Start(); var udf = Path.Combine(Path.GetTempPath(), $"vd_reader_{Guid.NewGuid():N}"); var ok = await sta.InvokeAsync(async () => { var window = new Window(); var browser = new Microsoft.Web.WebView2.Wpf.WebView2(); window.Content = browser; window.Show(); var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf); await browser.EnsureCoreWebView2Async(env); var cv = browser.CoreWebView2; cv.NavigateToString(ArticleHtml); await WaitNav(cv); var tab = new TabItem { View = browser }; // 1) 리더 진입 var entered = await ReaderMode.ToggleAsync(tab, lightTheme: false); await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""), t => t.Contains("테스트 기사"), 15000); await Wait.Until(async () => await cv.ExecuteScriptAsync("document.body.innerText"), b => b.Contains("마커텍스트-고유문구-레인보우4747"), 15000); var titleAfter = cv.DocumentTitle ?? ""; var bodyText = await cv.ExecuteScriptAsync("document.body.innerText"); var hasMarker = bodyText.Contains("마커텍스트-고유문구-레인보우4747"); var hasNav = bodyText.Contains("광고 광고 광고"); var isReader = tab.IsReader; var originalSaved = tab.ReaderOriginalUrl; // 2) 복귀 var exited = await ReaderMode.ToggleAsync(tab, lightTheme: false); var restoredReader = tab.IsReader == false; window.Close(); return (entered: entered == true, titleAfter, hasMarker, navRemoved: !hasNav, isReader, originalSaved, exited: exited == false, restoredReader); }); try { Directory.Delete(udf, true); } catch { } Assert.True(ok.entered, "리더 진입 실패"); Assert.Contains("테스트 기사", ok.titleAfter); Assert.True(ok.hasMarker, "본문 마커 누락"); Assert.True(ok.navRemoved, "내비/광고 제거 안됨"); Assert.True(ok.isReader); Assert.True(ok.exited); Assert.True(ok.restoredReader); } private static Task WaitNav(CoreWebView2 cv) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); EventHandler? h = null; h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); }; cv.NavigationCompleted += h; return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); } } /// /// E2E: 설정창 "앱 업데이트" 섹션(DEPLOYMENT_PLAN §6 자동 업데이트 v1) — /// 실제 SettingsWindow 를 띄워 설정값 로드 배선과 잘못된 저장소 형식의 즉시 안내(네트워크 불필요 경로)를 검증. /// [Collection("E2E")] public class SettingsUpdateUiE2ETests { [Fact] public void 설정창_업데이트_저장소_로드와_형식오류_안내() { // VD_DATA_DIR 격리: SettingsWindow 가 참조하는 프로필 경로를 실사용자와 분리 var dataDir = Path.Combine(Path.GetTempPath(), $"vd_set_{Guid.NewGuid():N}"); Environment.SetEnvironmentVariable("VD_DATA_DIR", dataDir); try { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); sta.Invoke(() => { var config = new AppConfig { UpdateRepo = "user/videodownloader" }; var win = new SettingsWindow(config); // 설정 → UI 로드 배선 Assert.Equal("user/videodownloader", win.UpdateRepoBox.Text); // owner/repo 형식이 아니면 네트워크 호출 없이 즉시 안내 win.UpdateRepoBox.Text = "invalid-format"; win.CheckUpdate_Click(null, new RoutedEventArgs()); Assert.Contains("owner/repo", win.UpdateResultText.Text); win.Close(); }); } finally { Environment.SetEnvironmentVariable("VD_DATA_DIR", null); try { Directory.Delete(dataDir, recursive: true); } catch (DirectoryNotFoundException) { } } } } /// /// E2E: 실제 WebView2 + 네이티브 Find API(CoreWebView2.Find) 검증(T1-3). /// WebView2 런타임 위에서 매치 카운트/활성 인덱스가 실제로 발생하는지 확인한다. /// [Collection("E2E")] public class WebView2FindE2ETests { [Fact] public async Task 네이티브_Find로_매치_수와_활성_인덱스를_얻는다() { using var sta = StaDispatcher.Start(); var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}"); var result = await sta.InvokeAsync(async () => { var window = new Window(); var browser = new Microsoft.Web.WebView2.Wpf.WebView2(); window.Content = browser; window.Show(); var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf); await browser.EnsureCoreWebView2Async(env); var cv = browser.CoreWebView2; cv.Settings.AreDefaultScriptDialogsEnabled = false; var html = "

hello world hello brave hello

"; cv.NavigateToString(html); await WaitUntilNavCompleted(cv); // 네이티브 Find API — SuppressDefaultFindDialog 로 자체 UI 전제 검증 int matchCount = -1, activeIndex = -1; cv.Find.MatchCountChanged += (_, _) => matchCount = cv.Find.MatchCount; cv.Find.ActiveMatchIndexChanged += (_, _) => activeIndex = cv.Find.ActiveMatchIndex; var options = env.CreateFindOptions(); options.FindTerm = "hello"; options.ShouldHighlightAllMatches = true; options.SuppressDefaultFindDialog = true; await cv.Find.StartAsync(options); await Wait.Until(() => Task.FromResult(matchCount), c => c > 0, 10000); var countAfterStart = matchCount; cv.Find.FindNext(); await Wait.Until(() => Task.FromResult(activeIndex), i => i >= 1, 10000); cv.Find.Stop(); window.Close(); return (countAfterStart, activeIndex); }); try { Directory.Delete(udf, true); } catch { } Assert.Equal(3, result.countAfterStart); Assert.True(result.activeIndex >= 1, $"활성 인덱스가 진행되지 않음: {result.activeIndex}"); } private static Task WaitUntilNavCompleted(CoreWebView2 cv) { var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); EventHandler? h = null; h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); }; cv.NavigationCompleted += h; return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15)); } } /// /// 실제 사용자 상호작용(인풋창 타이핑, 버튼 클릭, 상태 전이) 복합 유즈케이스 시나리오 E2E 테스트. /// TDD RED 체계 진화: 단편적 검증을 실질적 사용자 스토리로 통폐합. /// [Collection("E2E")] public class UserJourneyInteractiveE2ETests { [Fact] public async Task Journey01_AddressBox_InputAndSearch_Interaction_E2E() { var tempDir = Path.Combine(Path.GetTempPath(), $"vd_journey01_{Guid.NewGuid():N}"); Environment.SetEnvironmentVariable("VD_DATA_DIR", tempDir); try { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); await sta.InvokeAsync(() => { var window = new MainWindow(); window.Show(); // 1. 주소창 인풋에 텍스트 기록 (URL 타이핑 시뮬레이션) Assert.NotNull(window.AddressBox); window.AddressBox.Text = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; Assert.Equal("https://www.youtube.com/watch?v=dQw4w9WgXcQ", window.AddressBox.Text); // 2. 텍스트 변경에 따른 도메인 또는 북마크 별표 버튼 갱신 확인 Assert.NotNull(window.BookmarkStarBtn); // 3. 인풋창 클리어 시뮬레이션 window.AddressBox.Text = string.Empty; Assert.Empty(window.AddressBox.Text); window.Close(); return Task.CompletedTask; }); } finally { try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } } } [Fact] public async Task Journey02_SidePanel_TabsSwitching_And_FilterInput_E2E() { var tempDir = Path.Combine(Path.GetTempPath(), $"vd_journey02_{Guid.NewGuid():N}"); Environment.SetEnvironmentVariable("VD_DATA_DIR", tempDir); try { using var sta = StaDispatcher.Start(); WpfTestApp.Ensure(sta.Dispatcher); await sta.InvokeAsync(() => { var window = new MainWindow(); window.Show(); // 1. 사이드탭 3종(다운로드, 방문기록, 북마크) 존재 확인 Assert.NotNull(window.SideTabDownloads); Assert.NotNull(window.SideTabHistory); Assert.NotNull(window.SideTabBookmarks); // 2. 방문 기록 탭 클릭(IsChecked = true) 시 방문기록 뷰 활성화 검증 window.SideTabHistory.IsChecked = true; Assert.True(window.SideTabHistory.IsChecked); Assert.NotNull(window.HistoryPanelView); Assert.Equal(Visibility.Visible, window.HistoryPanelView.Visibility); // 방문기록 검색창에 필터 텍스트 입력 Assert.NotNull(window.HistorySearchBox); window.HistorySearchBox.Text = "Github"; Assert.Equal("Github", window.HistorySearchBox.Text); // 3. 북마크 탭 클릭 시 북마크 뷰 활성화 검증 window.SideTabBookmarks.IsChecked = true; Assert.True(window.SideTabBookmarks.IsChecked); Assert.NotNull(window.BookmarksPanelView); Assert.Equal(Visibility.Visible, window.BookmarksPanelView.Visibility); // 북마크 검색창에 필터 텍스트 입력 Assert.NotNull(window.BookmarkSearchBox); window.BookmarkSearchBox.Text = "React"; Assert.Equal("React", window.BookmarkSearchBox.Text); // 4. 다운로드 탭 복귀 window.SideTabDownloads.IsChecked = true; Assert.NotNull(window.DownloadsPanelView); Assert.Equal(Visibility.Visible, window.DownloadsPanelView.Visibility); window.Close(); return Task.CompletedTask; }); } finally { try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } } } }