Skip to content
Draft
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
16 changes: 16 additions & 0 deletions AuthoringTests.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,22 @@ Mac:

## How to Write a Functional Test

### White-box Functional Tests

White-box functional tests exercise product APIs with real operating-system dependencies, such as NTFS handles or a ProjFS provider.
They belong in `GVFS.FunctionalTests`, not the unit-test project.
Keep Windows-specific fixtures under `Windows\Tests\WhiteBox` in the `GVFS.FunctionalTests.Tests.WhiteBox` namespace.
Mark each fixture with `[Category(Categories.WhiteBox)]` and document it as a white-box test in its class summary.

Each fixture owns its temporary filesystem roots and releases its native resources.
Do not mix white-box cases into black-box fixtures.
Keep black-box tests independent of product-code APIs.

For provider notifications, run client operations in a separate test process.
The backup fixture uses a private test-runner mode and a live-file control to verify notification delivery.

### Black-box Functional Tests

Each piece of functionality that we add to VFS for Git should have corresponding functional tests that clone a repo, mount the filesystem, and use existing tools and filesystem
APIs to interact with the virtual repo.

Expand Down
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,10 +279,14 @@ The design review process is as follows:

Comprehensive tests are essential for maintaining the health and quality of the product. For more details on writing tests see [Authoring Tests](https://github.com/Microsoft/VFSForGit/blob/master/AuthoringTests.md).

- *Functional tests are black-box tests and should not build against any VFS4G product code*
- *Keep black-box functional tests independent of VFS4G product code*

Keeping the code separate helps ensure that bugs in the product code do not compromise the integrity of the functional tests.

White-box functional tests can reference product code when they need native filesystem or provider integration.
Keep them in separate fixtures under `Tests.WhiteBox` and mark them with `Categories.WhiteBox`.
See [White-box Functional Tests](AuthoringTests.md#white-box-functional-tests) for their scope and location.

### C# Unit Tests

- *Add `ExceptionExpected` to unit tests that are expected to have exceptions*
Expand Down
1 change: 1 addition & 0 deletions GVFS/GVFS.FunctionalTests/Categories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public static class Categories
public const string FastFetch = "FastFetch";
public const string GitCommands = "GitCommands";
public const string SkipInCI = "SkipInCI";
public const string WhiteBox = "WhiteBox";
}
}
3 changes: 3 additions & 0 deletions GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
<ItemGroup>
<PackageReference Include="NUnitLite" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Microsoft.Windows.ProjFS" />
<PackageReference Include="Moq" />
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="SharpZipLib" />
<PackageReference Include="System.ServiceProcess.ServiceController" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\GVFS.Tests\GVFS.Tests.csproj" />
<ProjectReference Include="..\GVFS.Platform.Windows\GVFS.Platform.Windows.csproj" />
<ProjectReference Include="..\FastFetch\FastFetch.csproj" />
<ProjectReference Include="..\GVFS.FunctionalTests.LockHolder\GVFS.FunctionalTests.LockHolder.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
Expand Down
6 changes: 6 additions & 0 deletions GVFS/GVFS.FunctionalTests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ public class Program
{
public static void Main(string[] args)
{
if (args.Length > 0 && args[0] == Tests.WhiteBox.DehydrateBackupProcess.Switch)
{
Environment.ExitCode = Tests.WhiteBox.DehydrateBackupProcess.Run(args);
return;
}

Properties.Settings.Default.Initialize();
GVFSPlatformLoader.Initialize();

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using GVFS.Common.Tracing;
using GVFS.FunctionalTests.Tools;
using GVFS.Platform.Windows;
using Microsoft.Win32.SafeHandles;
using NUnit.Framework;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;

namespace GVFS.FunctionalTests.Tests.WhiteBox
{
/// <summary>
/// Runs client-side test operations separately from the ProjFS provider process.
/// </summary>
internal static class DehydrateBackupProcess
{
public const string Switch = "--whitebox-dehydrate-backup";
public const string NotificationControl = "notification-control.bin";
public static readonly byte[] Content = Enumerable.Repeat((byte)'x', 4096).ToArray();
private const int FileDispositionInfoEx = 21;
private const uint DispositionDelete = 1;

public static int Run(string[] args)
{
if (args.Length != 4 || (args[1] != "control" && args[1] != "moved-control" && args[1] != "delete"))
{
throw new ArgumentException("Specify the white-box operation, fixture root, and backup name.");
}

string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(args[2]));
string name = Path.GetFileName(root);
string prefix = nameof(DehydrateBackupDeletionTests);
if (!string.Equals(Path.GetDirectoryName(root), Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())), StringComparison.OrdinalIgnoreCase) ||
!name.StartsWith(prefix, StringComparison.Ordinal) ||
!Guid.TryParseExact(name.AsSpan(prefix.Length), "N", out _))
{
throw new ArgumentException("The white-box helper requires a disposable dehydrate-backup test fixture.");
}

if (args[1] == "control")
{
DeleteControlFile(Path.Combine(root, "src", NotificationControl));
return 0;
}

if (args[1] == "moved-control")
{
using (WindowsDehydrateBackup scope = WindowsDehydrateBackup.Open(NullTracer.Instance, root, args[3]))
{
DeleteControlFile(Path.Combine(root, "dehydrate_backup", args[3], "tree", "cached", NotificationControl));
}

return 0;
}

int liveReads = 0;
using (WindowsDehydrateBackup scope = WindowsDehydrateBackup.Open(NullTracer.Instance, root, args[3]))
{
WindowsDehydrateBackup.DeletionResult result = scope.Delete(progress: _ =>
{
byte[] data = File.ReadAllBytes(Path.Combine(root, "src", $"health-{liveReads}.bin"));
if (!data.SequenceEqual(Content))
{
throw new IOException("The live provider returned unexpected contents during backup deletion.");
}

++liveReads;
});
Console.WriteLine(JsonSerializer.Serialize(new Summary
{
FilesDeleted = result.FilesDeleted,
DirectoriesDeleted = result.DirectoriesDeleted,
LiveReads = liveReads,
Failures = result.FailureSamples.Select(failure => failure.Path + ": " + failure.Exception).ToArray(),
}));
return result.Succeeded ? 0 : 1;
}
}

public static Summary Delete(string root, string backupName)
{
ProcessResult result = Start("delete", root, backupName);
return JsonSerializer.Deserialize<Summary>(result.Output)
?? throw new IOException("The backup test process returned no deletion summary.");
}

public static void DeleteControl(string root)
{
Start("control", root, string.Empty);
}

public static void DeleteMovedControl(string root, string backupName)
{
Start("moved-control", root, backupName);
}

private static void DeleteControlFile(string path)
{
// Follow normal client opens, then verify the pinned target before arming deletion.
using (SafeFileHandle handle = CreateFileW(
@"\\?\" + path,
(uint)(GVFS.Common.NativeMethods.FileAccess.DELETE | GVFS.Common.NativeMethods.FileAccess.FILE_READ_ATTRIBUTES),
FileShare.Read, IntPtr.Zero, FileMode.Open, 0, IntPtr.Zero))
{
if (handle.IsInvalid)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open the notification control.");
}

StringBuilder finalPath = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, finalPath, (uint)finalPath.Capacity, 0);
if (length == 0)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to resolve the notification control.");
}

