OneClient test passed

This commit is contained in:
benbierens 2023-04-13 09:33:10 +02:00
parent bb81d7f037
commit f5c60f0bca
No known key found for this signature in database
GPG Key ID: FE44815D96D0A1AA
12 changed files with 379 additions and 156 deletions

View File

@ -4,13 +4,13 @@ namespace DistTestCore.Codex
{ {
public class CodexAccess public class CodexAccess
{ {
private readonly RunningContainer runningContainer;
public CodexAccess(RunningContainer runningContainer) public CodexAccess(RunningContainer runningContainer)
{ {
this.runningContainer = runningContainer; Container = runningContainer;
} }
public RunningContainer Container { get; }
public CodexDebugResponse GetDebugInfo() public CodexDebugResponse GetDebugInfo()
{ {
var response = Http().HttpGetJson<CodexDebugResponse>("debug/info"); var response = Http().HttpGetJson<CodexDebugResponse>("debug/info");
@ -18,12 +18,27 @@ namespace DistTestCore.Codex
return response; return response;
} }
public string UploadFile(FileStream fileStream)
{
return Http().HttpPostStream("upload", fileStream);
}
public Stream DownloadFile(string contentId)
{
return Http().HttpGetStream("download/" + contentId);
}
private Http Http() private Http Http()
{ {
var ip = runningContainer.Pod.Cluster.GetIp(); var ip = Container.Pod.Cluster.GetIp();
var port = runningContainer.ServicePorts[0].Number; var port = Container.ServicePorts[0].Number;
return new Http(ip, port, baseUrl: "/api/codex/v1"); return new Http(ip, port, baseUrl: "/api/codex/v1");
} }
public string ConnectToPeer(string peerId, string peerMultiAddress)
{
return Http().HttpGetString($"connect/{peerId}?addrs={peerMultiAddress}");
}
} }
public class CodexDebugResponse public class CodexDebugResponse

View File

@ -1,4 +1,6 @@
namespace DistTestCore.Codex using KubernetesWorkflow;
namespace DistTestCore.Codex
{ {
public class CodexStartupConfig public class CodexStartupConfig
{ {

View File

@ -0,0 +1,78 @@
using DistTestCore.Codex;
using KubernetesWorkflow;
using System.Collections;
namespace DistTestCore
{
public interface ICodexNodeGroup : IEnumerable<IOnlineCodexNode>
{
//ICodexSetup BringOffline();
IOnlineCodexNode this[int index] { get; }
}
public class CodexNodeGroup : ICodexNodeGroup
{
private readonly TestLifecycle lifecycle;
public CodexNodeGroup(TestLifecycle lifecycle, CodexSetup setup, RunningContainers containers)
{
this.lifecycle = lifecycle;
Setup = setup;
Containers = containers;
Nodes = containers.Containers.Select(c => CreateOnlineCodexNode(c)).ToArray();
}
public IOnlineCodexNode this[int index]
{
get
{
return Nodes[index];
}
}
//public ICodexSetup BringOffline()
//{
// //return k8SManager.BringOffline(this);
//}
public CodexSetup Setup { get; }
public RunningContainers Containers { get; }
public OnlineCodexNode[] Nodes { get; }
//public GethCompanionGroup? GethCompanionGroup { get; set; }
//public CodexNodeContainer[] GetContainers()
//{
// return Nodes.Select(n => n.Container).ToArray();
//}
public IEnumerator<IOnlineCodexNode> GetEnumerator()
{
return Nodes.Cast<IOnlineCodexNode>().GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return Nodes.GetEnumerator();
}
//public CodexNodeLog DownloadLog(IOnlineCodexNode node)
//{
// var logDownloader = new PodLogDownloader(log, k8SManager);
// var n = (OnlineCodexNode)node;
// return logDownloader.DownloadLog(n);
//}
public string Describe()
{
var orderNumber = Containers.RunningPod.Ip;
return $"CodexNodeGroup@{orderNumber}-{Setup.Describe()}";
}
private OnlineCodexNode CreateOnlineCodexNode(RunningContainer c)
{
var access = new CodexAccess(c);
return new OnlineCodexNode(lifecycle, access, this);
}
}
}

View File

@ -3,24 +3,24 @@ using KubernetesWorkflow;
namespace DistTestCore namespace DistTestCore
{ {
public interface ICodexSetupConfig public interface ICodexSetup
{ {
ICodexSetupConfig At(Location location); ICodexSetup At(Location location);
ICodexSetupConfig WithLogLevel(CodexLogLevel level); ICodexSetup WithLogLevel(CodexLogLevel level);
//ICodexStartupConfig WithBootstrapNode(IOnlineCodexNode node); //ICodexStartupConfig WithBootstrapNode(IOnlineCodexNode node);
ICodexSetupConfig WithStorageQuota(ByteSize storageQuota); ICodexSetup WithStorageQuota(ByteSize storageQuota);
ICodexSetupConfig EnableMetrics(); ICodexSetup EnableMetrics();
//ICodexSetupConfig EnableMarketplace(int initialBalance); //ICodexSetupConfig EnableMarketplace(int initialBalance);
ICodexNodeGroup BringOnline(); ICodexNodeGroup BringOnline();
} }
public class CodexSetupConfig : CodexStartupConfig, ICodexSetupConfig public class CodexSetup : CodexStartupConfig, ICodexSetup
{ {
private readonly CodexStarter starter; private readonly CodexStarter starter;
public int NumberOfNodes { get; } public int NumberOfNodes { get; }
public CodexSetupConfig(CodexStarter starter, int numberOfNodes) public CodexSetup(CodexStarter starter, int numberOfNodes)
{ {
this.starter = starter; this.starter = starter;
NumberOfNodes = numberOfNodes; NumberOfNodes = numberOfNodes;
@ -31,7 +31,7 @@ namespace DistTestCore
return starter.BringOnline(this); return starter.BringOnline(this);
} }
public ICodexSetupConfig At(Location location) public ICodexSetup At(Location location)
{ {
Location = location; Location = location;
return this; return this;
@ -43,19 +43,19 @@ namespace DistTestCore
// return this; // return this;
//} //}
public ICodexSetupConfig WithLogLevel(CodexLogLevel level) public ICodexSetup WithLogLevel(CodexLogLevel level)
{ {
LogLevel = level; LogLevel = level;
return this; return this;
} }
public ICodexSetupConfig WithStorageQuota(ByteSize storageQuota) public ICodexSetup WithStorageQuota(ByteSize storageQuota)
{ {
StorageQuota = storageQuota; StorageQuota = storageQuota;
return this; return this;
} }
public ICodexSetupConfig EnableMetrics() public ICodexSetup EnableMetrics()
{ {
MetricsEnabled = true; MetricsEnabled = true;
return this; return this;

View File

@ -1,27 +1,28 @@
using DistTestCore.Codex; using DistTestCore.Codex;
using KubernetesWorkflow; using KubernetesWorkflow;
using Logging;
namespace DistTestCore namespace DistTestCore
{ {
public class CodexStarter public class CodexStarter
{ {
private readonly WorkflowCreator workflowCreator; private readonly WorkflowCreator workflowCreator;
private readonly TestLifecycle lifecycle;
public CodexStarter(TestLog log, Configuration configuration) public CodexStarter(TestLifecycle lifecycle, Configuration configuration)
{ {
workflowCreator = new WorkflowCreator(configuration.GetK8sConfiguration()); workflowCreator = new WorkflowCreator(configuration.GetK8sConfiguration());
this.lifecycle = lifecycle;
} }
public ICodexNodeGroup BringOnline(CodexSetupConfig codexSetupConfig) public ICodexNodeGroup BringOnline(CodexSetup codexSetup)
{ {
var workflow = workflowCreator.CreateWorkflow(); var workflow = workflowCreator.CreateWorkflow();
var startupConfig = new StartupConfig(); var startupConfig = new StartupConfig();
startupConfig.Add(codexSetupConfig); startupConfig.Add(codexSetup);
var runningContainers = workflow.Start(codexSetupConfig.NumberOfNodes, codexSetupConfig.Location, new CodexContainerRecipe(), startupConfig); var runningContainers = workflow.Start(codexSetup.NumberOfNodes, codexSetup.Location, new CodexContainerRecipe(), startupConfig);
// create access objects. Easy, right? return new CodexNodeGroup(lifecycle, codexSetup, runningContainers);
} }
public void DeleteAllResources() public void DeleteAllResources()

View File

@ -61,27 +61,27 @@ namespace DistTestCore
return lifecycle.FileManager.GenerateTestFile(size); return lifecycle.FileManager.GenerateTestFile(size);
} }
public ICodexSetupConfig SetupCodexNodes(int numberOfNodes) public ICodexSetup SetupCodexNodes(int numberOfNodes)
{ {
return new CodexSetupConfig(lifecycle.CodexStarter, numberOfNodes); return new CodexSetup(lifecycle.CodexStarter, numberOfNodes);
} }
private void IncludeLogsAndMetricsOnTestFailure() private void IncludeLogsAndMetricsOnTestFailure()
{ {
var result = TestContext.CurrentContext.Result; //var result = TestContext.CurrentContext.Result;
if (result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed) //if (result.Outcome.Status == NUnit.Framework.Interfaces.TestStatus.Failed)
{ //{
if (IsDownloadingLogsAndMetricsEnabled()) // if (IsDownloadingLogsAndMetricsEnabled())
{ // {
log.Log("Downloading all CodexNode logs and metrics because of test failure..."); // log.Log("Downloading all CodexNode logs and metrics because of test failure...");
k8sManager.ForEachOnlineGroup(DownloadLogs); // k8sManager.ForEachOnlineGroup(DownloadLogs);
k8sManager.DownloadAllMetrics(); // k8sManager.DownloadAllMetrics();
} // }
else // else
{ // {
log.Log("Skipping download of all CodexNode logs and metrics due to [DontDownloadLogsAndMetricsOnFailure] attribute."); // log.Log("Skipping download of all CodexNode logs and metrics due to [DontDownloadLogsAndMetricsOnFailure] attribute.");
} // }
} //}
} }
private void Log(string msg) private void Log(string msg)
@ -101,19 +101,19 @@ namespace DistTestCore
private void DownloadLogs(CodexNodeGroup group) private void DownloadLogs(CodexNodeGroup group)
{ {
foreach (var node in group) //foreach (var node in group)
{ //{
var downloader = new PodLogDownloader(log, k8sManager); // var downloader = new PodLogDownloader(log, k8sManager);
var n = (OnlineCodexNode)node; // var n = (OnlineCodexNode)node;
downloader.DownloadLog(n); // downloader.DownloadLog(n);
} //}
} }
private bool IsDownloadingLogsAndMetricsEnabled() //private bool IsDownloadingLogsAndMetricsEnabled()
{ //{
var testProperties = TestContext.CurrentContext.Test.Properties; // var testProperties = TestContext.CurrentContext.Test.Properties;
return !testProperties.ContainsKey(PodLogDownloader.DontDownloadLogsOnFailureKey); // return !testProperties.ContainsKey(PodLogDownloader.DontDownloadLogsOnFailureKey);
} //}
} }
public static class GlobalTestFailure public static class GlobalTestFailure

View File

@ -1,6 +1,7 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using NUnit.Framework; using NUnit.Framework;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using Utils;
namespace DistTestCore namespace DistTestCore
{ {
@ -26,8 +27,8 @@ namespace DistTestCore
{ {
using var client = GetClient(); using var client = GetClient();
var url = GetUrl() + route; var url = GetUrl() + route;
var result = Utils.Wait(client.GetAsync(url)); var result = Time.Wait(client.GetAsync(url));
return Utils.Wait(result.Content.ReadAsStringAsync()); return Time.Wait(result.Content.ReadAsStringAsync());
}); });
} }
@ -45,9 +46,9 @@ namespace DistTestCore
var content = new StreamContent(stream); var content = new StreamContent(stream);
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
var response = Utils.Wait(client.PostAsync(url, content)); var response = Time.Wait(client.PostAsync(url, content));
return Utils.Wait(response.Content.ReadAsStringAsync()); return Time.Wait(response.Content.ReadAsStringAsync());
}); });
} }
@ -58,7 +59,7 @@ namespace DistTestCore
var client = GetClient(); var client = GetClient();
var url = GetUrl() + route; var url = GetUrl() + route;
return Utils.Wait(client.GetStreamAsync(url)); return Time.Wait(client.GetStreamAsync(url));
}); });
} }

