Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,25 +203,38 @@ jobs:
"--no-build",
"--nologo",
"--settings", "tests/CodeIndex.Tests/CodeIndex.Tests.runsettings",
"--collect", "XPlat Code Coverage",
"--blame-crash",
"--blame-hang",
"--blame-hang-timeout", "5m",
"--logger", "trx;LogFileName=test_results.trx",
"--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."
Expand Down Expand Up @@ -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 }}
Expand Down
4 changes: 4 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3343.fixed.md
Original file line number Diff line number Diff line change
@@ -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 例外が出ることを防ぎます。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3358.fixed.md
Original file line number Diff line number Diff line change
@@ -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 として扱います。
5 changes: 5 additions & 0 deletions tests/CodeIndex.Tests/CiWorkflowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
58 changes: 51 additions & 7 deletions tests/CodeIndex.Tests/IndexWatchRunnerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>? 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 リダイレクトを保持したまま停止時間を検証するため。
Expand All @@ -415,6 +418,7 @@ public void RunCore_CancellationToken_StopsImmediately()
}
finally
{
CancelAndDrainWatchLoop(cts, loopTask);
Console.SetOut(originalOut);
}
capturedOut = stdout.ToString();
Expand Down Expand Up @@ -469,22 +473,27 @@ 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<int>? 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;
#pragma warning restore xUnit1031
}
finally
{
CancelAndDrainWatchLoop(cts, loopTask);
Console.SetError(originalErr);
Console.SetOut(originalOut);
}
Expand Down Expand Up @@ -547,6 +556,13 @@ private static string InvokeFormatHumanSummary(
return Assert.IsType<string>(method.Invoke(null, [status, batchSize, elapsedMs, subRunJson, exitCode]));
}

private static void CancelAndDrainWatchLoop(CancellationTokenSource cts, Task<int>? 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)
Expand Down Expand Up @@ -589,4 +605,32 @@ private static void DeleteDirectory(string path)
{
}
}

private sealed class SignalingStringWriter : StringWriter
{
private readonly Func<string, bool> _predicate;
private readonly ManualResetEventSlim _signal = new();

internal SignalingStringWriter(Func<string, bool> 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);
}
}
}
Loading