using System.Diagnostics;
using Sbroenne.ExcelMcp.ComInterop.Session;
using Xunit;
using Xunit.Abstractions;
namespace Sbroenne.ExcelMcp.ComInterop.Tests.Integration.Session;
/// <summary>
/// Tests for SessionManager behavior when operations timeout.
///
/// These tests validate the integration between ExcelBatch timeout detection
/// and SessionManager session cleanup — the complete recovery path that was
/// missing before Bug 8 (Feb 2026).
///
/// LAYER RESPONSIBILITY:
/// - ✅ Test that SessionManager.CloseSession(force:true) works after timeout
/// - ✅ Test that session is removed after timeout + force close
/// - ✅ Test that subsequent GetSession returns null after timeout cleanup
/// - ✅ Test that Excel process is cleaned up end-to-end through SessionManager
/// </summary>
[Trait("Category", "Integration")]
[Trait("Speed", "Slow")]
[Trait("Layer", "ComInterop")]
[Trait("Feature", "SessionManager")]
[Trait("RunType", "OnDemand")]
[Collection("Sequential")]
public class SessionManagerTimeoutTests : IDisposable
{
private readonly ITestOutputHelper _output;
private readonly string _tempDir;
private readonly List<string> _testFiles = [];
private static readonly string TemplateFilePath = Path.Combine(
Path.GetDirectoryName(typeof(SessionManagerTimeoutTests).Assembly.Location)!,
"Integration", "Session", "TestFiles", "batch-test-static.xlsx");
public SessionManagerTimeoutTests(ITestOutputHelper output)
{
_output = output;
_tempDir = Path.Combine(Path.GetTempPath(), $"SessionMgrTimeoutTests_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
GC.SuppressFinalize(this);
foreach (var file in _testFiles.Where(File.Exists))
{
#pragma warning disable CA1031 // Intentional: best-effort test cleanup
try { File.Delete(file); } catch (Exception) { /* best effort */ }
#pragma warning restore CA1031
}
if (Directory.Exists(_tempDir))
{
#pragma warning disable CA1031
try { Directory.Delete(_tempDir, recursive: true); } catch (Exception) { /* best effort */ }
#pragma warning restore CA1031
}
}
private string CreateTestFile(string testName)
{
var filePath = Path.Combine(_tempDir, $"{testName}_{Guid.NewGuid():N}.xlsx");
File.Copy(TemplateFilePath, filePath);
_testFiles.Add(filePath);
return filePath;
}
/// <summary>
/// REGRESSION TEST: After a timeout, CloseSession(force:true) must succeed and remove the session.
/// This simulates what WithSessionAsync does when it catches TimeoutException.
/// Before Bug 8 fix, there was no TimeoutException handler — the session leaked.
/// </summary>
[Fact]
public void CloseSession_AfterTimeout_RemovesSessionAndCleansUp()
{
// Arrange
var testFile = CreateTestFile(nameof(CloseSession_AfterTimeout_RemovesSessionAndCleansUp));
using var manager = new SessionManager();
// Create session with very short timeout
var sessionId = manager.CreateSession(testFile, operationTimeout: TimeSpan.FromSeconds(3));
_output.WriteLine($"Session created: {sessionId}");
var batch = manager.GetSession(sessionId);
Assert.NotNull(batch);
// Warm up
batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; });
// Trigger timeout
var ex = Assert.Throws<TimeoutException>(() =>
{
batch.Execute((ctx, ct) =>
{
Thread.Sleep(TimeSpan.FromSeconds(30));
return 0;
});
});
_output.WriteLine($"Timeout triggered: {ex.Message}");
// Act — simulate what WithSessionAsync does: force-close the session
var closed = manager.CloseSession(sessionId, save: false, force: true);
// Assert
Assert.True(closed, "CloseSession should succeed after timeout");
Assert.Equal(0, manager.ActiveSessionCount);
Assert.Null(manager.GetSession(sessionId));
_output.WriteLine("✓ Session cleaned up after timeout");
}
/// <summary>
/// REGRESSION TEST: After timeout + force close, the Excel process must be terminated.
/// This is the end-to-end test for the complete Bug 8 recovery chain:
/// timeout → force close → pre-emptive kill → process cleanup.
/// </summary>
[Fact]
public void CloseSession_AfterTimeout_ExcelProcessIsTerminated()
{
// Arrange
var testFile = CreateTestFile(nameof(CloseSession_AfterTimeout_ExcelProcessIsTerminated));
using var manager = new SessionManager();
var sessionId = manager.CreateSession(testFile, operationTimeout: TimeSpan.FromSeconds(3));
var batch = manager.GetSession(sessionId)!;
int? excelPid = batch.ExcelProcessId;
_output.WriteLine($"Session {sessionId}, Excel PID: {excelPid}");
// Warm up
batch.Execute((ctx, ct) => { _ = ctx.Book.Worksheets[1]; return 0; });
// Trigger timeout
Assert.Throws<TimeoutException>(() =>
{
batch.Execute((ctx, ct) =>
{
Thread.Sleep(TimeSpan.FromSeconds(30));
return 0;
});
});
// Act — force close (this triggers Dispose → pre-emptive kill)
var sw = Stopwatch.StartNew();
manager.CloseSession(sessionId, save: false, force: true);
sw.Stop();
_output.WriteLine($"CloseSession took {sw.Elapsed.TotalSeconds:F1}s");
// Wait for process cleanup
Thread.Sleep(2000);
// Assert — Excel process should be dead
if (excelPid.HasValue)
{
bool processAlive;
try
{
using var process = Process.GetProcessById(excelPid.Value);
processAlive = !process.HasExited;
}
catch (ArgumentException)
{
processAlive = false;
}
Assert.False(processAlive,
$"REGRESSION: Excel process {excelPid.Value} still alive after timeout + force close. " +
"The pre-emptive kill in Dispose() may not be working.");
_output.WriteLine($"✓ Excel process {excelPid.Value} terminated");
}
}
/// <summary>
/// Test that normal operations still work with custom timeout — only long operations fail.
/// </summary>
[Fact]
public void Execute_WithinTimeout_SucceedsNormally()
{
// Arrange
var testFile = CreateTestFile(nameof(Execute_WithinTimeout_SucceedsNormally));
using var manager = new SessionManager();
var sessionId = manager.CreateSession(testFile, operationTimeout: TimeSpan.FromSeconds(30));
var batch = manager.GetSession(sessionId)!;
// Act — quick operation should succeed
var result = batch.Execute((ctx, ct) =>
{
dynamic sheet = ctx.Book.Worksheets[1];
return sheet.Name?.ToString() ?? "unknown";
});
// Assert
Assert.NotNull(result);
Assert.NotEmpty(result);
_output.WriteLine($"✓ Normal operation succeeded: sheet name = {result}");
}
}