511 lines
20 KiB
C#
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 { }
|
|
}
|
|
}
|
|
}
|
|
|