Add queued FFmpeg conversion and finish splitting the window from the host.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 01:49:50 +02:00
parent 48d03f794f
commit 6fc7506eb7
85 changed files with 3394 additions and 512 deletions

View File

@@ -0,0 +1,109 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
namespace Explorer.Application.Tests;
public class ConversionPlannerTests
{
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
[Fact]
public void Video_files_become_mp4_outputs()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.File(@"C:\src\notes.txt", 1, T0)
.Dir(@"C:\dst");
var plan = Build([@"C:\src"], @"C:\dst", ConversionKind.VideoToMp4, fs);
Assert.True(plan.CanEnqueue);
var op = Assert.Single(plan.Operations);
Assert.Equal(TransferOp.Convert, op.Op);
Assert.Equal(@"C:\src\clip.mov", op.SourcePath);
Assert.Equal(@"C:\dst\clip.mp4", op.DestinationPath);
Assert.Contains(plan.Issues, i => i.Message.Contains("not a match", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Online_only_files_are_skipped()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.Dir(@"C:\dst");
var plan = Build(
[@"C:\src\clip.mov"],
@"C:\dst",
ConversionKind.VideoToMp4,
fs,
wouldHydrate: item => item.Name == "clip.mov");
Assert.False(plan.CanEnqueue);
Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Missing_ffmpeg_is_an_error()
{
var fs = Tree().Dir(@"C:\src").File(@"C:\src\clip.mov", 10, T0).Dir(@"C:\dst");
var plan = Build([@"C:\src\clip.mov"], @"C:\dst", ConversionKind.VideoToMp4, fs, ffmpegAvailable: false);
Assert.False(plan.CanEnqueue);
Assert.Contains(plan.Issues, i => i.Message.Contains("FFmpeg", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Unique_names_avoid_overwrite()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.File(@"C:\src\clip.mkv", 10, T0)
.Dir(@"C:\dst");
var plan = Build(
[@"C:\src"],
@"C:\dst",
ConversionKind.VideoToMp4,
fs,
pathExists: path => path.Equals(@"C:\dst\clip.mp4", StringComparison.OrdinalIgnoreCase));
Assert.True(plan.CanEnqueue);
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\dst\clip (2).mp4");
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\dst\clip (3).mp4");
}
[Fact]
public void Heic_maps_to_jpeg()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\IMG_0001.HEIC", 4, T0)
.Dir(@"C:\dst");
var plan = Build([@"C:\src\IMG_0001.HEIC"], @"C:\dst", ConversionKind.HeicToJpeg, fs);
var op = Assert.Single(plan.Operations);
Assert.Equal(@"C:\dst\IMG_0001.jpg", op.DestinationPath);
Assert.Equal(nameof(ConversionKind.HeicToJpeg), op.NewName);
}
private static OperationPlan Build(
string[] sources,
string dest,
ConversionKind kind,
IFileSystemEnumerator fs,
bool ffmpegAvailable = true,
Func<string, bool>? pathExists = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
=> new ConversionPlanner().Build(
sources,
dest,
kind,
fs,
ffmpegAvailable,
FfmpegLocator.MissingHint,
pathExists ?? (_ => false),
wouldHydrate);
private static TreeEnumerator Tree() => new();
}

View File

@@ -0,0 +1,30 @@
using Explorer.Application;
namespace Explorer.Application.Tests;
public class FfmpegLocatorTests
{
[Fact]
public void Prefers_the_configured_path_when_it_exists()
{
var path = @"C:\Tools\ffmpeg.exe";
Assert.Equal(path, FfmpegLocator.Find(path, fileExists: p => p == path, pathVariable: ""));
}
[Fact]
public void Finds_ffmpeg_on_PATH_when_not_configured()
{
var found = FfmpegLocator.Find(
null,
fileExists: p => p.Equals(@"D:\bin\ffmpeg.exe", StringComparison.OrdinalIgnoreCase),
pathVariable: @"C:\Windows;D:\bin");
Assert.Equal(@"D:\bin\ffmpeg.exe", found);
}
[Fact]
public void Returns_null_when_ffmpeg_is_missing()
{
Assert.Null(FfmpegLocator.Find(null, fileExists: _ => false, pathVariable: @"C:\none"));
Assert.Contains("FFmpeg", FfmpegLocator.MissingHint, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -160,6 +160,44 @@ public class FileOperationProfilePlannerTests
Assert.Contains(plan.Issues, i => i.Message.Contains("7-Zip", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Convert_lists_one_job_per_matching_file()
{
var fs = Tree()
.Dir(@"C:\src")
.File(@"C:\src\clip.mov", 10, T0)
.File(@"C:\src\notes.txt", 1, T0)
.Dir(@"C:\dst");
var plan = Build(new OperationProfile
{
Name = "Convert",
DestPath = @"C:\dst",
DoConvert = true,
ConversionKind = ConversionKind.VideoToMp4
}, fs, [@"C:\src"]);
Assert.True(plan.CanEnqueue);
var convert = Assert.Single(plan.Operations);
Assert.Equal(TransferOp.Convert, convert.Op);
Assert.Equal(@"C:\src\clip.mov", convert.SourcePath);
Assert.Equal(@"C:\dst\clip.mp4", convert.DestinationPath);
Assert.Contains(plan.ProfilePreview, r => r.Action == "Convert");
}
[Fact]
public void Missing_ffmpeg_is_an_error()
{
var fs = Tree().Dir(@"C:\src").File(@"C:\src\clip.mov", 10, T0).Dir(@"C:\dst");
var plan = Build(new OperationProfile
{
Name = "Convert",
DestPath = @"C:\dst",
DoConvert = true
}, fs, [@"C:\src"], convertAvailable: false);
Assert.False(plan.CanEnqueue);
Assert.Contains(plan.Issues, i => i.Message.Contains("FFmpeg", StringComparison.OrdinalIgnoreCase));
}
private static OperationProfile CopyProfile(string dest = @"C:\dst", bool git = false)
=> new()
{
@@ -176,6 +214,7 @@ public class FileOperationProfilePlannerTests
GitStatus? git = null,
bool gitAvailable = true,
bool compressAvailable = true,
bool convertAvailable = true,
Func<string, bool>? reachable = null,
Func<FileSystemItem, bool>? wouldHydrate = null)
=> new FileOperationProfilePlanner(new RenamePlanner()).Build(
@@ -188,7 +227,9 @@ public class FileOperationProfilePlannerTests
compressAvailable,
SevenZipLocator.MissingHint,
pathExists: _ => false,
wouldHydrate: wouldHydrate);
wouldHydrate: wouldHydrate,
convertAvailable: convertAvailable,
convertMissingHint: FfmpegLocator.MissingHint);
private static TreeEnumerator Tree() => new();
}

View File

@@ -25,6 +25,7 @@ public class UiPreferencesStoreTests
Assert.True(prefs.AutoClearQueueWhenDone);
Assert.Equal(@"C:\Program Files\7-Zip\7z.exe", prefs.SevenZipPath);
Assert.Null(prefs.GitPath);
Assert.Null(prefs.FfmpegPath);
}
[Fact]
@@ -43,6 +44,13 @@ public class UiPreferencesStoreTests
Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath);
}
[Fact]
public void Parse_reads_ffmpeg_path()
{
var prefs = UiPreferencesStore.Parse(["ffmpeg=C:\\Tools\\ffmpeg.exe"]);
Assert.Equal(@"C:\Tools\ffmpeg.exe", prefs.FfmpegPath);
}
[Fact]
public void Parse_reads_host_and_removable_index_flags()
{
@@ -70,6 +78,8 @@ public class UiPreferencesStoreTests
Assert.False(prefs.BackgroundHostAtLogon);
Assert.Null(prefs.SevenZipPath);
Assert.Null(prefs.GitPath);
Assert.Null(prefs.FfmpegPath);
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
}
[Fact]
@@ -91,6 +101,43 @@ public class UiPreferencesStoreTests
Assert.Equal(24, prefs.WindowTop);
Assert.True(prefs.WindowMaximized);
Assert.Equal(320, prefs.TreeWidth);
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
}
[Fact]
public void Parse_reads_session_tabs_and_active_index()
{
var left = @"C:\Users\Dominique\Documents";
var right = @"D:\Photos";
var prefs = UiPreferencesStore.Parse(
[
"session-active-tab=1",
"session-tab=" + UiPreferencesStore.FormatSessionTab(new SessionTabState(LocationRoots.ThisPc)),
"session-tab=" + UiPreferencesStore.FormatSessionTab(new SessionTabState(left, right, true, 0.42, true))
]);
Assert.Equal(1, prefs.SessionActiveTab);
Assert.NotNull(prefs.SessionTabs);
Assert.Equal(2, prefs.SessionTabs.Count);
Assert.Equal(LocationRoots.ThisPc, prefs.SessionTabs[0].LeftPath);
Assert.False(prefs.SessionTabs[0].IsSplit);
Assert.Equal(left, prefs.SessionTabs[1].LeftPath);
Assert.Equal(right, prefs.SessionTabs[1].RightPath);
Assert.True(prefs.SessionTabs[1].IsSplit);
Assert.Equal(0.42, prefs.SessionTabs[1].SplitRatio);
Assert.True(prefs.SessionTabs[1].ActiveIsRight);
}
[Fact]
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
{
var state = new SessionTabState(@"C:\weird;name", @"\\server\share", true, 0.3, false);
var parsed = UiPreferencesStore.TryParseSessionTab(UiPreferencesStore.FormatSessionTab(state));
Assert.NotNull(parsed);
Assert.Equal(state.LeftPath, parsed.LeftPath);
Assert.Equal(state.RightPath, parsed.RightPath);
Assert.True(parsed.IsSplit);
Assert.Equal(0.3, parsed.SplitRatio);
Assert.False(parsed.ActiveIsRight);
}
[Fact]
@@ -100,7 +147,17 @@ public class UiPreferencesStoreTests
try
{
var store = new UiPreferencesStore(new PrefsEnv(dir));
store.Save(new UiPreferences("Light", true, false, true, AutoClearQueueWhenDone: true, WindowWidth: 1100, WindowHeight: 720, TreeWidth: 300));
store.Save(new UiPreferences(
"Light", true, false, true,
AutoClearQueueWhenDone: true,
WindowWidth: 1100,
WindowHeight: 720,
TreeWidth: 300,
SessionTabs:
[
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
],
SessionActiveTab: 0));
var loaded = store.Load();
Assert.Equal("Light", loaded.Theme);
Assert.True(loaded.GroupNetworkPlaces);
@@ -114,6 +171,13 @@ public class UiPreferencesStoreTests
Assert.Equal(1100, loaded.WindowWidth);
Assert.Equal(720, loaded.WindowHeight);
Assert.Equal(300, loaded.TreeWidth);
Assert.NotNull(loaded.SessionTabs);
var tab = Assert.Single(loaded.SessionTabs);
Assert.Equal(@"C:\Temp", tab.LeftPath);
Assert.Equal(@"D:\", tab.RightPath);
Assert.True(tab.IsSplit);
Assert.Equal(0.6, tab.SplitRatio);
Assert.True(tab.ActiveIsRight);
}
finally
{

View File

@@ -110,6 +110,29 @@ public class ArchiveFormatsTests
}
}
public class ConversionFormatsTests
{
[Fact]
public void Matches_video_audio_and_heic()
{
Assert.True(ConversionFormats.Matches("clip.MOV", ConversionKind.VideoToMp4));
Assert.True(ConversionFormats.Matches("talk.wav", ConversionKind.ExtractAudio));
Assert.True(ConversionFormats.Matches("film.mkv", ConversionKind.ExtractAudio));
Assert.True(ConversionFormats.Matches("IMG_0001.heic", ConversionKind.HeicToJpeg));
Assert.False(ConversionFormats.Matches("notes.txt", ConversionKind.VideoToMp4));
Assert.False(ConversionFormats.IsConvertible("notes.txt"));
Assert.True(ConversionFormats.IsConvertible("phone.mp4"));
Assert.Equal("mp4", ConversionFormats.Extension(ConversionKind.VideoToMp4));
Assert.Equal("m4a", ConversionFormats.Extension(ConversionKind.ExtractAudio));
Assert.Equal("jpg", ConversionFormats.Extension(ConversionKind.HeicToJpeg));
Assert.Equal(ConversionKind.VideoToMp4, ConversionFormats.Preferred(["clip.mov", "notes.txt"]));
Assert.Equal(ConversionKind.HeicToJpeg, ConversionFormats.Preferred(["IMG.HEIC"]));
Assert.Equal(ConversionKind.VideoToMp4, ConversionFormats.Infer(@"C:\a.mov", @"C:\a.mp4"));
Assert.Equal(ConversionKind.ExtractAudio, ConversionFormats.Infer(@"C:\a.mov", @"C:\a.m4a"));
Assert.Equal(ConversionKind.HeicToJpeg, ConversionFormats.Infer(@"C:\a.heic", @"C:\a.jpg"));
}
}
public class VolumeIdentityTests
{
[Fact]
@@ -386,6 +409,9 @@ public class OperationProfileTests
var compress = new OperationProfile { Name = "Archive", DoCopy = true, DoCompress = true, AutoRun = true };
Assert.False(compress.CanAutoRun);
var convert = new OperationProfile { Name = "Convert", DoCopy = true, DoConvert = true, AutoRun = true };
Assert.False(convert.CanAutoRun);
var rename = new OperationProfile { Name = "Rename", DoCopy = true, DoRename = true, RenamePrefix = "x_", AutoRun = true };
Assert.False(rename.CanAutoRun);
}

View File

@@ -61,6 +61,21 @@ public class OperationProfileServiceTests
Assert.Empty(ctx.Queue.Snapshot());
}
[Fact]
public async Task Convert_auto_run_is_ignored()
{
await using var ctx = await ProfileHarness.CreateAsync();
ctx.Profile.DoConvert = true;
ctx.Profile.AutoRun = true;
await ctx.Profiles.SaveAsync(ctx.Profile);
Assert.False((await ctx.Store.OperationProfiles.GetAsync(ctx.Profile.Id))!.CanAutoRun);
ctx.Volumes.Reachable = false;
await ctx.Profiles.TryAutoRunAsync();
ctx.Volumes.Reachable = true;
await ctx.Profiles.TryAutoRunAsync();
Assert.Empty(ctx.Queue.Snapshot());
}
private static async Task WaitUntil(Func<bool> condition)
{
var limit = DateTime.UtcNow + TimeSpan.FromSeconds(4);
@@ -119,7 +134,8 @@ public class OperationProfileServiceTests
enumerator,
git,
new NeverHydrate(),
new FakeArchiveExecutor());
new FakeArchiveExecutor(),
new FakeConversionExecutor());
return new ProfileHarness
{
Profiles = profiles,

View File

@@ -395,6 +395,48 @@ public class TransferQueueTests
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Convert_fails_when_ffmpeg_is_missing()
{
await using var ctx = await Harness.CreateAsync();
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
await ctx.Queue.StartAsync(CancellationToken.None);
await ops.ConvertAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "a.mp4"), ConversionKind.VideoToMp4);
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
j.Op == TransferOp.Convert && j.Status == TransferStatus.Failed));
var job = ctx.Queue.Snapshot().Single(j => j.Op == TransferOp.Convert);
Assert.Contains("FFmpeg", job.Error, StringComparison.OrdinalIgnoreCase);
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Fake_convert_writes_output()
{
var fake = new FakeConversionExecutor();
await using var ctx = await Harness.CreateAsync(conversion: fake);
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
await ctx.Queue.StartAsync(CancellationToken.None);
var dest = Path.Combine(ctx.Dest, "a.mp4");
await ops.ConvertAsync(ctx.File("a.txt"), dest, ConversionKind.VideoToMp4);
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
j.Op == TransferOp.Convert && j.Status == TransferStatus.Done));
Assert.True(File.Exists(dest));
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Convert_refuses_online_only_cloud_files()
{
await using var ctx = await Harness.CreateAsync(hydration: new AlwaysHydrate(), conversion: new FakeConversionExecutor());
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
await ctx.Queue.StartAsync(CancellationToken.None);
await ops.ConvertAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "a.mp4"), ConversionKind.VideoToMp4);
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
j.Op == TransferOp.Convert && j.Status == TransferStatus.Failed));
Assert.Equal(FileOperationErrors.CloudHydration, ctx.Queue.Snapshot().Single().Error);
await ctx.Queue.StopAsync(CancellationToken.None);
}
[Fact]
public async Task Empty_recycle_bin_goes_through_the_queue()
{
@@ -438,7 +480,8 @@ public class TransferQueueTests
public static async Task<Harness> CreateAsync(
IArchiveExecutor? archives = null,
IHydrationGuard? hydration = null)
IHydrationGuard? hydration = null,
IMediaConversionProvider? conversion = null)
{
var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
@@ -452,7 +495,7 @@ public class TransferQueueTests
var shell = new GateShell();
var volumes = new ControlledVolumes();
var queue = new TransferQueue(
new NativeFileOperationExecutor(shell, new DiskEnum(), archives, hydration),
new NativeFileOperationExecutor(shell, new DiskEnum(), archives, hydration, conversion),
store,
volumes,
NullLogger<TransferQueue>.Instance);
@@ -629,6 +672,24 @@ internal sealed class FakeArchiveExecutor : IArchiveExecutor
=> Task.CompletedTask;
}
internal sealed class FakeConversionExecutor : IMediaConversionProvider
{
public bool IsAvailable { get; set; } = true;
public string MissingHint => FfmpegLocator.MissingHint;
public async Task ConvertAsync(
string sourcePath,
string destinationPath,
ConversionKind kind,
IProgress<ConversionProgress>? progress,
CancellationToken cancellationToken)
{
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
await File.WriteAllTextAsync(destinationPath, "converted:" + kind, cancellationToken).ConfigureAwait(false);
progress?.Report(new ConversionProgress(100, destinationPath));
}
}
internal sealed class AlwaysHydrate : IHydrationGuard
{
public bool WouldHydrateOnRead(FileSystemItem item) => true;

View File

@@ -1,7 +1,12 @@
using Explorer.Analysis;
using Explorer.Application;
using Explorer.Contracts;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.Hosting;
using Explorer.Indexing;
using Explorer.Plugin.Abstractions;
using Explorer.Search;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -26,6 +31,10 @@ public class CoreRegistrationTests
Assert.NotNull(sp.GetService<ITransferHost>());
Assert.NotNull(sp.GetService<IIndexMutations>());
Assert.Null(sp.GetService<IOsClipboard>());
Assert.NotNull(sp.GetService<FilesystemScanner>());
Assert.NotEmpty(sp.GetServices<IStorageProvider>());
Assert.NotNull(sp.GetService<ICloudOverlay>());
Assert.IsType<StorageProviderRegistry>(sp.GetService<ICloudOverlay>());
}
finally
{
@@ -52,9 +61,22 @@ public class CoreRegistrationTests
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
Assert.False(sp.GetRequiredService<IIndexStore>().CanWrite);
Assert.NotNull(sp.GetService<BrowseService>());
Assert.NotNull(sp.GetService<SearchService>());
Assert.NotNull(sp.GetService<AnalysisService>());
Assert.NotNull(sp.GetService<Explorer.FileOperations.FileOperationService>());
Assert.NotNull(sp.GetService<Explorer.FileOperations.FolderSyncService>());
Assert.Same(NullCloudOverlay.Instance, sp.GetService<ICloudOverlay>());
Assert.Null(sp.GetService<FilesystemScanner>());
Assert.Null(sp.GetService<FolderReconciler>());
Assert.Null(sp.GetService<UsnChangeApplier>());
Assert.Null(sp.GetService<ArchiveContentsIndexer>());
Assert.Null(sp.GetService<IUsnJournal>());
Assert.Null(sp.GetService<IElevatedScanService>());
Assert.Empty(sp.GetServices<IStorageProvider>());
var hosted = sp.GetServices<IHostedService>().ToList();
Assert.Contains(hosted, s => s is IndexStoreLifetime);
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService");
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService");
}
finally
{

View File

@@ -17,6 +17,7 @@
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\..\src\Explorer.Contracts\Explorer.Contracts.csproj" />
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\..\src\Explorer.Hosting.Client\Explorer.Hosting.Client.csproj" />
<ProjectReference Include="..\..\src\Explorer.Hosting\Explorer.Hosting.csproj" />
</ItemGroup>
</Project>

View File

@@ -5,15 +5,12 @@ namespace Explorer.Hosting.Tests;
public class HostLogonAutostartTests
{
[Fact]
public void Create_args_are_per_user_logon_not_system_service()
public void Run_command_is_the_quoted_host_exe_for_the_current_user()
{
var args = HostLogonAutostart.CreateTaskArgs(@"C:\Tools\Explorer.Host.exe");
Assert.Contains("/SC", args);
Assert.Contains("ONLOGON", args);
Assert.Contains("/RL", args);
Assert.Contains("LIMITED", args);
Assert.Contains(HostLogonAutostart.TaskName, args);
Assert.DoesNotContain("ONSTART", args);
Assert.DoesNotContain("/RU", args);
var command = HostLogonAutostart.RunCommand(@"C:\Tools\Explorer.Host.exe");
Assert.Equal("\"C:\\Tools\\Explorer.Host.exe\"", command);
Assert.Equal("ExplorerWorkbenchHost", HostLogonAutostart.RunValueName);
Assert.DoesNotContain("/RU", command, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("ONSTART", command, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,67 @@
namespace Explorer.Hosting.Tests;
public class ProjectGraphTests
{
[Fact]
public void App_references_client_not_host_runtime_or_plugin_implementations()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.App", "Explorer.App.csproj"));
Assert.Contains("Explorer.Hosting.Client", csproj);
Assert.Contains("Explorer.Presentation", csproj);
Assert.DoesNotContain("Explorer.Hosting\\Explorer.Hosting.csproj", csproj);
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
Assert.DoesNotContain("Explorer.Indexing", csproj);
}
[Fact]
public void Client_does_not_reference_plugin_implementations_or_scanners()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Hosting.Client", "Explorer.Hosting.Client.csproj"));
Assert.Contains("Explorer.Plugin.Abstractions", csproj);
Assert.Contains("Explorer.Storage.Sqlite", csproj);
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
Assert.DoesNotContain("Explorer.Indexing", csproj);
Assert.DoesNotContain("Explorer.Hosting\\Explorer.Hosting.csproj", csproj);
}
[Fact]
public void Host_runtime_owns_plugins_and_references_the_client()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Hosting", "Explorer.Hosting.csproj"));
Assert.Contains("Explorer.Hosting.Client", csproj);
Assert.Contains("Explorer.Plugin.OneDrive", csproj);
Assert.Contains("Explorer.Plugin.GoogleDrive", csproj);
Assert.Contains("Explorer.Plugin.Nextcloud", csproj);
Assert.Contains("Explorer.Indexing", csproj);
}
[Fact]
public void Presentation_may_reference_plugin_abstractions_not_implementations()
{
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Presentation", "Explorer.Presentation.csproj"));
Assert.Contains("Explorer.Plugin.Abstractions", csproj);
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
}
private static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "Explorer.slnx")))
{
return dir.FullName;
}
dir = dir.Parent;
}
throw new InvalidOperationException("Could not find Explorer.slnx above " + AppContext.BaseDirectory);
}
}