if (length >= finalPath.Capacity ||
!string.Equals(finalPath.ToString(), @"\\?\" + path, StringComparison.OrdinalIgnoreCase))
{
throw new IOException("The notification control resolves outside the disposable fixture path.");
}

uint disposition = DispositionDelete;
if (!SetFileInformationByHandle(handle, FileDispositionInfoEx, ref disposition, sizeof(uint)))
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to delete the notification control.");
}
}
}

private static ProcessResult Start(string operation, string root, string backupName)
{
ProcessStartInfo info = new ProcessStartInfo
{
FileName = Path.Combine(TestContext.CurrentContext.TestDirectory, "GVFS.FunctionalTests.exe"),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
info.ArgumentList.Add(Switch);
info.ArgumentList.Add(operation);
info.ArgumentList.Add(root);
info.ArgumentList.Add(backupName);
ProcessResult result = ProcessHelper.Run(info, timeoutMs: 30_000);
Assert.That(result.ExitCode, Is.Zero, result.Errors + Environment.NewLine + result.Output);
return result;
}

internal sealed class Summary
{
public long FilesDeleted { get; set; }
public long DirectoriesDeleted { get; set; }
public int LiveReads { get; set; }
public string[] Failures { get; set; }
}

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string path, uint access, FileShare share, IntPtr security, FileMode mode, uint flags, IntPtr template);

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder path, uint size, uint flags);

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetFileInformationByHandle(SafeFileHandle handle, int informationClass, ref uint flags, uint size);
}
}
Loading
Loading