From f2130a415e317a9ab6d91c5d455f8c5b628f6818 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 25 Jul 2026 14:59:05 +0900 Subject: [PATCH] Isolate Git-based tests from user configuration --- CHANGELOG.md | 8 + .../CiAutomationConfigurationTests.cs | 306 +++++++----------- .../FolderDiffIL4DotNet.Tests.csproj | 2 + .../Helpers/FakeDisassembler.csproj | 2 + .../Helpers/TestGitRepository.cs | 306 ++++++++++++++++++ .../Helpers/TestGitRepositoryTests.cs | 78 +++++ doc/TESTING_GUIDE.md | 4 + 7 files changed, 508 insertions(+), 198 deletions(-) create mode 100644 FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs create mode 100644 FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1895d712..ca91153c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **CLI parsing now returns one structured result and rejects surplus positional arguments** — `CliParser` now separates `oldFolder`, `newFolder`, and optional `reportLabel` while consuming options in one pass. Existing two- and three-positional forms, option placement, automatic labels, and `--creator` behavior are preserved; a fourth positional argument now prints usage and exits with code `2`. `CliOptions` uses named properties with defaults instead of a 35-field positional constructor. Affected: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`. Tests: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`. +#### Fixed + +- **Git-based tests no longer inherit user signing configuration** — Temporary repositories now use a shared helper that isolates global/system Git configuration, disables commit/tag signing and hooks locally, and forces non-interactive execution without modifying user configuration. Regression coverage verifies that commits and annotated tags succeed even when the isolated global configuration enables signing. Affected: `FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`, `FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj`, `doc/TESTING_GUIDE.md`. Tests: `TestGitRepositoryTests`, `CiAutomationConfigurationTests`. + ### [1.21.0] - 2026-07-22 #### Added @@ -1690,6 +1694,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **CLI 解析を単一の構造化結果へ集約し、余分な位置引数を拒否** — `CliParser` はオプションを 1 回の走査で消費しながら、`oldFolder`、`newFolder`、任意の `reportLabel` を分離するようになりました。既存の 2/3 位置引数形式、オプション位置、自動ラベル、`--creator` の動作は維持し、4 個目の位置引数は使い方を表示して終了コード `2` で拒否します。`CliOptions` は 35 フィールドの位置指定コンストラクタではなく、既定値付きの名前付きプロパティを使用します。対象: `Runner/CliParser.cs`, `Runner/CliOptions.cs`, `ProgramRunner.cs`, `Runner/ProgramRunner.Wizard.cs`, `Runner/RunPreflightValidator.cs`, `USER_GUIDE.md`。テスト: `CliOptionsTests`, `ProgramRunnerTests`, `CliOverrideApplierTests`, `SpinnerThemesTests`。 +#### 修正 + +- **Git ベースのテストがユーザーの署名設定を継承しないよう修正** — 一時リポジトリは、グローバル/システム Git 設定を分離し、commit/tag 署名と hook をローカルで無効化して、ユーザー設定を変更せず非対話実行する共通 helper を使うようになりました。分離済みグローバル設定で署名を有効化しても commit と annotated tag が成功することを回帰テストで検証します。対象: `FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs`, `FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs`, `FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj`, `doc/TESTING_GUIDE.md`。テスト: `TestGitRepositoryTests`, `CiAutomationConfigurationTests`。 + ### [1.21.0] - 2026-07-22 #### 追加 diff --git a/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs b/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs index 0823ab42..702992ef 100644 --- a/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs +++ b/FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; +using FolderDiffIL4DotNet.Tests.Helpers; using Xunit; namespace FolderDiffIL4DotNet.Tests.Architecture @@ -238,39 +239,27 @@ public void ReleaseWorkflow_CreatesGitHubReleaseFromVersionTags() public async Task ReleaseWorkflow_PreviousTagResolution_WithCurrentTagOnly_DoesNotFailUnderPipefail() { Skip.IfNot(CanRunCommand("bash", "--version"), "bash is required to validate the release workflow tag-resolution script."); - Skip.IfNot(CanRunCommand("git", "--version"), "git is required to validate the release workflow tag-resolution script."); - - var repoRoot = Path.Combine(Path.GetTempPath(), "fd-release-tag-resolution-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(repoRoot); - - try - { - await RunProcessAsync("git", repoRoot, "init"); - await RunProcessAsync("git", repoRoot, "config", "user.email", "ci@example.invalid"); - await RunProcessAsync("git", repoRoot, "config", "user.name", "CI Test"); - await File.WriteAllTextAsync(Path.Combine(repoRoot, "README.md"), "test"); - await RunProcessAsync("git", repoRoot, "add", "README.md"); - await RunProcessAsync("git", repoRoot, "commit", "-m", "initial"); - await RunProcessAsync("git", repoRoot, "tag", "v1.0.0"); - - const string script = """ - CURRENT_TAG=$(git describe --tags --exact-match HEAD --match 'v*') - PREV_TAG=$(git describe --first-parent --tags --abbrev=0 HEAD^ --match 'v*' 2>/dev/null || true) - if [ -z "$PREV_TAG" ]; then - echo "changed=true" - else - echo "changed=false" - fi - """; - - var result = await RunProcessAsync("bash", repoRoot, "-eo", "pipefail", "-c", script); - Assert.Equal(0, result.ExitCode); - Assert.Contains("changed=true", result.StandardOutput, StringComparison.Ordinal); - } - finally - { - TryDeleteDirectory(repoRoot); - } + Skip.IfNot(TestGitRepository.IsGitAvailable(), "git is required to validate the release workflow tag-resolution script."); + + using var repository = await TestGitRepository.CreateAsync("fd-release-tag-resolution-"); + await File.WriteAllTextAsync(Path.Combine(repository.RepositoryPath, "README.md"), "test"); + await repository.RunGitAsync("add", "README.md"); + await repository.RunGitAsync("commit", "-m", "initial"); + await repository.RunGitAsync("tag", "v1.0.0"); + + const string script = """ + CURRENT_TAG=$(git describe --tags --exact-match HEAD --match 'v*') + PREV_TAG=$(git describe --first-parent --tags --abbrev=0 HEAD^ --match 'v*' 2>/dev/null || true) + if [ -z "$PREV_TAG" ]; then + echo "changed=true" + else + echo "changed=false" + fi + """; + + var result = await repository.RunCommandAsync("bash", "-eo", "pipefail", "-c", script); + Assert.Equal(0, result.ExitCode); + Assert.Contains("changed=true", result.StandardOutput, StringComparison.Ordinal); } /// @@ -284,61 +273,48 @@ public async Task ReleaseWorkflow_PreviousTagResolution_WithCurrentTagOnly_DoesN public async Task ReleaseWorkflow_PreviousTagResolution_WithOlderDispatchedTag_UsesPreviousReachableTag() { Skip.IfNot(CanRunCommand("bash", "--version"), "bash is required to validate the release workflow tag-resolution script."); - Skip.IfNot(CanRunCommand("git", "--version"), "git is required to validate the release workflow tag-resolution script."); - - var repoRoot = Path.Combine(Path.GetTempPath(), "fd-release-prev-tag-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(repoRoot); - - try - { - var coreDir = Path.Combine(repoRoot, "FolderDiffIL4DotNet.Core"); - Directory.CreateDirectory(coreDir); - var markerPath = Path.Combine(coreDir, "marker.txt"); - - await RunProcessAsync("git", repoRoot, "init"); - await RunProcessAsync("git", repoRoot, "config", "user.email", "ci@example.invalid"); - await RunProcessAsync("git", repoRoot, "config", "user.name", "CI Test"); - - await File.WriteAllTextAsync(markerPath, "v1.0.0"); - await RunProcessAsync("git", repoRoot, "add", "."); - await RunProcessAsync("git", repoRoot, "commit", "-m", "v1.0.0"); - await RunProcessAsync("git", repoRoot, "tag", "v1.0.0"); - - await File.WriteAllTextAsync(markerPath, "v1.1.0"); - await RunProcessAsync("git", repoRoot, "commit", "-am", "v1.1.0"); - await RunProcessAsync("git", repoRoot, "tag", "v1.1.0"); - - await File.WriteAllTextAsync(markerPath, "v2.0.0"); - await RunProcessAsync("git", repoRoot, "commit", "-am", "v2.0.0"); - await RunProcessAsync("git", repoRoot, "tag", "v2.0.0"); - - await RunProcessAsync("git", repoRoot, "checkout", "v1.1.0"); - - const string script = """ - CURRENT_TAG=$(git describe --tags --exact-match HEAD --match 'v*') - PREV_TAG=$(git describe --first-parent --tags --abbrev=0 HEAD^ --match 'v*' 2>/dev/null || true) - - echo "prev=$PREV_TAG" - if [ -z "$PREV_TAG" ]; then - echo "changed=true" - elif git diff --quiet "${PREV_TAG}..HEAD" -- FolderDiffIL4DotNet.Core/; then - echo "changed=false" - else - echo "changed=true" - fi - """; - - var result = await RunProcessAsync("bash", repoRoot, "-eo", "pipefail", "-c", script); - Assert.Equal(0, result.ExitCode); - Assert.Contains("prev=v1.0.0", result.StandardOutput, StringComparison.Ordinal); - Assert.DoesNotContain("prev=v1.1.0", result.StandardOutput, StringComparison.Ordinal); - Assert.DoesNotContain("prev=v2.0.0", result.StandardOutput, StringComparison.Ordinal); - Assert.Contains("changed=true", result.StandardOutput, StringComparison.Ordinal); - } - finally - { - TryDeleteDirectory(repoRoot); - } + Skip.IfNot(TestGitRepository.IsGitAvailable(), "git is required to validate the release workflow tag-resolution script."); + + using var repository = await TestGitRepository.CreateAsync("fd-release-prev-tag-"); + var coreDir = Path.Combine(repository.RepositoryPath, "FolderDiffIL4DotNet.Core"); + Directory.CreateDirectory(coreDir); + var markerPath = Path.Combine(coreDir, "marker.txt"); + + await File.WriteAllTextAsync(markerPath, "v1.0.0"); + await repository.RunGitAsync("add", "."); + await repository.RunGitAsync("commit", "-m", "v1.0.0"); + await repository.RunGitAsync("tag", "v1.0.0"); + + await File.WriteAllTextAsync(markerPath, "v1.1.0"); + await repository.RunGitAsync("commit", "-am", "v1.1.0"); + await repository.RunGitAsync("tag", "v1.1.0"); + + await File.WriteAllTextAsync(markerPath, "v2.0.0"); + await repository.RunGitAsync("commit", "-am", "v2.0.0"); + await repository.RunGitAsync("tag", "v2.0.0"); + + await repository.RunGitAsync("checkout", "v1.1.0"); + + const string script = """ + CURRENT_TAG=$(git describe --tags --exact-match HEAD --match 'v*') + PREV_TAG=$(git describe --first-parent --tags --abbrev=0 HEAD^ --match 'v*' 2>/dev/null || true) + + echo "prev=$PREV_TAG" + if [ -z "$PREV_TAG" ]; then + echo "changed=true" + elif git diff --quiet "${PREV_TAG}..HEAD" -- FolderDiffIL4DotNet.Core/; then + echo "changed=false" + else + echo "changed=true" + fi + """; + + var result = await repository.RunCommandAsync("bash", "-eo", "pipefail", "-c", script); + Assert.Equal(0, result.ExitCode); + Assert.Contains("prev=v1.0.0", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("prev=v1.1.0", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("prev=v2.0.0", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("changed=true", result.StandardOutput, StringComparison.Ordinal); } /// @@ -352,63 +328,50 @@ public async Task ReleaseWorkflow_PreviousTagResolution_WithOlderDispatchedTag_U public async Task ReleaseWorkflow_PreviousTagResolution_WithMergedMainlineRelease_UsesPreviousFirstParentTag() { Skip.IfNot(CanRunCommand("bash", "--version"), "bash is required to validate the release workflow tag-resolution script."); - Skip.IfNot(CanRunCommand("git", "--version"), "git is required to validate the release workflow tag-resolution script."); - - var repoRoot = Path.Combine(Path.GetTempPath(), "fd-release-merge-prev-tag-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(repoRoot); - - try - { - var coreDir = Path.Combine(repoRoot, "FolderDiffIL4DotNet.Core"); - Directory.CreateDirectory(coreDir); - var markerPath = Path.Combine(coreDir, "marker.txt"); - - await RunProcessAsync("git", repoRoot, "init", "-b", "main"); - await RunProcessAsync("git", repoRoot, "config", "user.email", "ci@example.invalid"); - await RunProcessAsync("git", repoRoot, "config", "user.name", "CI Test"); - - await File.WriteAllTextAsync(markerPath, "v1.2.0"); - await RunProcessAsync("git", repoRoot, "add", "."); - await RunProcessAsync("git", repoRoot, "commit", "-m", "v1.2.0"); - await RunProcessAsync("git", repoRoot, "tag", "v1.2.0"); - await RunProcessAsync("git", repoRoot, "branch", "maintenance"); - - await File.WriteAllTextAsync(markerPath, "v2.0.0"); - await RunProcessAsync("git", repoRoot, "commit", "-am", "v2.0.0"); - await RunProcessAsync("git", repoRoot, "tag", "v2.0.0"); - - await RunProcessAsync("git", repoRoot, "checkout", "maintenance"); - await RunProcessAsync("git", repoRoot, "merge", "--no-ff", "main", "-m", "merge main"); - - await File.WriteAllTextAsync(markerPath, "v1.2.1"); - await RunProcessAsync("git", repoRoot, "commit", "-am", "v1.2.1"); - await RunProcessAsync("git", repoRoot, "tag", "v1.2.1"); - - const string script = """ - CURRENT_TAG=$(git describe --tags --exact-match HEAD --match 'v*') - PREV_TAG=$(git describe --first-parent --tags --abbrev=0 HEAD^ --match 'v*' 2>/dev/null || true) - echo "current=$CURRENT_TAG" - echo "prev=$PREV_TAG" - if [ -z "$PREV_TAG" ]; then - echo "changed=true" - elif git diff --quiet "${PREV_TAG}..HEAD" -- FolderDiffIL4DotNet.Core/; then - echo "changed=false" - else - echo "changed=true" - fi - """; - - var result = await RunProcessAsync("bash", repoRoot, "-eo", "pipefail", "-c", script); - Assert.Equal(0, result.ExitCode); - Assert.Contains("current=v1.2.1", result.StandardOutput, StringComparison.Ordinal); - Assert.Contains("prev=v1.2.0", result.StandardOutput, StringComparison.Ordinal); - Assert.DoesNotContain("prev=v2.0.0", result.StandardOutput, StringComparison.Ordinal); - Assert.Contains("changed=true", result.StandardOutput, StringComparison.Ordinal); - } - finally - { - TryDeleteDirectory(repoRoot); - } + Skip.IfNot(TestGitRepository.IsGitAvailable(), "git is required to validate the release workflow tag-resolution script."); + + using var repository = await TestGitRepository.CreateAsync("fd-release-merge-prev-tag-"); + var coreDir = Path.Combine(repository.RepositoryPath, "FolderDiffIL4DotNet.Core"); + Directory.CreateDirectory(coreDir); + var markerPath = Path.Combine(coreDir, "marker.txt"); + + await File.WriteAllTextAsync(markerPath, "v1.2.0"); + await repository.RunGitAsync("add", "."); + await repository.RunGitAsync("commit", "-m", "v1.2.0"); + await repository.RunGitAsync("tag", "v1.2.0"); + await repository.RunGitAsync("branch", "maintenance"); + + await File.WriteAllTextAsync(markerPath, "v2.0.0"); + await repository.RunGitAsync("commit", "-am", "v2.0.0"); + await repository.RunGitAsync("tag", "v2.0.0"); + + await repository.RunGitAsync("checkout", "maintenance"); + await repository.RunGitAsync("merge", "--no-ff", "main", "-m", "merge main"); + + await File.WriteAllTextAsync(markerPath, "v1.2.1"); + await repository.RunGitAsync("commit", "-am", "v1.2.1"); + await repository.RunGitAsync("tag", "v1.2.1"); + + const string script = """ + CURRENT_TAG=$(git describe --tags --exact-match HEAD --match 'v*') + PREV_TAG=$(git describe --first-parent --tags --abbrev=0 HEAD^ --match 'v*' 2>/dev/null || true) + echo "current=$CURRENT_TAG" + echo "prev=$PREV_TAG" + if [ -z "$PREV_TAG" ]; then + echo "changed=true" + elif git diff --quiet "${PREV_TAG}..HEAD" -- FolderDiffIL4DotNet.Core/; then + echo "changed=false" + else + echo "changed=true" + fi + """; + + var result = await repository.RunCommandAsync("bash", "-eo", "pipefail", "-c", script); + Assert.Equal(0, result.ExitCode); + Assert.Contains("current=v1.2.1", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("prev=v1.2.0", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("prev=v2.0.0", result.StandardOutput, StringComparison.Ordinal); + Assert.Contains("changed=true", result.StandardOutput, StringComparison.Ordinal); } /// @@ -634,59 +597,6 @@ private static bool CanRunCommand(string fileName, params string[] arguments) } } - private static async Task<(int ExitCode, string StandardOutput, string StandardError)> RunProcessAsync(string fileName, string workingDirectory, params string[] arguments) - { - var startInfo = new ProcessStartInfo - { - FileName = fileName, - WorkingDirectory = workingDirectory, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - foreach (var argument in arguments) - { - startInfo.ArgumentList.Add(argument); - } - - using var process = Process.Start(startInfo); - if (process == null) - { - throw new InvalidOperationException($"Failed to start process '{fileName}'."); - } - - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - await process.WaitForExitAsync(); - - var stdout = await stdoutTask; - var stderr = await stderrTask; - if (process.ExitCode != 0) - { - throw new InvalidOperationException( - $"Process '{fileName}' failed with exit code {process.ExitCode}.{Environment.NewLine}STDOUT:{Environment.NewLine}{stdout}{Environment.NewLine}STDERR:{Environment.NewLine}{stderr}"); - } - - return (process.ExitCode, stdout, stderr); - } - - private static void TryDeleteDirectory(string path) - { - try - { - if (Directory.Exists(path)) - { - Directory.Delete(path, recursive: true); - } - } - catch - { - // ignore cleanup errors / クリーンアップエラーを無視 - } - } - private static string RepositoryRootPath => Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); } diff --git a/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj b/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj index 07282e38..089f9dd1 100644 --- a/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj +++ b/FolderDiffIL4DotNet.Tests/FolderDiffIL4DotNet.Tests.csproj @@ -12,6 +12,8 @@ + + diff --git a/FolderDiffIL4DotNet.Tests/Helpers/FakeDisassembler.csproj b/FolderDiffIL4DotNet.Tests/Helpers/FakeDisassembler.csproj index d3b6411a..1d3be17f 100644 --- a/FolderDiffIL4DotNet.Tests/Helpers/FakeDisassembler.csproj +++ b/FolderDiffIL4DotNet.Tests/Helpers/FakeDisassembler.csproj @@ -12,6 +12,8 @@ + + diff --git a/FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs b/FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs new file mode 100644 index 00000000..457b6dc2 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace FolderDiffIL4DotNet.Tests.Helpers +{ + /// + /// Creates and runs a temporary Git repository with isolated, non-interactive configuration. + /// 分離された非対話設定で一時 Git リポジトリを作成・実行します。 + /// + internal sealed class TestGitRepository : IDisposable + { + private static readonly TimeSpan s_processTimeout = TimeSpan.FromSeconds(30); + + private static readonly string[] s_inheritedGitEnvironmentVariables = + { + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_NAMESPACE", + "GIT_PREFIX", + "GIT_CEILING_DIRECTORIES", + "GIT_DISCOVERY_ACROSS_FILESYSTEM", + "GIT_CONFIG", + "GIT_CONFIG_SYSTEM", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_NOSYSTEM", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_DATE", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_COMMITTER_DATE" + }; + + private readonly string _testRootPath; + private readonly IReadOnlyDictionary _environmentVariables; + private bool _disposed; + + private TestGitRepository( + string testRootPath, + string repositoryPath, + string isolatedGlobalConfigPath) + { + _testRootPath = testRootPath; + RepositoryPath = repositoryPath; + IsolatedGlobalConfigPath = isolatedGlobalConfigPath; + var isolatedHomePath = Path.Combine(testRootPath, "home"); + var isolatedXdgConfigPath = Path.Combine(testRootPath, "xdg-config"); + var isolatedTemplatePath = Path.Combine(testRootPath, "templates"); + _environmentVariables = new Dictionary(StringComparer.Ordinal) + { + ["HOME"] = isolatedHomePath, + ["XDG_CONFIG_HOME"] = isolatedXdgConfigPath, + ["GIT_CONFIG_GLOBAL"] = isolatedGlobalConfigPath, + ["GIT_CONFIG_NOSYSTEM"] = "1", + ["GIT_CONFIG_COUNT"] = "0", + ["GIT_ATTR_NOSYSTEM"] = "1", + ["GIT_TEMPLATE_DIR"] = isolatedTemplatePath, + ["GIT_TERMINAL_PROMPT"] = "0", + ["GCM_INTERACTIVE"] = "Never", + ["GIT_EDITOR"] = "true", + ["GIT_SEQUENCE_EDITOR"] = "true", + ["GIT_MERGE_AUTOEDIT"] = "no", + ["GIT_PAGER"] = "cat" + }; + } + + /// + /// Gets the isolated worktree path. + /// 分離された作業ツリーのパスを取得します。 + /// + internal string RepositoryPath { get; } + + /// + /// Gets the test-only global Git configuration path. + /// テスト専用のグローバル Git 設定パスを取得します。 + /// + internal string IsolatedGlobalConfigPath { get; } + + /// + /// Returns whether Git can be started in the current environment. + /// 現在の環境で Git を起動できるかを返します。 + /// + internal static bool IsGitAvailable() + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "git", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("--version"); + + using var process = Process.Start(startInfo); + if (process == null) + { + return false; + } + + process.WaitForExit(); + return process.ExitCode == 0; + } + catch + { + return false; + } + } + + /// + /// Creates an initialized repository with deterministic local identity and signing disabled. + /// 決定的なローカル ID と署名無効化を設定した初期化済みリポジトリを作成します。 + /// + /// + /// Unique temporary-directory prefix. + /// 一意な一時ディレクトリ接頭辞。 + /// + /// + /// Optional isolated global configuration used by regression tests. + /// 回帰テストで使う任意の分離グローバル設定。 + /// + /// + /// Optional test-only default user excludes. + /// テスト専用の任意のデフォルトユーザー除外設定。 + /// + internal static async Task CreateAsync( + string directoryPrefix, + string simulatedGlobalConfig = "", + string simulatedUserExcludes = "") + { + ArgumentException.ThrowIfNullOrWhiteSpace(directoryPrefix); + + var testRootPath = Path.Combine( + Path.GetTempPath(), + directoryPrefix + Guid.NewGuid().ToString("N")); + var repositoryPath = Path.Combine(testRootPath, "repository"); + var isolatedGlobalConfigPath = Path.Combine(testRootPath, "global.gitconfig"); + var hooksPath = Path.Combine(testRootPath, "hooks"); + var homePath = Path.Combine(testRootPath, "home"); + var xdgConfigPath = Path.Combine(testRootPath, "xdg-config"); + var templatePath = Path.Combine(testRootPath, "templates"); + var excludesPath = Path.Combine(testRootPath, "excludes"); + var attributesPath = Path.Combine(testRootPath, "attributes"); + + Directory.CreateDirectory(repositoryPath); + Directory.CreateDirectory(hooksPath); + Directory.CreateDirectory(homePath); + Directory.CreateDirectory(Path.Combine(xdgConfigPath, "git")); + Directory.CreateDirectory(templatePath); + await File.WriteAllTextAsync(isolatedGlobalConfigPath, simulatedGlobalConfig); + await File.WriteAllTextAsync( + Path.Combine(xdgConfigPath, "git", "ignore"), + simulatedUserExcludes); + await File.WriteAllTextAsync(excludesPath, string.Empty); + await File.WriteAllTextAsync(attributesPath, string.Empty); + + var repository = new TestGitRepository( + testRootPath, + repositoryPath, + isolatedGlobalConfigPath); + + try + { + await repository.RunGitAsync("init", "-b", "main"); + await repository.RunGitAsync("config", "--local", "user.email", "ci@example.invalid"); + await repository.RunGitAsync("config", "--local", "user.name", "CI Test"); + await repository.RunGitAsync("config", "--local", "user.useConfigOnly", "true"); + await repository.RunGitAsync("config", "--local", "commit.gpgSign", "false"); + await repository.RunGitAsync("config", "--local", "tag.gpgSign", "false"); + await repository.RunGitAsync("config", "--local", "core.hooksPath", hooksPath); + await repository.RunGitAsync("config", "--local", "core.excludesFile", excludesPath); + await repository.RunGitAsync("config", "--local", "core.attributesFile", attributesPath); + return repository; + } + catch + { + repository.Dispose(); + throw; + } + } + + /// + /// Runs Git inside the isolated repository. + /// 分離されたリポジトリ内で Git を実行します。 + /// + internal Task<(int ExitCode, string StandardOutput, string StandardError)> RunGitAsync( + params string[] arguments) + => RunCommandAsync("git", arguments); + + /// + /// Runs a command with the repository's isolated Git environment. + /// リポジトリの分離 Git 環境を引き継いでコマンドを実行します。 + /// + internal async Task<(int ExitCode, string StandardOutput, string StandardError)> RunCommandAsync( + string fileName, + params string[] arguments) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var startInfo = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = RepositoryPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + foreach (var variableName in s_inheritedGitEnvironmentVariables) + { + startInfo.Environment.Remove(variableName); + } + + foreach (var environmentVariable in _environmentVariables) + { + startInfo.Environment[environmentVariable.Key] = environmentVariable.Value; + } + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using var process = Process.Start(startInfo); + if (process == null) + { + throw new InvalidOperationException($"Failed to start process '{fileName}'."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + using var timeoutSource = new CancellationTokenSource(s_processTimeout); + + try + { + await process.WaitForExitAsync(timeoutSource.Token); + } + catch (OperationCanceledException) + { + TryKillProcess(process); + throw new TimeoutException( + $"Process '{fileName}' did not exit within {s_processTimeout.TotalSeconds:0} seconds."); + } + + var stdout = await stdoutTask; + var stderr = await stderrTask; + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"Process '{fileName}' failed with exit code {process.ExitCode}.{Environment.NewLine}STDOUT:{Environment.NewLine}{stdout}{Environment.NewLine}STDERR:{Environment.NewLine}{stderr}"); + } + + return (process.ExitCode, stdout, stderr); + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + if (Directory.Exists(_testRootPath)) + { + Directory.Delete(_testRootPath, recursive: true); + } + } + catch + { + // Ignore cleanup errors. + // クリーンアップエラーを無視します。 + } + } + + private static void TryKillProcess(Process process) + { + try + { + process.Kill(entireProcessTree: true); + process.WaitForExit(); + } + catch + { + // Preserve the timeout as the actionable failure. + // 実行可能な失敗として timeout を維持します。 + } + } + } +} diff --git a/FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs b/FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs new file mode 100644 index 00000000..67730045 --- /dev/null +++ b/FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace FolderDiffIL4DotNet.Tests.Helpers +{ + /// + /// Verifies deterministic configuration for temporary Git repositories. + /// 一時 Git リポジトリの決定的な設定を検証します。 + /// + public sealed class TestGitRepositoryTests + { + /// + /// Verifies that simulated global signing and user excludes do not affect local commits or tags. + /// 模擬グローバル署名とユーザー除外設定がローカルの commit と tag に影響しないことを検証します。 + /// + [SkippableFact] + [Trait("Category", "Unit")] + public async Task CreateAsync_WithGlobalSigningAndUserExcludes_IsolatesConfiguration() + { + Skip.IfNot(TestGitRepository.IsGitAvailable(), "git is required to validate test repository isolation."); + + const string simulatedGlobalConfig = """ + [commit] + gpgSign = true + [tag] + gpgSign = true + [gpg] + format = ssh + [user] + signingKey = /definitely/missing/nildiff-test-signing-key + """; + + using var repository = await TestGitRepository.CreateAsync( + "fd-test-git-isolation-", + simulatedGlobalConfig, + "*.md"); + + var globalSigning = await repository.RunGitAsync( + "config", + "--global", + "--get", + "commit.gpgsign"); + Assert.Equal("true", globalSigning.StandardOutput.Trim()); + + var markerPath = Path.Combine(repository.RepositoryPath, "README.md"); + await File.WriteAllTextAsync(markerPath, "test"); + await repository.RunGitAsync("add", "README.md"); + await repository.RunGitAsync("commit", "-m", "initial"); + await repository.RunGitAsync("tag", "-a", "v1.0.0", "-m", "v1.0.0"); + + var localCommitSigning = await repository.RunGitAsync( + "config", + "--local", + "--get", + "commit.gpgsign"); + var localTagSigning = await repository.RunGitAsync( + "config", + "--local", + "--get", + "tag.gpgsign"); + var commitSubject = await repository.RunGitAsync( + "log", + "-1", + "--format=%s"); + var trackedFiles = await repository.RunGitAsync("ls-files"); + + Assert.Equal("false", localCommitSigning.StandardOutput.Trim()); + Assert.Equal("false", localTagSigning.StandardOutput.Trim()); + Assert.Equal("initial", commitSubject.StandardOutput.Trim()); + Assert.Contains("README.md", trackedFiles.StandardOutput, StringComparison.Ordinal); + Assert.Equal( + simulatedGlobalConfig, + await File.ReadAllTextAsync(repository.IsolatedGlobalConfigPath)); + } + } +} diff --git a/doc/TESTING_GUIDE.md b/doc/TESTING_GUIDE.md index f1e7e2c8..0353b15c 100644 --- a/doc/TESTING_GUIDE.md +++ b/doc/TESTING_GUIDE.md @@ -44,6 +44,7 @@ Use the commands in [Run Tests Locally](#testing-en-run-tests) to inspect the cu | Core utility layer | [`FileComparerTests`](../FolderDiffIL4DotNet.Tests/Core/IO/FileComparerTests.cs), [`FileSystemUtilityTests`](../FolderDiffIL4DotNet.Tests/Core/IO/FileSystemUtilityTests.cs), [`PathValidatorTests`](../FolderDiffIL4DotNet.Tests/Core/IO/PathValidatorTests.cs), [`ProcessHelperTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/ProcessHelperTests.cs), [`SystemInfoTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/SystemInfoTests.cs), [`TextSanitizerTests`](../FolderDiffIL4DotNet.Tests/Core/Text/TextSanitizerTests.cs), [`TextDifferTests`](../FolderDiffIL4DotNet.Tests/Core/Text/TextDifferTests.cs), [`EncodingDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/Text/EncodingDetectorTests.cs), [`ConsoleRenderCoordinatorTests`](../FolderDiffIL4DotNet.Tests/Core/Console/ConsoleRenderCoordinatorTests.cs), [`ConsoleBannerTests`](../FolderDiffIL4DotNet.Tests/Core/Console/ConsoleBannerTests.cs) | hashing/text compare, shared report-timestamp formatting, path/network detection (including `//`-prefixed forward-slash UNC and IP-based UNC paths), command tokenization, file-name/path sanitization, computer name and app version retrieval (`SystemInfo`), Myers diff algorithm correctness (identical/empty/added/removed lines, context lines, hunk headers, edit-distance cap, output-line truncation, large-file small-diff efficiency), file encoding auto-detection (UTF-8 with/without BOM, UTF-16 LE/BE, Shift_JIS, ASCII, empty file, round-trip decoding of Japanese text), console render coordinator thread-safety (`RenderSyncRoot`, spinner throttling, `MarkProgressRendered` timing), time-based greeting message correctness (all 24 hours covered, no empty strings, meal-time "Leave the diff to me" slots, coffee-break message consistency), the reusable helper contract now housed in `FolderDiffIL4DotNet.Core`, `TryGetProcessOutputAsync` non-zero exit code null-return and valid command output and null-args safety, `SystemInfo.GetComputerName` and `GetAppVersion` consecutive-call determinism | | HTML report JavaScript | [`diff_report.test.js`](../JsTests/diff_report.test.js), [`diff_report_extended.test.js`](../JsTests/diff_report_extended.test.js) (Jest/jsdom) | `formatTs` date formatting, `collectState` checkbox/text/textarea collection with filter-ID exclusion, `autoSave` localStorage persistence and status display, `applyFilters` diff-detail/importance/unchecked-only/search filtering with associated diff-row hiding (including SHA256Match/ILMatch/TextMatch/TextMismatch diff-detail filters and combined diff+importance filtering), `resetFilters` default state restoration, `decodeDiffHtml` base64-UTF8 decoding (including multibyte), reviewed-state `encodeEmbeddedState` / `decodeEmbeddedState` Base64 round-trip for UTF-8 and script-breaking text, `collapseAll` details-element folding, `clearAll` input reset with confirm guard, DOMContentLoaded state restore from `__savedState__` and localStorage, reviewed-mode read-only enforcement, `verifyIntegrity` null-guard alert, `setupLazyDiff` lazy decode/insert on toggle with no-duplicate-decode guard, `setupLazySection` lazy section decode with save-event wiring, `forceDecodeLazySections` batch decode without toggle, `updateProgress` review-count aggregation (including lazy sections) and corrupted-localStorage tolerance, `esc` HTML escaping (XSS prevention), `readSavedStateFromStorage` edge cases (invalid JSON, fallback, array), `copyPath` clipboard interaction, `toggleDiffView` side-by-side mode (del+add pairing, standalone del/add, hunk headers, unified restore), `buildExcelRow` cell extraction and short-row guard, keyboard navigation (Escape closes details), `highlightILCell` directive/label/type/keyword highlighting with prefix preservation and skip-already-highlighted/empty guards, `highlightILDiff` ILMismatch targeting and TextMismatch skipping, keyboard navigation (`j`/`k` file row focus, `x` checkbox toggle, help overlay auto-show/auto-hide, `Esc` text input blur with `__kbEscHandled__` coordination), `getStoredTheme` localStorage read/error handling, `highlightAllILDiffs` bulk IL highlight with ILMismatch/SHA256Mismatch discrimination, `wrapInputWithClear` container wrapping/clear-button/has-text toggling/double-wrap prevention, `initClearButtons` filter-search integration, `initColResizeSingle` resize handle creation and mousedown/mousemove CSS variable update, `syncScTableWidths` sc-detail/dc-detail width calculation, `syncFilterRowHeight` graceful no-op, filter-zone collapsed-state persistence and reviewed-mode localStorage isolation, `initVirtualScroll` threshold gating (<=100 skip, >100 activate), viewport wrapping, row-count indicator, partial rendering, `vsRender` idempotent re-render and null-guard, `vsRefreshVisibility` importance-based row filtering with indicator update, `vsMaterializeAll` full DOM restoration and viewport unwrap, `buildExcelFramework` header/legend/section/summary/warning assembly, `downloadExcelCompatibleHtml` small-report immediate path, `downloadExcelImmediate` blob download with filename, `downloadAsPdf` print-header/footer injection and afterprint cleanup, `downloadReviewed` reviewed HTML generation with SHA256 integrity embedding and safe state export, `setupLazyIntersectionObserver` observer creation/missing-API tolerance/intersection callback, keyboard IME fallback (`Process` key with `KeyJ`/`KeyK`/`KeyX` code mapping), Escape keyboard focus clear, `toggleAllInSection` main table header check/uncheck all, `toggleAllInDetailTable` detail table check/uncheck all (non-virtual-scroll and virtual-scroll with `rowData.cbChecked` update), `syncHeaderCheckboxes` indeterminate/checked state (main tables, virtual-scroll detail tables with `rowData`-based counting), `collectState` virtual scroll row inclusion for non-DOM entries | | Architecture boundary | [`CoreSeparationTests`](../FolderDiffIL4DotNet.Tests/Architecture/CoreSeparationTests.cs), [`CiAutomationConfigurationTests`](../FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs), [`MutationSummaryScriptTests`](../FolderDiffIL4DotNet.Tests/Architecture/MutationSummaryScriptTests.cs), [`MutationPrCommentScriptTests`](../FolderDiffIL4DotNet.Tests/Architecture/MutationPrCommentScriptTests.cs) | utility types stay in the `FolderDiffIL4DotNet.Core` assembly, the main assembly no longer defines the legacy `FolderDiffIL4DotNet.Utils` namespace, repository automation keeps coverage gates, release workflow, CodeQL, Dependabot, and benchmark regression workflow configured, mutation-summary automation keeps the extracted `scripts/generate-mutation-summary.py` entry point plus best-effort PR-comment guardrails wired, the summary script reads thresholds from `stryker-config.json`, documentation coverage thresholds match the CI workflow, benchmark regression workflow contains expected structure (PR trigger, benchmark-action, alert threshold, fail-on-alert), the mutation-summary script is exercised against valid, zero-mutant, malformed, and missing-report fixtures, and the PR-comment helper covers bot-only selection plus update/create upsert paths for the sticky summary comment | +| Git test isolation | [`TestGitRepositoryTests`](../FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs) | temporary Git repositories use one shared initialization path, ignore user/system Git configuration, disable commit/tag signing and hooks locally, run non-interactively, preserve the simulated global configuration file, and still create commits and annotated tags when the isolated global configuration enables signing | | Plugin system | [`PluginLoaderTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginLoaderTests.cs), [`FileDiffServiceUnitTests.Hooks`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.Hooks.cs), [`DotNetDisassemblerProviderTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassemblerProviderTests.cs), [`PluginConfigSettingsTests`](../FolderDiffIL4DotNet.Tests/Models/PluginConfigSettingsTests.cs), [`ReportFormatterTests`](../FolderDiffIL4DotNet.Tests/Services/ReportFormatterTests.cs), [`ReportSectionWriterOrderTests`](../FolderDiffIL4DotNet.Tests/Services/ReportSectionWriterOrderTests.cs) | Plugin loading from search paths (non-existent path, empty directory, invalid DLL, duplicate search-path deduplication for the same plugin DLL, constructor null-guard, strict-mode pre-load hash rejection when trusted hashes are missing/empty/mismatched), `IFileComparisonHook` before/after integration (hook override, null passthrough, exception handling, phase-aware failure logs with hook order, multi-hook ordering), `DotNetDisassemblerProvider` CanHandle for .NET vs non-.NET files, DisplayName/extension-aware warning logs on recoverable detection failures, DisassembleAsync delegation and error handling, plugin config settings round-trip (PluginSearchPaths, PluginEnabledIds, PluginConfig JSON serialization, readonly guarantees), `IReportFormatter` Order values and IsEnabled conditions, `IReportSectionWriter` built-in writer count and unique/increasing order invariants | | Runner layer | [`CliOverrideApplierTests`](../FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs), [`DiffPipelineExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DiffPipelineExecutorTests.cs), [`DryRunExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DryRunExecutorTests.cs), [`RunScopeBuilderTests`](../FolderDiffIL4DotNet.Tests/Runner/RunScopeBuilderTests.cs), [`PluginAssemblyLoadContextTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginAssemblyLoadContextTests.cs), [`SpinnerThemesTests`](../FolderDiffIL4DotNet.Tests/Runner/SpinnerThemesTests.cs) | CLI override application (`--threads`, `--no-il-cache`, `--skip-il`, `--no-timestamp-warnings` applied to builder, default options leave builder unchanged), `FormatElapsedTime` formatting (zero, sub-second, multi-hour), `DiffPipelineResult` record equality (including `HasILFilterWarnings` field), constructor null-guards, `RunScopeBuilder.BuildExecutionContext` network detection and path storage, `CreateIlCache` conditional creation, DI container service resolution, `PluginAssemblyLoadContext` collectible context creation and shared assembly fallback, post-process action failures staying best-effort while warning logs retain the action type, execution position, `Order`, and exception type, spinner theme application for all 7 themes plus multiple-spinners-detected matcha fallback and random selection | | Section writer individual | [`HeaderSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/HeaderSectionWriterTests.cs), [`LegendSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/LegendSectionWriterTests.cs), [`ConditionalSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs), [`FileListSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/FileListSectionWriterTests.cs), [`SummarySectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/SummarySectionWriterTests.cs), [`IgnoredFilesSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/IgnoredFilesSectionWriterTests.cs), [`ILCacheStatsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ILCacheStatsSectionWriterTests.cs), [`WarningsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs) | Per-writer Order values, IsEnabled conditions (IgnoredFiles config flag, UnchangedFiles config flag, ILCacheStats config+cache null, Warnings SHA256 mismatch/timestamp regression/IL filter string validation), Write output content assertions (report title, app version, paths, computer name, legend SHA256, section headers for Added/Removed/Modified/Unchanged, summary elapsed time, warning keyword, IL filter warning text), empty/zero-count result list handling, Ignored early return on empty list, ILCacheStats disabled when cache is null | @@ -222,6 +223,7 @@ Workflow/config files: [`.github/workflows/dotnet.yml`](../.github/workflows/dot ## Test Isolation and Environment Notes - Most tests create unique temporary directories under [`Path.GetTempPath()`](https://learn.microsoft.com/en-us/dotnet/api/system.io.path.gettemppath?view=net-8.0) and clean them up in `Dispose`/`finally`. +- Git-based tests must create repositories through [`TestGitRepository`](../FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs). It uses test-only global/system configuration, removes inherited repository and identity environment variables, disables commit/tag signing and hooks locally, and forces non-interactive Git behavior without modifying the user's configuration. - [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs) temporarily writes [`config.json`](../config.json) under [`AppContext.BaseDirectory`](https://learn.microsoft.com/en-us/dotNet/API/system.appcontext.basedirectory?view=net-8.0) and restores original content. - [`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs) temporarily rewires `PATH`/`HOME` and uses scripted fake tools to test fallback/blacklist logic deterministically; any test that pre-seeds a specific disassembler version into the version cache must also prepend a matching fake tool to `PATH` so that `GetVersionWithFallbacksAsync` finds the fake before the real tool installed on the CI runner, which would otherwise overwrite the seeded entry. - [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) builds throwaway class libraries under temp directories and pins the E2E assertion to [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/); CI ensures that prerequisite, adds the global tool directory to `PATH`, and sets both `DOTNET_ROLL_FORWARD=Major` and `FOLDERDIFF_RUN_E2E=true` for the blocking test step. @@ -290,6 +292,7 @@ Workflow/config files: [`.github/workflows/dotnet.yml`](../.github/workflows/dot | Core ユーティリティ層 | [`FileComparerTests`](../FolderDiffIL4DotNet.Tests/Core/IO/FileComparerTests.cs), [`FileSystemUtilityTests`](../FolderDiffIL4DotNet.Tests/Core/IO/FileSystemUtilityTests.cs), [`PathValidatorTests`](../FolderDiffIL4DotNet.Tests/Core/IO/PathValidatorTests.cs), [`ProcessHelperTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/ProcessHelperTests.cs), [`SystemInfoTests`](../FolderDiffIL4DotNet.Tests/Core/Diagnostics/SystemInfoTests.cs), [`TextSanitizerTests`](../FolderDiffIL4DotNet.Tests/Core/Text/TextSanitizerTests.cs), [`TextDifferTests`](../FolderDiffIL4DotNet.Tests/Core/Text/TextDifferTests.cs), [`EncodingDetectorTests`](../FolderDiffIL4DotNet.Tests/Core/Text/EncodingDetectorTests.cs), [`ConsoleRenderCoordinatorTests`](../FolderDiffIL4DotNet.Tests/Core/Console/ConsoleRenderCoordinatorTests.cs), [`ConsoleBannerTests`](../FolderDiffIL4DotNet.Tests/Core/Console/ConsoleBannerTests.cs) | ハッシュ/テキスト比較、共有タイムスタンプ書式、パス/ネットワーク判定(`//` プレフィックスのスラッシュ形式 UNC および IP ベース UNC パスを含む)、コマンド分解、ファイル名/パス整形、コンピュータ名・アプリバージョン取得(`SystemInfo`)、Myers diff アルゴリズムの正確性(同一/空/追加/削除行、コンテキスト行、ハンクヘッダ、編集距離上限、出力行数切り詰め、大ファイル小差分の効率性)、ファイルエンコーディング自動検出(BOM 付き/なし UTF-8、UTF-16 LE/BE、Shift_JIS、ASCII、空ファイル、日本語テキストのラウンドトリップ)、コンソールレンダーコーディネータのスレッド安全性(`RenderSyncRoot`、スピナースロットリング、`MarkProgressRendered` タイミング)、時刻ベース挨拶メッセージの正確性(24時間全カバー、空文字列なし、食事時間帯 "Leave the diff to me" スロット、コーヒーブレイクメッセージの一致)、`FolderDiffIL4DotNet.Core` に移した再利用 helper の契約確認、`TryGetProcessOutputAsync` の非ゼロ終了コードでの null 返却・正常コマンド出力・null 引数安全性、`SystemInfo.GetComputerName` と `GetAppVersion` の連続呼び出し決定論性 | | HTML レポート JavaScript | [`diff_report.test.js`](../JsTests/diff_report.test.js)、[`diff_report_extended.test.js`](../JsTests/diff_report_extended.test.js)(Jest/jsdom) | `formatTs` 日付フォーマット、`collectState` チェックボックス/テキスト/テキストエリア収集とフィルタ ID 除外、`autoSave` localStorage 永続化とステータス表示、`applyFilters` Diff Detail/重要度/未チェックのみ/検索フィルタリングと関連 diff-row の非表示(SHA256Match/ILMatch/TextMatch/TextMismatch の Diff Detail フィルタ、diff+importance 複合フィルタを含む)、`resetFilters` デフォルト状態復元、`decodeDiffHtml` base64-UTF8 デコード(マルチバイト文字含む)、レビュー状態の `encodeEmbeddedState` / `decodeEmbeddedState` による Base64 ラウンドトリップと script-breaking 文字列の安全化、`collapseAll` details 要素の折りたたみ、`clearAll` 入力リセットと confirm ガード、DOMContentLoaded 時の `__savedState__` および localStorage からの状態復元、レビュー済みモードの読み取り専用化、`verifyIntegrity` null ガードアラート、`setupLazyDiff` トグル時の遅延デコード/挿入と重複デコード防止、`setupLazySection` 遅延セクションのデコードと save イベント接続、`forceDecodeLazySections` トグルなしの一括デコード、`updateProgress` のレビュー件数集計(遅延セクションを含む)と破損 localStorage 耐性、`esc` HTML エスケープ(XSS 防止)、`readSavedStateFromStorage` エッジケース(不正 JSON、フォールバック、配列)、`copyPath` クリップボード操作、`toggleDiffView` サイドバイサイドモード(del+add ペアリング、単独 del/add、ハンクヘッダー、統合ビュー復元)、`buildExcelRow` セル抽出と短行ガード、キーボードナビゲーション(Escape で details を閉じる)、`highlightILCell` ディレクティブ/ラベル/型/キーワードのハイライトとプレフィックス保持・ハイライト済み/空セルのスキップ、`highlightILDiff` ILMismatch 対象指定と TextMismatch スキップ、キーボードナビゲーション(`j`/`k` ファイル行フォーカス、`x` チェックボックストグル、ヘルプオーバーレイ自動表示/自動非表示、`Esc` テキスト入力 blur と `__kbEscHandled__` 連携)、`getStoredTheme` localStorage 読み取り/エラーハンドリング、`highlightAllILDiffs` IL ハイライト一括適用と ILMismatch/SHA256Mismatch 識別、`wrapInputWithClear` コンテナラップ/クリアボタン/has-text トグル/二重ラップ防止、`initClearButtons` フィルター検索統合、`initColResizeSingle` リサイズハンドル生成と mousedown/mousemove CSS 変数更新、`syncScTableWidths` sc-detail/dc-detail 幅計算、`syncFilterRowHeight` 要素不在時の安全な no-op、フィルターゾーン折りたたみ状態の永続化と reviewed モードの localStorage 分離、`initVirtualScroll` 閾値判定(100行以下スキップ/超過で有効化)・ビューポートラップ・行数インジケーター・部分レンダリング、`vsRender` 冪等再レンダリングと null ガード、`vsRefreshVisibility` 重要度ベースの行フィルタリングとインジケーター更新、`vsMaterializeAll` 完全 DOM 復元とビューポートアンラップ、`buildExcelFramework` ヘッダー/凡例/セクション/サマリー/警告の組み立て、`downloadExcelCompatibleHtml` 小規模レポートの即時パス、`downloadExcelImmediate` Blob ダウンロードとファイル名、`downloadAsPdf` 印刷ヘッダー/フッター注入と afterprint クリーンアップ、`downloadReviewed` SHA256 整合性埋め込み付きレビュー済み HTML 生成と安全な state export、`setupLazyIntersectionObserver` オブザーバー生成/API 不在時耐性/交差コールバック、キーボード IME フォールバック(`Process` キーと `KeyJ`/`KeyK`/`KeyX` コードマッピング)、Escape キーボードフォーカス解除、`toggleAllInSection` メインテーブルヘッダーの全チェック/全チェック解除、`toggleAllInDetailTable` 詳細テーブルの全チェック/全チェック解除(非仮想スクロールおよび仮想スクロールの `rowData.cbChecked` 更新)、`syncHeaderCheckboxes` の indeterminate/checked 状態(メインテーブル、`rowData` ベースカウントの仮想スクロール詳細テーブル)、`collectState` 仮想スクロール非 DOM エントリの含有 | | アーキテクチャ境界 | [`CoreSeparationTests`](../FolderDiffIL4DotNet.Tests/Architecture/CoreSeparationTests.cs), [`CiAutomationConfigurationTests`](../FolderDiffIL4DotNet.Tests/Architecture/CiAutomationConfigurationTests.cs), [`MutationSummaryScriptTests`](../FolderDiffIL4DotNet.Tests/Architecture/MutationSummaryScriptTests.cs), [`MutationPrCommentScriptTests`](../FolderDiffIL4DotNet.Tests/Architecture/MutationPrCommentScriptTests.cs) | utility 型が `FolderDiffIL4DotNet.Core` アセンブリに残り、実行ファイル側へ旧 `FolderDiffIL4DotNet.Utils` 名前空間が戻らないこと、カバレッジゲート、リリースワークフロー、CodeQL、Dependabot、ベンチマークリグレッションワークフローの設定が維持されること、切り出した `scripts/generate-mutation-summary.py` の entry point と best-effort PR コメント防御が workflow に配線され続けること、summary script が `stryker-config.json` から閾値を読むこと、ドキュメントのカバレッジ閾値が CI ワークフローと一致すること、ベンチマークリグレッションワークフローが期待構造(PR トリガー、benchmark-action、閾値、fail-on-alert)を含むこと、さらに mutation-summary script 自体が正常系・0 mutant・破損レポート・レポート欠落系の fixture で実行検証され、PR コメント helper は bot 所有コメントだけを対象にした update/create の upsert 経路まで検証すること | +| Git テスト分離 | [`TestGitRepositoryTests`](../FolderDiffIL4DotNet.Tests/Helpers/TestGitRepositoryTests.cs) | 一時 Git リポジトリが単一の共通初期化経路を使い、ユーザー/システム Git 設定を読み込まず、commit/tag 署名と hook をローカルで無効化して非対話実行すること、擬似グローバル設定ファイルを変更しないこと、分離済みグローバル設定で署名を有効化しても commit と annotated tag を作成できること | | プラグインシステム | [`PluginLoaderTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginLoaderTests.cs), [`FileDiffServiceUnitTests.Hooks`](../FolderDiffIL4DotNet.Tests/Services/FileDiffServiceUnitTests.Hooks.cs), [`DotNetDisassemblerProviderTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassemblerProviderTests.cs), [`PluginConfigSettingsTests`](../FolderDiffIL4DotNet.Tests/Models/PluginConfigSettingsTests.cs), [`ReportFormatterTests`](../FolderDiffIL4DotNet.Tests/Services/ReportFormatterTests.cs), [`ReportSectionWriterOrderTests`](../FolderDiffIL4DotNet.Tests/Services/ReportSectionWriterOrderTests.cs) | サーチパスからのプラグイン読み込み(存在しないパス、空ディレクトリ、無効な DLL、同一プラグイン DLL の重複サーチパス抑止、コンストラクタ null ガード、trusted hash 未設定/空/不一致時の strict mode 事前ハッシュ拒否)、`IFileComparisonHook` の before/after 統合(フックオーバーライド、null パススルー、例外処理、フェーズ付き失敗ログと hook order、マルチフック順序付け)、`DotNetDisassemblerProvider` の .NET/非 .NET ファイル向け CanHandle、recoverable な判定失敗時の DisplayName/拡張子付き warning、DisassembleAsync 委譲とエラー処理、プラグイン設定のラウンドトリップ(PluginSearchPaths、PluginEnabledIds、PluginConfig JSON シリアライズ、読取専用保証)、`IReportFormatter` の Order 値と IsEnabled 条件、`IReportSectionWriter` 組み込みライター数と一意/昇順 Order 不変条件 | | Runner 層 | [`CliOverrideApplierTests`](../FolderDiffIL4DotNet.Tests/Runner/CliOverrideApplierTests.cs)、[`DiffPipelineExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DiffPipelineExecutorTests.cs)、[`DryRunExecutorTests`](../FolderDiffIL4DotNet.Tests/Runner/DryRunExecutorTests.cs)、[`RunScopeBuilderTests`](../FolderDiffIL4DotNet.Tests/Runner/RunScopeBuilderTests.cs)、[`PluginAssemblyLoadContextTests`](../FolderDiffIL4DotNet.Tests/Runner/PluginAssemblyLoadContextTests.cs)、[`SpinnerThemesTests`](../FolderDiffIL4DotNet.Tests/Runner/SpinnerThemesTests.cs) | CLI オーバーライド適用(`--threads`、`--no-il-cache`、`--skip-il`、`--no-timestamp-warnings` のビルダーへの反映、デフォルトオプションはビルダーを変更しないこと)、`FormatElapsedTime` フォーマット(ゼロ、秒未満、複数時間)、`DiffPipelineResult` レコード等価性(`HasILFilterWarnings` フィールドを含む)、コンストラクタ null ガード、`RunScopeBuilder.BuildExecutionContext` ネットワーク検出とパス保存、`CreateIlCache` 条件付き生成、DI コンテナサービス解決、`PluginAssemblyLoadContext` コレクティブルコンテキスト生成と共有アセンブリフォールバック、ポストプロセスアクション失敗が best-effort のまま継続されつつ Warning ログにアクション型と例外型が残ること、全 7 テーマのスピナーテーマ適用と複数スピナー検出時の抹茶フォールバックおよびランダム選択 | | セクションライター個別 | [`HeaderSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/HeaderSectionWriterTests.cs)、[`LegendSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/LegendSectionWriterTests.cs)、[`ConditionalSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ConditionalSectionWriterTests.cs)、[`FileListSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/FileListSectionWriterTests.cs)、[`SummarySectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/SummarySectionWriterTests.cs)、[`IgnoredFilesSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/IgnoredFilesSectionWriterTests.cs)、[`ILCacheStatsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/ILCacheStatsSectionWriterTests.cs)、[`WarningsSectionWriterTests`](../FolderDiffIL4DotNet.Tests/Services/SectionWriters/WarningsSectionWriterTests.cs) | ライターごとの Order 値、IsEnabled 条件(IgnoredFiles 設定フラグ、UnchangedFiles 設定フラグ、ILCacheStats 設定+キャッシュ null、Warnings SHA256 不一致/タイムスタンプ後退/IL フィルタ文字列検証)、Write 出力内容アサーション(レポートタイトル、アプリバージョン、パス、コンピュータ名、凡例 SHA256、Added/Removed/Modified/Unchanged セクションヘッダ、サマリー経過時間、警告キーワード、IL フィルタ警告テキスト)、空/ゼロカウント結果リスト処理、Ignored 空リスト時の早期リターン、ILCacheStats キャッシュ null 時の無効化 | @@ -467,6 +470,7 @@ dotnet tool run reportgenerator -reports:"TestResults/**/coverage.cobertura.xml" ## テスト分離と実行環境の注意 - 多くのテストは [`Path.GetTempPath()`](https://learn.microsoft.com/ja-jp/dotnet/api/system.io.path.gettemppath?view=net-8.0) 配下に一意ディレクトリを作成し、`Dispose`/`finally` で後始末します。 +- Git を使うテストは [`TestGitRepository`](../FolderDiffIL4DotNet.Tests/Helpers/TestGitRepository.cs) 経由でリポジトリを作成してください。テスト専用のグローバル/システム設定を使い、継承されたリポジトリ・識別情報の環境変数を除去し、commit/tag 署名と hook をローカルで無効化して、ユーザー設定を変更せず Git を非対話実行します。 - [`ProgramTests`](../FolderDiffIL4DotNet.Tests/ProgramTests.cs) は [`AppContext.BaseDirectory`](https://learn.microsoft.com/ja-jp/dotNet/API/system.appcontext.basedirectory?view=net-8.0) 配下の [`config.json`](../config.json) を一時書き換えし、必ず復元します。 - [`DotNetDisassembleServiceTests`](../FolderDiffIL4DotNet.Tests/Services/DotNetDisassembleServiceTests.cs) は `PATH`/`HOME` を一時変更し、擬似ツールスクリプトでフォールバック/ブラックリスト挙動を決定的に検証します。バージョンキャッシュに特定バージョンを事前投入するテストは、`GetVersionWithFallbacksAsync` が実ツールより先に擬似ツールを解決できるよう、同じバージョンを返す偽スクリプトも `PATH` に追加する必要があります(CI ランナーに実ツールがインストールされているため、追加しないとキャッシュ投入値が上書きされます)。 - [`RealDisassemblerE2ETests`](../FolderDiffIL4DotNet.Tests/Services/RealDisassemblerE2ETests.cs) は temp ディレクトリ上に一時クラスライブラリをビルドし、[`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) 固定で E2E 検証します。CI では [`dotnet-ildasm`](https://www.nuget.org/packages/dotnet-ildasm/) のインストール、グローバルツールディレクトリの `PATH` 追加、`DOTNET_ROLL_FORWARD=Major`、`FOLDERDIFF_RUN_E2E=true` によりこの前提を満たします。