View File

@ -0,0 +1,126 @@
using DistTestCore.Codex;
using NUnit.Framework;
namespace DistTestCore
{
public interface IOnlineCodexNode
{
CodexDebugResponse GetDebugInfo();
ContentId UploadFile(TestFile file);
TestFile? DownloadContent(ContentId contentId);
void ConnectToPeer(IOnlineCodexNode node);
//ICodexNodeLog DownloadLog();
//IMetricsAccess Metrics { get; }
//IMarketplaceAccess Marketplace { get; }
}
public class OnlineCodexNode : IOnlineCodexNode
{
private const string SuccessfullyConnectedMessage = "Successfully connected to peer";
private const string UploadFailedMessage = "Unable to store block";
private readonly TestLifecycle lifecycle;
public OnlineCodexNode(TestLifecycle lifecycle, CodexAccess codexAccess, CodexNodeGroup group)
{
this.lifecycle = lifecycle;
CodexAccess = codexAccess;
Group = group;
}
public CodexAccess CodexAccess { get; }
public CodexNodeGroup Group { get; }
public string GetName()
{
return $"<{CodexAccess.Container.Recipe.Name}>";
}
public CodexDebugResponse GetDebugInfo()
{
var response = CodexAccess.GetDebugInfo();
Log($"Got DebugInfo with id: '{response.id}'.");
return response;
}
public ContentId UploadFile(TestFile file)
{
Log($"Uploading file of size {file.GetFileSize()}...");
using var fileStream = File.OpenRead(file.Filename);
var response = CodexAccess.UploadFile(fileStream);
if (response.StartsWith(UploadFailedMessage))
{
Assert.Fail("Node failed to store block.");
}
Log($"Uploaded file. Received contentId: '{response}'.");
return new ContentId(response);
}
public TestFile? DownloadContent(ContentId contentId)
{
Log($"Downloading for contentId: '{contentId.Id}'...");
var file = lifecycle.FileManager.CreateEmptyTestFile();
DownloadToFile(contentId.Id, file);
Log($"Downloaded file of size {file.GetFileSize()} to '{file.Filename}'.");
return file;
}
public void ConnectToPeer(IOnlineCodexNode node)
{
var peer = (OnlineCodexNode)node;
Log($"Connecting to peer {peer.GetName()}...");
var peerInfo = node.GetDebugInfo();
var response = CodexAccess.ConnectToPeer(peerInfo.id, GetPeerMultiAddress(peer, peerInfo));
Assert.That(response, Is.EqualTo(SuccessfullyConnectedMessage), "Unable to connect codex nodes.");
Log($"Successfully connected to peer {peer.GetName()}.");
}
//public ICodexNodeLog DownloadLog()
//{
// return Group.DownloadLog(this);
//}
public string Describe()
{
return $"{Group.Describe()} contains {GetName()}";
}
private string GetPeerMultiAddress(OnlineCodexNode peer, CodexDebugResponse peerInfo)
{
var multiAddress = peerInfo.addrs.First();
// Todo: Is there a case where First address in list is not the way?
if (Group == peer.Group)
{
return multiAddress;
}
// The peer we want to connect is in a different pod.
// We must replace the default IP with the pod IP in the multiAddress.
return multiAddress.Replace("0.0.0.0", peer.Group.Containers.RunningPod.Ip);
}
private void DownloadToFile(string contentId, TestFile file)
{
using var fileStream = File.OpenWrite(file.Filename);
using var downloadStream = CodexAccess.DownloadFile(contentId);
downloadStream.CopyTo(fileStream);
}
private void Log(string msg)
{
lifecycle.Log.Log($"{GetName()}: {msg}");
}
}
public class ContentId
{
public ContentId(string id)
{
Id = id;
}
public string Id { get; }
}
}