View File

@@ -1,7 +1,11 @@
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;
@@ -13,10 +17,7 @@ public class WorkbenchPipeTests
{
var indexing = new FakeIndexing();
var transfers = new FakeTransfers();
var server = new WorkbenchPipeServer(
new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()),
new WorkbenchIpcOptions { PipeName = "ew-test" },
NullLogger<WorkbenchPipeServer>.Instance);
var server = CreateServer(indexing, transfers);
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
Assert.True(ping.Ok);
@@ -46,15 +47,122 @@ public class WorkbenchPipeTests
[Fact]
public void Handle_rejects_other_protocol_versions()
{
var server = new WorkbenchPipeServer(
new WorkbenchHost(new FakeIndexing(), new FakeTransfers(), new StubSources(), new StubMutations()),
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
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<TimeoutException>(() =>
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<ICloudOverlay>(NullCloudOverlay.Instance);
using var sp = services.BuildServiceProvider();
var server = new WorkbenchPipeServer(
sp,
new WorkbenchIpcOptions(),
NullLogger<WorkbenchPipeServer>.Instance);
var reply = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Cloud.Places" });
Assert.True(reply.Ok);
var places = JsonSerializer.Deserialize<ProviderPlace[]>(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<WorkbenchPipeServer>.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<WorkbenchPipeServer>.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<WorkbenchPipeServer>.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<WorkbenchPipeServer>.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<IWorkbenchHost>(
new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()));
return new WorkbenchPipeServer(
services.BuildServiceProvider(),
new WorkbenchIpcOptions { PipeName = "ew-test" },
NullLogger<WorkbenchPipeServer>.Instance);
}
private sealed class FakeIndexing : IIndexingHost
{
public long FullScanId { get; private set; }

View File

@@ -11,6 +11,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
<ProjectReference Include="..\..\src\Explorer.FileOperations\Explorer.FileOperations.csproj" />
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />

View File

@@ -1,6 +1,7 @@
using Explorer.Application;
using Explorer.Domain;
using Explorer.Domain.Abstractions;
using Explorer.FileOperations;
using Explorer.Indexing;
using Explorer.Storage.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;

View File

@@ -393,6 +393,8 @@ public class FileRelationTests
RequireGitClean = true,
DoCompress = true,
ArchiveFormat = ArchiveFormat.SevenZip,
DoConvert = true,
ConversionKind = ConversionKind.HeicToJpeg,
DoCopy = false,
DoRename = true,
RenamePrefix = "x_",
@@ -410,6 +412,8 @@ public class FileRelationTests
Assert.True(loaded.RequireGitClean);
Assert.True(loaded.DoCompress);
Assert.Equal(ArchiveFormat.SevenZip, loaded.ArchiveFormat);
Assert.True(loaded.DoConvert);
Assert.Equal(ConversionKind.HeicToJpeg, loaded.ConversionKind);
Assert.True(loaded.DoRename);
Assert.Equal("x_", loaded.RenamePrefix);
Assert.Equal(".git\nbin", loaded.Excludes);
@@ -471,4 +475,16 @@ public class IndexStoreLockTests
() => reader.RunWriteAsync(_ => Task.CompletedTask));
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task IsHeld_is_true_while_a_writer_is_open()
{
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
Assert.False(IndexStoreLock.IsHeld(path));
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
await writer.OpenAsync();
Assert.True(await Task.Run(() => IndexStoreLock.IsHeld(path)));
await writer.DisposeAsync();
Assert.False(IndexStoreLock.IsHeld(path));
}
}