diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 441227b5f3..2efb3f3b78 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -203,7 +203,6 @@ jobs: "--no-build", "--nologo", "--settings", "tests/CodeIndex.Tests/CodeIndex.Tests.runsettings", - "--collect", "XPlat Code Coverage", "--blame-crash", "--blame-hang", "--blame-hang-timeout", "5m", @@ -211,17 +210,31 @@ jobs: "--results-directory", "./TestResults" ) - dotnet @testArgs + $collectCoverage = "${{ matrix.os }}" -ne "windows-latest" -or "${{ matrix.test-framework }}" -ne "net9.0" + if ($collectCoverage) { + $testArgs += @("--collect", "XPlat Code Coverage") + } else { + Write-Host "Skipping XPlat Code Coverage for windows-latest/net9.0 to keep the slowest matrix lane under the test session timeout; see #3358." + } + + New-Item -ItemType Directory -Force -Path ./TestResults | Out-Null + $firstLogPath = Join-Path "./TestResults" "test-output-first.txt" + dotnet @testArgs 2>&1 | Tee-Object -FilePath $firstLogPath $firstExitCode = $LASTEXITCODE if ($firstExitCode -eq 0) { exit 0 } + if (Select-String -Path $firstLogPath -SimpleMatch "test run timeout" -Quiet) { + Write-Warning "Initial test run hit TestSessionTimeout; skipping flaky retry to keep CI bounded. Inspect uploaded TRX/blame artifacts." + exit $firstExitCode + } + Write-Warning "Initial test run failed with exit code $firstExitCode. Rerunning once to classify possible flakiness." - dotnet @testArgs + $retryLogPath = Join-Path "./TestResults" "test-output-retry.txt" + dotnet @testArgs 2>&1 | Tee-Object -FilePath $retryLogPath $retryExitCode = $LASTEXITCODE if ($retryExitCode -eq 0) { - New-Item -ItemType Directory -Force -Path ./TestResults | Out-Null "Initial test run failed, but the single retry passed. Treat this run as flaky and inspect TRX/blame artifacts." | Set-Content -Encoding UTF8 ./TestResults/flaky-retry.txt Write-Warning "Tests passed on retry; uploaded TestResults include flaky-retry.txt." @@ -251,7 +264,7 @@ jobs: TestResults/**/*.hangdump - name: Upload coverage reports - if: always() + if: always() && !(matrix.os == 'windows-latest' && matrix.test-framework == 'net9.0') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Coverage-${{ matrix.os }}-${{ matrix.test-framework }} diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 520dd01c9d..3c619c11e0 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -46,6 +46,8 @@ The test project mirrors the production areas closely. CLI parsing, command execution, and installer behavior. Query command coverage is split by command family with partial `QueryCommandRunnerTests` classes so shared console and fixture helpers stay centralized. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`, `Run_CancelDuringDryRunScan_ReturnsInterruptedJson`, and `Run_CancelBeforeFreshScan_ReturnsInterruptedJson` exercise the same in-process cancellation paths used after Ctrl-C/SIGINT wiring, including scan-time cancellation, so interrupted index runs keep returning the canonical JSON error contract. +- `IndexWatchRunnerTests.RunCore_CancellationToken_StopsImmediately` and `RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled` + exercise watch-loop startup and shutdown under redirected console output. These tests wait for the watch start line before cancelling and always cancel/drain the dedicated watch task before restoring `Console.Out` / `Console.Error`; do not replace that synchronization with fixed sleeps because full-suite load can delay the long-running task startup. - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` is a coarse runaway guard for the real `InstallScriptTests.cs` C# extraction fixture. Its wall-clock budget is intentionally broader than a benchmark so slower or noisy CI hosts do not fail the suite for ordinary variance. - `IndexCommandRunnerTests.RunBackfillFold_PublishedTrimmedBinary_SerializesSuccessAndErrorJson` @@ -250,6 +252,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" CLI の引数解析、コマンド実行、installer 挙動のテスト。Query command coverage は command family ごとの partial `QueryCommandRunnerTests` class に分割し、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。 - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`、`Run_CancelDuringDryRunScan_ReturnsInterruptedJson`、`Run_CancelBeforeFreshScan_ReturnsInterruptedJson` Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を、scan 中のキャンセルも含めて検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 +- `IndexWatchRunnerTests.RunCore_CancellationToken_StopsImmediately` と `RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled` + リダイレクトした console 出力の下で watch loop の起動と停止を検証する。これらのテストは watch start 行を待ってからキャンセルし、`Console.Out` / `Console.Error` を戻す前に専用 watch task を必ず cancel/drain する。full suite の負荷で long-running task の起動が遅れることがあるため、この同期を固定 sleep に戻さないこと。 - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` は実ファイル `InstallScriptTests.cs` を C# 抽出に通す coarse な runaway guard です。wall-clock の予算は benchmark より意図的に広く取り、遅い / 混雑した CI host で通常の揺れだけにより suite が失敗しないようにしています。 - `IndexCommandRunnerTests.RunBackfillFold_PublishedTrimmedBinary_SerializesSuccessAndErrorJson` diff --git a/changelog.d/unreleased/3343.fixed.md b/changelog.d/unreleased/3343.fixed.md new file mode 100644 index 0000000000..d7fa66aa9b --- /dev/null +++ b/changelog.d/unreleased/3343.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3343 +affected: + - TESTING_GUIDE.md + - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +--- + +## English + +- **IndexWatchRunner cancellation coverage now waits for watch startup (#3343)** — the cancellation regression tests now cancel only after observing the watch loop's start event, so full Debug test runs do not race a delayed watcher task and report cancellation exceptions through the wait path. + +## 日本語 + +- **IndexWatchRunner の cancellation カバレッジが watch 起動を待つようになりました (#3343)** — cancellation 回帰テストは watch loop の起動イベントを観測してから取り消すようになり、Debug のフルテスト実行で watcher task の起動遅延と競合して待機経路に cancellation 例外が出ることを防ぎます。 diff --git a/changelog.d/unreleased/3358.fixed.md b/changelog.d/unreleased/3358.fixed.md new file mode 100644 index 0000000000..1f9a927fe7 --- /dev/null +++ b/changelog.d/unreleased/3358.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3358 +affected: + - .github/workflows/dotnet.yml + - tests/CodeIndex.Tests/CiWorkflowTests.cs +--- + +## English + +- **Windows/net9.0 CI no longer retries test-session timeouts (#3358)** — the slowest matrix leg skips XPlat Code Coverage collection, and the workflow now treats a `TestSessionTimeout` abort as a bounded failure instead of starting a second full-suite retry. + +## 日本語 + +- **Windows/net9.0 CI がテストセッションタイムアウトを再実行しないようになりました (#3358)** — 最も遅い matrix leg では XPlat Code Coverage 収集を省略し、`TestSessionTimeout` による中断は2回目のフルスイート再実行ではなく bounded failure として扱います。 diff --git a/tests/CodeIndex.Tests/CiWorkflowTests.cs b/tests/CodeIndex.Tests/CiWorkflowTests.cs index d3a97f1ed7..4b0aa206b4 100644 --- a/tests/CodeIndex.Tests/CiWorkflowTests.cs +++ b/tests/CodeIndex.Tests/CiWorkflowTests.cs @@ -10,15 +10,20 @@ public void DotnetWorkflow_RunsTestsWithRunsettingsBlameRetryAndArtifacts() var workflow = File.ReadAllText(Path.Combine(GetRepositoryRoot(), ".github", "workflows", "dotnet.yml")); Assert.Contains("--settings\", \"tests/CodeIndex.Tests/CodeIndex.Tests.runsettings", workflow); + Assert.Contains("Skipping XPlat Code Coverage for windows-latest/net9.0", workflow); Assert.Contains("--blame-crash", workflow); Assert.Contains("--blame-hang", workflow); Assert.Contains("--blame-hang-timeout\", \"5m", workflow); + Assert.Contains("test-output-first.txt", workflow); + Assert.Contains("Initial test run hit TestSessionTimeout; skipping flaky retry", workflow); Assert.Contains("Rerunning once to classify possible flakiness.", workflow); Assert.Contains("flaky-retry.txt", workflow); Assert.Contains("TestResults/**/*.trx", workflow); + Assert.Contains("TestResults/**/*.txt", workflow); Assert.Contains("TestResults/**/*Sequence*.xml", workflow); Assert.Contains("TestResults/**/*.dmp", workflow); Assert.Contains("TestResults/**/*.dump", workflow); + Assert.Contains("always() && !(matrix.os == 'windows-latest' && matrix.test-framework == 'net9.0')", workflow); } [Fact] diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index c0e6f96a9a..ee7476b130 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -396,14 +396,17 @@ public void RunCore_CancellationToken_StopsImmediately() lock (TestConsoleLock.Gate) { var originalOut = Console.Out; - using var stdout = new StringWriter(); + using var stdout = new SignalingStringWriter( + line => line.Contains("\"status\":\"watching\"", StringComparison.Ordinal)); + Task? loopTask = null; Console.SetOut(stdout); try { - var loopTask = StartWatchLoop(options, projectRoot, dbPath, cts.Token); - // Give the watcher a moment to emit the "watching" event. - Thread.Sleep(500); + loopTask = StartWatchLoop(options, projectRoot, dbPath, cts.Token); + var started = stdout.WaitForSignal(TimeSpan.FromSeconds(10)); cts.Cancel(); + Assert.True(started, + "Watch loop did not emit the watching event before cancellation / 取り消し前に watching イベントが出力されなかった"); // Blocking wait is intentional: this test verifies the loop terminates within // a wall-clock budget while holding the redirected Console.Out under a lock. // 同期的に待機しているのは、Console.Out リダイレクトを保持したまま停止時間を検証するため。 @@ -415,6 +418,7 @@ public void RunCore_CancellationToken_StopsImmediately() } finally { + CancelAndDrainWatchLoop(cts, loopTask); Console.SetOut(originalOut); } capturedOut = stdout.ToString(); @@ -469,15 +473,19 @@ public void RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled() { var originalErr = Console.Error; var originalOut = Console.Out; - using var stderr = new StringWriter(); + using var stderr = new SignalingStringWriter( + line => line.Contains("[watch] Watching", StringComparison.Ordinal)); using var stdout = new StringWriter(); + Task? loopTask = null; Console.SetError(stderr); Console.SetOut(stdout); try { - var loopTask = StartWatchLoop(options, projectRoot, dbPath, cts.Token); - Thread.Sleep(500); + loopTask = StartWatchLoop(options, projectRoot, dbPath, cts.Token); + var started = stderr.WaitForSignal(TimeSpan.FromSeconds(10)); cts.Cancel(); + Assert.True(started, + "Watch loop did not emit the human start line before cancellation / 取り消し前に human start 行が出力されなかった"); #pragma warning disable xUnit1031 Assert.True(loopTask.Wait(TimeSpan.FromSeconds(10))); exitCode = loopTask.Result; @@ -485,6 +493,7 @@ public void RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled() } finally { + CancelAndDrainWatchLoop(cts, loopTask); Console.SetError(originalErr); Console.SetOut(originalOut); } @@ -547,6 +556,13 @@ private static string InvokeFormatHumanSummary( return Assert.IsType(method.Invoke(null, [status, batchSize, elapsedMs, subRunJson, exitCode])); } + private static void CancelAndDrainWatchLoop(CancellationTokenSource cts, Task? loopTask) + { + cts.Cancel(); + if (loopTask is { IsCompleted: false }) + SpinWait.SpinUntil(() => loopTask.IsCompleted, TimeSpan.FromSeconds(10)); + } + private string RunIndexAndCapture(string[] args, out int exitCode) { lock (TestConsoleLock.Gate) @@ -589,4 +605,32 @@ private static void DeleteDirectory(string path) { } } + + private sealed class SignalingStringWriter : StringWriter + { + private readonly Func _predicate; + private readonly ManualResetEventSlim _signal = new(); + + internal SignalingStringWriter(Func predicate) + { + _predicate = predicate; + } + + internal bool WaitForSignal(TimeSpan timeout) + => _signal.Wait(timeout); + + public override void WriteLine(string? value) + { + base.WriteLine(value); + if (value is not null && _predicate(value)) + _signal.Set(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _signal.Dispose(); + base.Dispose(disposing); + } + } }