using System.Diagnostics; using System.Text.Json; using Explorer.Application; using Explorer.Contracts; using Explorer.Domain; using Explorer.Hosting.Ipc; using Explorer.Plugin.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; namespace Explorer.Hosting.Tests; public class WorkbenchPipeTests { [Fact] public void Handle_forwards_indexing_and_transfer_calls() { var indexing = new FakeIndexing(); var transfers = new FakeTransfers(); var server = CreateServer(indexing, transfers); var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" }); Assert.True(ping.Ok); var scan = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Indexing.EnqueueFullScan", N = 42 }); Assert.True(scan.Ok); Assert.Equal(42, indexing.FullScanId); var pause = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.PauseAll" }); Assert.True(pause.Ok); Assert.True(transfers.PausedAll); var paused = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.IsPaused" }); Assert.True(paused.Paused); var copy = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Transfers.EnqueueCopy", Paths = ["C:\\a.txt"], Dest = "D:\\" }); Assert.True(copy.Ok); Assert.Equal(@"D:\", transfers.CopiedDest); } [Fact] public void Handle_rejects_other_protocol_versions() { var server = CreateServer(new FakeIndexing(), new FakeTransfers()); var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" }); Assert.False(reply.Ok); Assert.Contains("99", reply.Error); } [Fact] public async Task Connect_to_a_missing_pipe_fails_quickly() { var options = new WorkbenchIpcOptions { PipeName = "ew-missing-" + Guid.NewGuid().ToString("N") }; var started = Stopwatch.GetTimestamp(); await Assert.ThrowsAsync(() => WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromMilliseconds(400))); Assert.True( Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(3), "Named-pipe connect hung instead of timing out."); } [Fact] public void Handle_cloud_places_does_not_need_a_workbench() { var services = new ServiceCollection(); services.AddSingleton(NullCloudOverlay.Instance); using var sp = services.BuildServiceProvider(); var server = new WorkbenchPipeServer( sp, new WorkbenchIpcOptions(), NullLogger.Instance); var reply = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Cloud.Places" }); Assert.True(reply.Ok); var places = JsonSerializer.Deserialize(reply.Payload ?? "null", WorkbenchIpc.Json); Assert.NotNull(places); Assert.Empty(places); } [Fact] public async Task IsListening_does_not_consume_the_server_instance() { var options = new WorkbenchIpcOptions { PipeName = "ew-probe-" + Guid.NewGuid().ToString("N") }; using var sp = new ServiceCollection().BuildServiceProvider(); var server = new WorkbenchPipeServer( sp, options, NullLogger.Instance); await server.StartAsync(CancellationToken.None); try { await server.Listening.WaitAsync(TimeSpan.FromSeconds(3)); Assert.True(WorkbenchIpc.IsListening(options.PipeName, 200)); await using var client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(3)); } finally { await server.StopAsync(CancellationToken.None); } } [Fact] public void Handle_host_shutdown_does_not_need_a_workbench() { using var sp = new ServiceCollection().BuildServiceProvider(); var server = new WorkbenchPipeServer( sp, new WorkbenchIpcOptions(), NullLogger.Instance); var stopped = false; server.ShutdownRequested = () => stopped = true; var reply = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Shutdown" }); Assert.True(reply.Ok); Assert.True(stopped); } [Fact] public void Ping_does_not_need_a_workbench() { using var sp = new ServiceCollection().BuildServiceProvider(); var server = new WorkbenchPipeServer( sp, new WorkbenchIpcOptions(), NullLogger.Instance); var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" }); Assert.True(ping.Ok); } [Fact] public async Task Ping_roundtrip_over_a_live_named_pipe() { var options = new WorkbenchIpcOptions { PipeName = "ew-live-" + Guid.NewGuid().ToString("N") }; using var sp = new ServiceCollection().BuildServiceProvider(); var server = new WorkbenchPipeServer( sp, options, NullLogger.Instance); await server.StartAsync(CancellationToken.None); try { await server.Listening.WaitAsync(TimeSpan.FromSeconds(3)); await using var client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(3)); } finally { await server.StopAsync(CancellationToken.None); } } private static WorkbenchPipeServer CreateServer(FakeIndexing indexing, FakeTransfers transfers) { var services = new ServiceCollection(); services.AddSingleton( new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations())); return new WorkbenchPipeServer( services.BuildServiceProvider(), new WorkbenchIpcOptions { PipeName = "ew-test" }, NullLogger.Instance); } private sealed class FakeIndexing : IIndexingHost { public long FullScanId { get; private set; } public event EventHandler? ProgressChanged = delegate { }; public void EnqueueFullScan(long sourceId) => FullScanId = sourceId; public void EnqueueFolderScan(long sourceId, string pathRel) { } public void EnqueueReconcile(long sourceId, string pathRel) { } public void Cancel(long sourceId) { } } private sealed class FakeTransfers : ITransferHost { public bool PausedAll { get; private set; } public event EventHandler? Changed = delegate { }; public event EventHandler? JobFinished = delegate { }; public bool IsPaused => PausedAll; public IReadOnlyList Snapshot() => []; public void PauseAll() => PausedAll = true; public void ResumeAll() => PausedAll = false; public void Pause(long jobId) { } public void Resume(long jobId) { } public void Retry(long jobId) { } public void Cancel(long jobId) { } public void Dismiss(long jobId) { } public void ClearFinished() { } public bool MoveUp(long jobId) => false; public bool MoveDown(long jobId) => false; public string? CopiedDest { get; private set; } public Task EnqueueCopyAsync(IReadOnlyList sources, string destinationDirectory, CancellationToken cancellationToken = default) { CopiedDest = destinationDirectory; return Task.CompletedTask; } } private sealed class StubSources : ISourceHost { public Task RefreshAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task AddUncAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(new Source { StableKey = "x", DisplayName = path }); public Task EnsureForPathAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(null); public Task ForgetAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(false); } private sealed class StubMutations : IIndexMutations { public Task UpsertSyncProfileAsync(SyncProfile profile, CancellationToken cancellationToken = default) => Task.FromResult(1L); public Task DeleteSyncProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task UpsertOperationProfileAsync(OperationProfile profile, CancellationToken cancellationToken = default) => Task.FromResult(1L); public Task DeleteOperationProfileAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task CreateRenameBatchAsync(IReadOnlyList items, CancellationToken cancellationToken = default) => Task.FromResult(1L); public Task MarkRenameBatchUndoneAsync(long id, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task EnqueueHashCollisionsAsync(long? sourceId, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task UpsertRelationAsync(FileRelation relation, CancellationToken cancellationToken = default) => Task.CompletedTask; } }