releases/tests/Paca.Tests/E2E/UiFeatureInteractionsPipelineTests.cs

511 lines
20 KiB
C#

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;
/// <summary>
/// UI 기능 상호작용 & 웹뷰 컨트롤 E2E 파이프라인 (레이아웃, 퍼미션 트래커, 리더 모드, 설정창 라이브 갱신, 검색창).
/// </summary>
[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 { }
}
}
}
/// <summary>
/// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증.
/// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다.
/// </summary>
[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 = "<html><body><script>Notification.requestPermission().then(p => { document.title = 'PERM=' + p; });</script></body></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 = "<html><body><p>tracker-e2e</p>" +
"<script src=\"https://www.blocked.test/a.js\"></script>" +
"<script src=\"https://www.blocked.test/b.js\"></script>" +
"<script src=\"https://ok.example.com/c.js\"></script>" +
"</body></html>";
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<CoreWebView2NavigationCompletedEventArgs> h = (_, _) => tcs.TrySetResult();
cv.NavigationCompleted += h;
if (tcs.Task.IsCompleted) return;
await Task.WhenAny(tcs.Task, Task.Delay(15000));
cv.NavigationCompleted -= h;
}
}
/// <summary>
/// E2E (T2-4): 실제 WebView2 + 번들된 Mozilla Readability.js 로
/// 기사 페이지에서 본문을 추출해 리더 뷰로 교체하고 원본 URL 복귀까지 검증.
/// </summary>
[Collection("E2E")]
public class ReaderModeE2ETests
{
private const string ArticleHtml = """
<html><head><title>테스트 기사</title></head><body>
<nav>광고 광고 광고 내비게이션</nav>
<article>
<h1>리더 모드 E2E 기사</h1>
<p>첫 번째 문단입니다. 이 문단은 Readability 가 본문으로 추출해야 합니다. 충분한 길이의 텍스트가 필요하므로 몇 번 더 반복합니다. 본문 본문 본문.</p>
<p>두 번째 문단입니다. 리더 모드는 광고와 내비게이션을 제거하고 본문만 남깁니다. 추출 결과에 이 텍스트가 포함되어야 합니다.</p>
<p>세 번째 문단입니다. 문단이 세 개 이상이면 Readability 의 휴리스틱이 본문으로 판정합니다. 마커텍스트-고유문구-레인보우4747.</p>
</article>
<footer>푸터 링크 모음</footer>
</body></html>
""";
[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<CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
cv.NavigationCompleted += h;
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
}
}
/// <summary>
/// E2E: 설정창 "앱 업데이트" 섹션(DEPLOYMENT_PLAN §6 자동 업데이트 v1) —
/// 실제 SettingsWindow 를 띄워 설정값 로드 배선과 잘못된 저장소 형식의 즉시 안내(네트워크 불필요 경로)를 검증.
/// </summary>
[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) { }
}
}
}
/// <summary>
/// E2E: 실제 WebView2 + 네이티브 Find API(CoreWebView2.Find) 검증(T1-3).
/// WebView2 런타임 위에서 매치 카운트/활성 인덱스가 실제로 발생하는지 확인한다.
/// </summary>
[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 = "<html><body><p>hello world hello brave hello</p></body></html>";
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<CoreWebView2NavigationCompletedEventArgs>? h = null;
h = (_, _) => { cv.NavigationCompleted -= h; tcs.TrySetResult(); };
cv.NavigationCompleted += h;
return tcs.Task.WaitAsync(TimeSpan.FromSeconds(15));
}
}
/// <summary>
/// 실제 사용자 상호작용(인풋창 타이핑, 버튼 클릭, 상태 전이) 복합 유즈케이스 시나리오 E2E 테스트.
/// TDD RED 체계 진화: 단편적 검증을 실질적 사용자 스토리로 통폐합.
/// </summary>
[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 { }
}
}
}