View File

@ -8,7 +8,7 @@ namespace DistTestCore
{ {
Log = new TestLog(configuration.GetLogConfig()); Log = new TestLog(configuration.GetLogConfig());
FileManager = new FileManager(Log, configuration); FileManager = new FileManager(Log, configuration);
CodexStarter = new CodexStarter(Log, configuration); CodexStarter = new CodexStarter(this, configuration);
} }
public TestLog Log { get; } public TestLog Log { get; }

View File

@ -187,7 +187,7 @@ namespace KubernetesWorkflow
private string GetNameForPort(ContainerRecipe recipe, Port port) private string GetNameForPort(ContainerRecipe recipe, Port port)
{ {
return $"P{workflowNumberSource.WorkflowNumber}-{recipe.Number}-{port.Number}"; return $"p{workflowNumberSource.WorkflowNumber}-{recipe.Number}-{port.Number}";
} }
#endregion #endregion

View File

@ -1,4 +1,4 @@
using CodexDistTestCore; using DistTestCore;
using NUnit.Framework; using NUnit.Framework;
namespace Tests.BasicTests namespace Tests.BasicTests
@ -6,68 +6,6 @@ namespace Tests.BasicTests
[TestFixture] [TestFixture]
public class SimpleTests : DistTest public class SimpleTests : DistTest
{ {
[Test]
public void TwoMetricsExample()
{
var group = SetupCodexNodes(2)
.EnableMetrics()
.BringOnline();
var group2 = SetupCodexNodes(2)
.EnableMetrics()
.BringOnline();
var primary = group[0];
var secondary = group[1];
var primary2 = group2[0];
var secondary2 = group2[1];
primary.ConnectToPeer(secondary);
primary2.ConnectToPeer(secondary2);
Thread.Sleep(TimeSpan.FromMinutes(5));
primary.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1));
primary2.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1));
}
[Test]
public void MarketplaceExample()
{
var group = SetupCodexNodes(4)
.WithStorageQuota(10.GB())
.EnableMarketplace(initialBalance: 20)
.BringOnline();
foreach (var node in group)
{
Assert.That(node.Marketplace.GetBalance(), Is.EqualTo(20));
}
// WIP: Balance is now only ETH.
// todo: All nodes should have plenty of ETH to pay for transactions.
// todo: Upload our own token, use this exclusively. ETH should be invisibile to the tests.
//var secondary = SetupCodexNodes(1)
// .EnableMarketplace(initialBalance: 1000)
// .BringOnline()[0];
//primary.ConnectToPeer(secondary);
//primary.Marketplace.MakeStorageAvailable(10.GB(), minPricePerBytePerSecond: 1, maxCollateral: 20);
//var testFile = GenerateTestFile(10.MB());
//var contentId = secondary.UploadFile(testFile);
//secondary.Marketplace.RequestStorage(contentId, pricePerBytePerSecond: 2,
// requiredCollateral: 10, minRequiredNumberOfNodes: 1);
//primary.Marketplace.AssertThatBalance(Is.LessThan(20), "Collateral was not placed.");
//var primaryBalance = primary.Marketplace.GetBalance();
//secondary.Marketplace.AssertThatBalance(Is.LessThan(1000), "Contractor was not charged for storage.");
//primary.Marketplace.AssertThatBalance(Is.GreaterThan(primaryBalance), "Storer was not paid for storage.");
}
[Test] [Test]
public void OneClientTest() public void OneClientTest()
{ {
@ -82,53 +20,115 @@ namespace Tests.BasicTests
testFile.AssertIsEqual(downloadedFile); testFile.AssertIsEqual(downloadedFile);
} }
[Test] //[Test]
public void TwoClientsOnePodTest() //public void TwoClientsOnePodTest()
{ //{
var group = SetupCodexNodes(2).BringOnline(); // var group = SetupCodexNodes(2).BringOnline();
var primary = group[0]; // var primary = group[0];
var secondary = group[1]; // var secondary = group[1];
PerformTwoClientTest(primary, secondary); // PerformTwoClientTest(primary, secondary);
} //}
[Test] //[Test]
public void TwoClientsTwoPodsTest() //public void TwoClientsTwoPodsTest()
{ //{
var primary = SetupCodexNodes(1).BringOnline()[0]; // var primary = SetupCodexNodes(1).BringOnline()[0];
var secondary = SetupCodexNodes(1).BringOnline()[0]; // var secondary = SetupCodexNodes(1).BringOnline()[0];
PerformTwoClientTest(primary, secondary); // PerformTwoClientTest(primary, secondary);
} //}
[Test] //[Test]
[Ignore("Requires Location map to be configured for k8s cluster.")] //[Ignore("Requires Location map to be configured for k8s cluster.")]
public void TwoClientsTwoLocationsTest() //public void TwoClientsTwoLocationsTest()
{ //{
var primary = SetupCodexNodes(1) // var primary = SetupCodexNodes(1)
.At(Location.BensLaptop) // .At(Location.BensLaptop)
.BringOnline()[0]; // .BringOnline()[0];
var secondary = SetupCodexNodes(1) // var secondary = SetupCodexNodes(1)
.At(Location.BensOldGamingMachine) // .At(Location.BensOldGamingMachine)
.BringOnline()[0]; // .BringOnline()[0];
PerformTwoClientTest(primary, secondary); // PerformTwoClientTest(primary, secondary);
} //}
private void PerformTwoClientTest(IOnlineCodexNode primary, IOnlineCodexNode secondary) //[Test]
{ //public void TwoMetricsExample()
primary.ConnectToPeer(secondary); //{
// var group = SetupCodexNodes(2)
// .EnableMetrics()
// .BringOnline();
var testFile = GenerateTestFile(1.MB()); // var group2 = SetupCodexNodes(2)
// .EnableMetrics()
// .BringOnline();
var contentId = primary.UploadFile(testFile); // var primary = group[0];
// var secondary = group[1];
// var primary2 = group2[0];
// var secondary2 = group2[1];
var downloadedFile = secondary.DownloadContent(contentId); // primary.ConnectToPeer(secondary);
// primary2.ConnectToPeer(secondary2);
testFile.AssertIsEqual(downloadedFile); // Thread.Sleep(TimeSpan.FromMinutes(5));
}
// primary.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1));
// primary2.Metrics.AssertThat("libp2p_peers", Is.EqualTo(1));
//}
//[Test]
//public void MarketplaceExample()
//{
// var group = SetupCodexNodes(4)
// .WithStorageQuota(10.GB())
// .EnableMarketplace(initialBalance: 20)
// .BringOnline();
// foreach (var node in group)
// {
// Assert.That(node.Marketplace.GetBalance(), Is.EqualTo(20));
// }
// // WIP: Balance is now only ETH.
// // todo: All nodes should have plenty of ETH to pay for transactions.
// // todo: Upload our own token, use this exclusively. ETH should be invisibile to the tests.
// //var secondary = SetupCodexNodes(1)
// // .EnableMarketplace(initialBalance: 1000)
// // .BringOnline()[0];
// //primary.ConnectToPeer(secondary);
// //primary.Marketplace.MakeStorageAvailable(10.GB(), minPricePerBytePerSecond: 1, maxCollateral: 20);
// //var testFile = GenerateTestFile(10.MB());
// //var contentId = secondary.UploadFile(testFile);
// //secondary.Marketplace.RequestStorage(contentId, pricePerBytePerSecond: 2,
// // requiredCollateral: 10, minRequiredNumberOfNodes: 1);
// //primary.Marketplace.AssertThatBalance(Is.LessThan(20), "Collateral was not placed.");
// //var primaryBalance = primary.Marketplace.GetBalance();
// //secondary.Marketplace.AssertThatBalance(Is.LessThan(1000), "Contractor was not charged for storage.");
// //primary.Marketplace.AssertThatBalance(Is.GreaterThan(primaryBalance), "Storer was not paid for storage.");
//}
//private void PerformTwoClientTest(IOnlineCodexNode primary, IOnlineCodexNode secondary)
//{
// primary.ConnectToPeer(secondary);
// var testFile = GenerateTestFile(1.MB());
// var contentId = primary.UploadFile(testFile);
// var downloadedFile = secondary.DownloadContent(contentId);
// testFile.AssertIsEqual(downloadedFile);
//}
} }
} }

View File

@ -13,7 +13,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CodexDistTestCore\CodexDistTestCore.csproj" /> <ProjectReference Include="..\DistTestCore\DistTestCore.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>