diff --git a/.gitignore b/.gitignore index e9e1c9e..53f141f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ /src/NodeDev.Core.Types.Tests/bin /src/NodeDev.Core.Types.Tests/obj /src/NodeDev.Core.Types/bin +/src/NodeDev.EndToEndTests/bin/Debug/net8.0 +/src/NodeDev.EndToEndTests/obj diff --git a/src/NodeDev.Blazor/Components/ClassExplorer.razor b/src/NodeDev.Blazor/Components/ClassExplorer.razor index 6bcd8fb..f0d27ee 100644 --- a/src/NodeDev.Blazor/Components/ClassExplorer.razor +++ b/src/NodeDev.Blazor/Components/ClassExplorer.razor @@ -1,6 +1,6 @@ @inject IDialogService DialogService - + @Class.Name @@ -19,7 +19,7 @@ { -
+
@Item.Text
@@ -32,7 +32,7 @@ { -
+
@Item.Value!.Method!.ReturnType.FriendlyName @Item.Text (@string.Join(',', Item.Value.Method!.Parameters.Select(x => $"{x.ParameterType.FriendlyName} {x.Name}")))
@@ -46,7 +46,7 @@ { -
+
@Item.Value!.Property!.PropertyType.FriendlyName @Item.Value!.Name
diff --git a/src/NodeDev.Blazor/Components/ProjectExplorer.razor b/src/NodeDev.Blazor/Components/ProjectExplorer.razor index dfff331..9795edd 100644 --- a/src/NodeDev.Blazor/Components/ProjectExplorer.razor +++ b/src/NodeDev.Blazor/Components/ProjectExplorer.razor @@ -1,98 +1,99 @@  - Project - - - - @if (context.Value == null) - { } - else if (context.Value.Type == TreeItemType.Folder) - { - - } - else if (context.Value.Type == TreeItemType.Class) - { - - } - - + Project + + + + @if (context.Value == null) + { } + else if (context.Value.Type == TreeItemType.Folder) + { + + } + else if (context.Value.Type == TreeItemType.Class) + { + + } + + @code { - private enum TreeItemType - { - Folder, - Class - } - - private record class TreeItem(string Name, TreeItemType Type, NodeDev.Core.Class.NodeClass? Class) - { - public bool IsExpanded { get; set; } = true; - } - - [Parameter] - public NodeDev.Core.Project Project { get; set; } = null!; - - [Parameter] - public NodeDev.Core.Class.NodeClass? SelectedClass { get; set; } - - [Parameter] - public EventCallback SelectedClassChanged { get; set; } - - private TreeItem? Selected = null; - - private List> Items { get; } = new(); - - private void OnSelectedItemChanged() - { - if (Selected?.Type == TreeItemType.Class) - { - SelectedClass = Selected.Class; - _ = SelectedClassChanged.InvokeAsync(SelectedClass); - } - else - { - SelectedClass = null; - _ = SelectedClassChanged.InvokeAsync(SelectedClass); - } - } - - protected override void OnInitialized() - { - base.OnInitialized(); - - foreach (var nodeClass in Project.Classes) - AddClass(nodeClass); - } - - private void AddClass(NodeDev.Core.Class.NodeClass nodeClass) - { - // find the folder that already exists in the tree - var folders = nodeClass.Namespace.Split('.'); - TreeItemData? folder = null; - for (int i = 0; i < folders.Length; ++i) - { - var parent = folder?.Children ?? Items; - folder = parent.FirstOrDefault(x => x.Value?.Name == folders[i] && x.Value?.Type == TreeItemType.Folder); - if (folder == null) - { - folder = new TreeItemData() - { - Value = new(folders[i], TreeItemType.Folder, null), - Children = [] - }; - parent.Add(folder); - } - } - - if (folder?.Children == null) - throw new Exception("Call cannot have no namespace ??"); - - folder.Children.Add(new() - { - Value = new(nodeClass.Name, TreeItemType.Class, nodeClass) - }); - } + private enum TreeItemType + { + Folder, + Class + } + + private record class TreeItem(string Name, TreeItemType Type, NodeDev.Core.Class.NodeClass? Class) + { + public bool IsExpanded { get; set; } = true; + } + + [Parameter] + public NodeDev.Core.Project Project { get; set; } = null!; + + [Parameter] + public NodeDev.Core.Class.NodeClass? SelectedClass { get; set; } + + [Parameter] + public EventCallback SelectedClassChanged { get; set; } + + private TreeItem? Selected = null; + + private List> Items { get; } = new(); + + private void OnSelectedItemChanged() + { + if (Selected?.Type == TreeItemType.Class) + { + SelectedClass = Selected.Class; + _ = SelectedClassChanged.InvokeAsync(SelectedClass); + } + else + { + SelectedClass = null; + _ = SelectedClassChanged.InvokeAsync(SelectedClass); + } + } + + protected override void OnInitialized() + { + base.OnInitialized(); + + foreach (var nodeClass in Project.Classes) + AddClass(nodeClass); + } + + private void AddClass(NodeDev.Core.Class.NodeClass nodeClass) + { + // find the folder that already exists in the tree + var folders = nodeClass.Namespace.Split('.'); + TreeItemData? folder = null; + for (int i = 0; i < folders.Length; ++i) + { + var parent = folder?.Children ?? Items; + folder = parent.FirstOrDefault(x => x.Value?.Name == folders[i] && x.Value?.Type == TreeItemType.Folder); + if (folder == null) + { + folder = new TreeItemData() + { + Value = new(folders[i], TreeItemType.Folder, null), + Children = [], + Expanded = true + }; + parent.Add(folder); + } + } + + if (folder?.Children == null) + throw new Exception("Call cannot have no namespace ??"); + + folder.Children.Add(new() + { + Value = new(nodeClass.Name, TreeItemType.Class, nodeClass) + }); + } } diff --git a/src/NodeDev.Blazor/Index.razor b/src/NodeDev.Blazor/Index.razor index 60a12f9..ab0749e 100644 --- a/src/NodeDev.Blazor/Index.razor +++ b/src/NodeDev.Blazor/Index.razor @@ -1,4 +1,6 @@ @inject Services.DebuggedPathService DebuggedPathService +@inject NavigationManager NavigationManager +@inject ISnackbar Snackbar @@ -6,8 +8,9 @@ - - Save + + New Project + Save Add node Run @(Project.IsLiveDebuggingEnabled ? "Stop Live Debugging" : "Start Live Debugging") @@ -18,7 +21,7 @@ - + @@ -164,6 +167,17 @@ { var content = Project.Serialize(); File.WriteAllText("project.json", content); + + Snackbar.Add("Project saved", Severity.Success); + } + + private void NewProject() + { + if (File.Exists("project.json")) + File.Move("project.json", "project_backup.json", true); + + + NavigationManager.Refresh(true); } private void Add() diff --git a/src/NodeDev.EndToEndTests/Features/SaveProject.feature b/src/NodeDev.EndToEndTests/Features/SaveProject.feature new file mode 100644 index 0000000..be229fc --- /dev/null +++ b/src/NodeDev.EndToEndTests/Features/SaveProject.feature @@ -0,0 +1,8 @@ +Feature: Save a project to file system + +Scenario: Save empty project + Given I load the default project + Then The 'Main' method in the 'Program' class should exist + + Given I save the current project + Then Snackbar should contain 'Project saved' \ No newline at end of file diff --git a/src/NodeDev.EndToEndTests/Features/SaveProject.feature.cs b/src/NodeDev.EndToEndTests/Features/SaveProject.feature.cs new file mode 100644 index 0000000..837ef7f --- /dev/null +++ b/src/NodeDev.EndToEndTests/Features/SaveProject.feature.cs @@ -0,0 +1,112 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by Reqnroll (https://www.reqnroll.net/). +// Reqnroll Version:2.0.0.0 +// Reqnroll Generator Version:2.0.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +namespace NodeDev.EndToEndTests.Features +{ + using Reqnroll; + using System; + using System.Linq; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("Reqnroll", "2.0.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [NUnit.Framework.TestFixtureAttribute()] + [NUnit.Framework.DescriptionAttribute("Save a project to file system")] + public partial class SaveAProjectToFileSystemFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private static string[] featureTags = ((string[])(null)); + +#line 1 "SaveProject.feature" +#line hidden + + [NUnit.Framework.OneTimeSetUpAttribute()] + public virtual async System.Threading.Tasks.Task FeatureSetupAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(); + global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "Features", "Save a project to file system", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags); + await testRunner.OnFeatureStartAsync(featureInfo); + } + + [NUnit.Framework.OneTimeTearDownAttribute()] + public virtual async System.Threading.Tasks.Task FeatureTearDownAsync() + { + await testRunner.OnFeatureEndAsync(); + global::Reqnroll.TestRunnerManager.ReleaseTestRunner(testRunner); + testRunner = null; + } + + [NUnit.Framework.SetUpAttribute()] + public async System.Threading.Tasks.Task TestInitializeAsync() + { + } + + [NUnit.Framework.TearDownAttribute()] + public async System.Threading.Tasks.Task TestTearDownAsync() + { + await testRunner.OnScenarioEndAsync(); + } + + public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(NUnit.Framework.TestContext.CurrentContext); + } + + public async System.Threading.Tasks.Task ScenarioStartAsync() + { + await testRunner.OnScenarioStartAsync(); + } + + public async System.Threading.Tasks.Task ScenarioCleanupAsync() + { + await testRunner.CollectScenarioErrorsAsync(); + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("Save empty project")] + public async System.Threading.Tasks.Task SaveEmptyProject() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Save empty project", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 3 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + await this.ScenarioStartAsync(); +#line 4 + await testRunner.GivenAsync("I load the default project", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 5 + await testRunner.ThenAsync("The \'Main\' method in the \'Program\' class should exist", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 7 + await testRunner.GivenAsync("I save the current project", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden +#line 8 + await testRunner.ThenAsync("Snackbar should contain \'Project saved\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/src/NodeDev.EndToEndTests/HelperExtensions.cs b/src/NodeDev.EndToEndTests/HelperExtensions.cs new file mode 100644 index 0000000..70e4f02 --- /dev/null +++ b/src/NodeDev.EndToEndTests/HelperExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.Playwright; + +namespace NodeDev.EndToEndTests; + +internal static class HelperExtensions +{ + public static Task WaitForVisible(this ILocator locator, WaitForSelectorState state = WaitForSelectorState.Visible) + { + return locator.WaitForAsync(new() { State = state }); + } +} diff --git a/src/NodeDev.EndToEndTests/Hooks/Hooks.cs b/src/NodeDev.EndToEndTests/Hooks/Hooks.cs new file mode 100644 index 0000000..2f54614 --- /dev/null +++ b/src/NodeDev.EndToEndTests/Hooks/Hooks.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Playwright; +using System.Diagnostics; + +namespace NodeDev.EndToEndTests.Hooks; + +[Binding] +public class Hooks +{ + public IPage User { get; private set; } = null!; //-> We'll call this property in the tests + + private static Process App; + + private const int Port = 5166; + + [BeforeFeature] + public static async Task StartServer() + { + App = Process.Start(new ProcessStartInfo() + { + CreateNoWindow = false, + FileName = "dotnet", + Arguments = "run --no-build", + WorkingDirectory = @"..\..\..\..\NodeDev.Blazor.Server", + })!; + } + + [BeforeScenario] // -> Notice how we're doing these steps before each scenario + public async Task RegisterSingleInstancePractitioner() + { + //Initialise Playwright + var playwright = await Playwright.CreateAsync(); + //Initialise a browser - 'Chromium' can be changed to 'Firefox' or 'Webkit' + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = false // -> Use this option to be able to see your test running + }); + //Setup a browser context + var context1 = await browser.NewContextAsync(); + + //Initialise a page on the browser context. + User = await context1.NewPageAsync(); + + for (int i = 0; ; ++i) + { + try + { + await User.GotoAsync($"http://localhost:{Port}"); + break; + } + catch + { + if (i == 5) + throw; + } + + await Task.Delay(100); + } + } + + + [AfterScenario] // -> Notice how we're doing these steps after each scenario + public static async Task StopServer() + { + App.Kill(true); + while (!App.HasExited) + { + await Task.Delay(100); + } + } +} diff --git a/src/NodeDev.EndToEndTests/ImplicitUsings.cs b/src/NodeDev.EndToEndTests/ImplicitUsings.cs new file mode 100644 index 0000000..faa2266 --- /dev/null +++ b/src/NodeDev.EndToEndTests/ImplicitUsings.cs @@ -0,0 +1,3 @@ +global using FluentAssertions; +global using NUnit; +global using Reqnroll; diff --git a/src/NodeDev.EndToEndTests/NodeDev.EndToEndTests.csproj b/src/NodeDev.EndToEndTests/NodeDev.EndToEndTests.csproj new file mode 100644 index 0000000..03e19a0 --- /dev/null +++ b/src/NodeDev.EndToEndTests/NodeDev.EndToEndTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/src/NodeDev.EndToEndTests/Pages/HomePage.cs b/src/NodeDev.EndToEndTests/Pages/HomePage.cs new file mode 100644 index 0000000..476edb1 --- /dev/null +++ b/src/NodeDev.EndToEndTests/Pages/HomePage.cs @@ -0,0 +1,83 @@ +using Microsoft.Playwright; + +namespace NodeDev.EndToEndTests.Pages; + +public class HomePage +{ + private readonly IPage _user; + + public HomePage(Hooks.Hooks hooks) + { + _user = hooks.User; + } + + private ILocator SearchAppBar => _user.Locator("[data-test-id='appBar']"); + private ILocator SearchNewProjectButton => SearchAppBar.Locator("[data-test-id='newProject']"); + private ILocator SearchProjectExplorer => _user.Locator("[data-test-id='projectExplorer']"); + private ILocator SearchProjectExplorerClasses => SearchProjectExplorer.Locator("[data-test-id='projectExplorerClass'] p"); + private ILocator SearchProjectExplorerTabsHeader => _user.Locator("[data-test-id='ProjectExplorerSection'] .mud-tabs-tabbar"); + private ILocator SearchClassExplorer => _user.Locator("[data-test-id='classExplorer']"); + private ILocator SearchSnackBarContainer => _user.Locator("#mud-snackbar-container"); + + public async Task CreateNewProject() + { + await SearchNewProjectButton.WaitForVisible(); + + await SearchNewProjectButton.ClickAsync(); + + await Task.Delay(100); + } + + public async Task HasClass(string name) + { + await SearchProjectExplorerClasses.GetByText(name).WaitForVisible(); + } + + public async Task ClickClass(string name) + { + await SearchProjectExplorerClasses.GetByText(name).ClickAsync(); + } + + public async Task OpenProjectExplorerProjectTab() + { + await SearchProjectExplorerTabsHeader.GetByText("PROJECT").ClickAsync(); + + await Task.Delay(100); + } + + public async Task OpenProjectExplorerClassTab() + { + await SearchProjectExplorerTabsHeader.GetByText("CLASS").ClickAsync(); + + await Task.Delay(100); + } + + public async Task FindMethodByName(string name) + { + await OpenProjectExplorerClassTab(); + + var locator = SearchClassExplorer.Locator($"[data-test-id='Method'][data-test-method='{name}']"); + return locator; + } + + public async Task HasMethodByName(string name) + { + var locator = await FindMethodByName(name); + + await locator.WaitForVisible(); + } + + public async Task SaveProject() + { + var saveBtn = SearchAppBar.Locator("[data-test-id='Save']"); + + await saveBtn.WaitForVisible(); + + await saveBtn.ClickAsync(); + } + + public async Task SnackBarHasByText(string text) + { + await SearchSnackBarContainer.GetByText(text).WaitForVisible(); + } +} \ No newline at end of file diff --git a/src/NodeDev.EndToEndTests/StepDefinitions/MainPageStepDefinitions.cs b/src/NodeDev.EndToEndTests/StepDefinitions/MainPageStepDefinitions.cs new file mode 100644 index 0000000..635aafa --- /dev/null +++ b/src/NodeDev.EndToEndTests/StepDefinitions/MainPageStepDefinitions.cs @@ -0,0 +1,49 @@ +using Microsoft.Playwright; +using NodeDev.EndToEndTests.Pages; + +namespace NodeDev.EndToEndTests.StepDefinitions; + +[Binding] +public sealed class MainPageStepDefinitions +{ + private readonly IPage User; + private readonly HomePage HomePage; + + public MainPageStepDefinitions(Hooks.Hooks hooks, HomePage homePage) + { + User = hooks.User; + HomePage = homePage; + } + + [Given("I load the default project")] + public async Task GivenILoadTheDefaultProject() + { + await HomePage.CreateNewProject(); + } + + [Then("The {string} method in the {string} class should exist")] + public async Task ThenTheMethodInTheClassShouldExist(string method, string className) + { + await HomePage.OpenProjectExplorerProjectTab(); + + await HomePage.HasClass(className); + + await HomePage.ClickClass(className); + + await HomePage.OpenProjectExplorerClassTab(); + + await HomePage.HasMethodByName(method); + } + + [Given("I save the current project")] + public async Task GivenISaveTheCurrentProject() + { + await HomePage.SaveProject(); + } + + [Then("Snackbar should contain {string}")] + public async Task ThenSnackbarShouldContain(string text) + { + await HomePage.SnackBarHasByText(text); + } +} diff --git a/src/NodeDev.sln b/src/NodeDev.sln index aa6ad21..a5bd891 100644 --- a/src/NodeDev.sln +++ b/src/NodeDev.sln @@ -29,6 +29,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Dependencies", "Dependencie EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dis2Msil", "Dis2Msil\Dis2Msil\Dis2Msil.csproj", "{9873F12D-9C5B-4756-B31C-B514664638CE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NodeDev.EndToEndTests", "NodeDev.EndToEndTests\NodeDev.EndToEndTests.csproj", "{DFA6D765-BFC3-407F-9330-B92B89310DCA}" + ProjectSection(ProjectDependencies) = postProject + {A73FFB19-1791-4E6E-829C-A6B85E1BD8F2} = {A73FFB19-1791-4E6E-829C-A6B85E1BD8F2} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -63,6 +68,10 @@ Global {9873F12D-9C5B-4756-B31C-B514664638CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {9873F12D-9C5B-4756-B31C-B514664638CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {9873F12D-9C5B-4756-B31C-B514664638CE}.Release|Any CPU.Build.0 = Release|Any CPU + {DFA6D765-BFC3-407F-9330-B92B89310DCA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFA6D765-BFC3-407F-9330-B92B89310DCA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFA6D765-BFC3-407F-9330-B92B89310DCA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFA6D765-BFC3-407F-9330-B92B89310DCA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE