diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index cba70dc1..e103b01d 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,16 +1,17 @@
-## Fixes #
+## Resolves #
## PR checklist
-- [ ] Sample tests have been added/updated and pass
-- [ ] [Documentation](/docs) has been added/updated for changes
-- [ ] Code styling has been met on new source file changes
-- [ ] Contains **NO** breaking changes
+- [ ] Have Legerity sample tests been added or updated, run locally, and all pass
+- [ ] Have added or updated support for platform specific element wrappers been reflected in the Page Object Generator
+- [ ] Have code styling rules been run on all new source file changes
+- [ ] Have relevant articles in the docs been added or updated for all new source file changes
+- [ ] Have major breaking changes been made and are documented
## Other information
-
+
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b763ac5e..2229bb3f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,7 @@ on:
- samples/**
- tests/**
- build/**
+ - tools/**
- .github/workflows/ci.yml
pull_request:
branches:
@@ -20,6 +21,7 @@ on:
- samples/**
- tests/**
- build/**
+ - tools/**
- .github/workflows/ci.yml
workflow_dispatch:
@@ -52,7 +54,7 @@ jobs:
uses: NuGet/setup-nuget@v1.0.5
- name: Restore dependencies
- run: nuget restore $SOLUTION
+ run: dotnet restore $SOLUTION
- name: Build
run: dotnet build $SOLUTION --configuration $BUILD_CONFIG -p:Version=$BUILD_VERSION --no-restore
diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props
new file mode 100644
index 00000000..7944bc5d
--- /dev/null
+++ b/samples/Directory.Build.props
@@ -0,0 +1,18 @@
+
+
+
+ 1.0.0.0
+ MADE Apps
+ MADE Apps
+ Copyright (C) MADE Apps. All rights reserved.
+ en
+ true
+ latest
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 1abb5ac7..270fbcdd 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -16,7 +16,7 @@
https://github.com/MADE-Apps/legerity/releases
en
true
- 8.0
+ latest
diff --git a/src/Legerity.sln b/src/Legerity.sln
index 23d2a341..4c01cfc5 100644
--- a/src/Legerity.sln
+++ b/src/Legerity.sln
@@ -61,6 +61,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Legerity.IOS", "Legerity.IO
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Legerity.Web", "Legerity.Web\Legerity.Web.csproj", "{66469000-1C91-4CBA-A27E-043C3E222DA6}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{9064E354-7A64-4288-93E5-B3628CA12DD3}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Legerity.PageObjectGenerator", "..\tools\Legerity.PageObjectGenerator\Legerity.PageObjectGenerator.csproj", "{063E6264-F623-4469-BADF-95C8E93E3ACF}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -131,6 +135,10 @@ Global
{66469000-1C91-4CBA-A27E-043C3E222DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{66469000-1C91-4CBA-A27E-043C3E222DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{66469000-1C91-4CBA-A27E-043C3E222DA6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {063E6264-F623-4469-BADF-95C8E93E3ACF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {063E6264-F623-4469-BADF-95C8E93E3ACF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {063E6264-F623-4469-BADF-95C8E93E3ACF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {063E6264-F623-4469-BADF-95C8E93E3ACF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -154,6 +162,7 @@ Global
{EF10E4CE-62F1-41A9-807A-79A153CD7A04} = {158E9EAA-AEE0-4E0C-81E0-6E9E63341CC1}
{9D100607-5873-419C-B1AD-8DE55E464CFE} = {44456B3E-73D9-43C5-9644-430E293ECD5E}
{89BD06DC-7E77-4062-9149-EF95D3F9D999} = {1BA37704-10A3-4EDC-94AA-BF0D8CD11A9C}
+ {063E6264-F623-4469-BADF-95C8E93E3ACF} = {9064E354-7A64-4288-93E5-B3628CA12DD3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FAB58D66-B3F1-408A-A96F-572170771367}
diff --git a/tools/Directory.Build.props b/tools/Directory.Build.props
new file mode 100644
index 00000000..8b2358ab
--- /dev/null
+++ b/tools/Directory.Build.props
@@ -0,0 +1,31 @@
+
+
+
+ true
+ true
+ true
+ snupkg
+ true
+ 1.0.0.0
+ MADE Apps
+ MADE Apps
+ Copyright (C) MADE Apps. All rights reserved.
+ https://github.com/MADE-Apps/legerity
+ LICENSE
+ ProjectLogo.png
+ https://github.com/MADE-Apps/legerity/releases
+ en
+ true
+ latest
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Features/Generators/Android/AxmlPageObjectGenerator.cs b/tools/Legerity.PageObjectGenerator/Features/Generators/Android/AxmlPageObjectGenerator.cs
new file mode 100644
index 00000000..29bbe18d
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Features/Generators/Android/AxmlPageObjectGenerator.cs
@@ -0,0 +1,166 @@
+namespace Legerity.Features.Generators.Android;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Infrastructure.IO;
+using Legerity.Features.Generators;
+using Legerity.Features.Generators.Models;
+using Legerity.Infrastructure.Extensions;
+using MADE.Collections.Compare;
+using MADE.Data.Validation.Extensions;
+using Scriban;
+using Serilog;
+
+internal class AxmlPageObjectGenerator : IPageObjectGenerator
+{
+ private const string AndroidNamespace = "http://schemas.android.com/apk/res/android";
+
+ private const string BaseElementType = "AndroidElement";
+
+ private static readonly GenericEqualityComparer SimpleStringComparer = new(s => s.ToLower());
+
+ public static IEnumerable SupportedCoreAndroidElements => new List
+ {
+ "Button",
+ "CheckBox",
+ "DatePicker",
+ "EditText",
+ "RadioButton",
+ "Spinner",
+ "Switch",
+ "TextView",
+ "ToggleButton",
+ "View"
+ };
+
+ public async Task GenerateAsync(string ns, string inputPath, string outputPath)
+ {
+ IEnumerable? filePaths = GetAxmlFilePaths(inputPath)?.ToList();
+
+ if (filePaths == null || !filePaths.Any())
+ {
+ Log.Warning("No AXML files found in {InputPath}", inputPath);
+ return;
+ }
+
+ foreach (string filePath in filePaths)
+ {
+ Log.Information($"Processing {filePath}...");
+
+ await using FileStream fileStream = File.Open(filePath, FileMode.Open);
+ var axml = XDocument.Load(fileStream);
+
+ if (axml.Root != null)
+ {
+ var templateData =
+ new GeneratorTemplateData(ns, Path.GetFileNameWithoutExtension(filePath), BaseElementType);
+
+ Log.Information($"Generating template for {templateData}...");
+
+ IEnumerable elements = this.FlattenElements(axml.Root.Elements());
+ foreach (XElement element in elements)
+ {
+ string? id = RemoveAndroidIdReference(element.Attribute(XName.Get("id", AndroidNamespace))?.Value);
+ string? contentDesc = element.Attribute(XName.Get("contentDescription", AndroidNamespace))?.Value;
+
+ string? byLocatorType = GetByLocatorType(id, contentDesc);
+ if (byLocatorType == null || byLocatorType.IsNullOrWhiteSpace())
+ {
+ continue;
+ }
+
+ string? byQueryValue = id ?? contentDesc;
+ if (byQueryValue == null || byQueryValue.IsNullOrWhiteSpace())
+ {
+ continue;
+ }
+
+ var uiElement = new UiElement(
+ GetElementWrapperType(element.Name.LocalName),
+ byQueryValue.Capitalize(),
+ byLocatorType,
+ byQueryValue);
+
+ Log.Information($"Element found on page - {uiElement}");
+
+ templateData.Trait = uiElement;
+ templateData.Elements.Add(uiElement);
+ }
+
+ await GeneratePageObjectClassFileAsync(templateData, outputPath);
+ }
+ else
+ {
+ Log.Warning($"Skipping {filePath} as a page was not detected");
+ }
+ }
+ }
+
+ private static string? RemoveAndroidIdReference(string? value)
+ {
+ return value == null || string.IsNullOrWhiteSpace(value)
+ ? null
+ : value.Replace("+", string.Empty).Replace("@id/", string.Empty);
+ }
+
+ private static async Task GeneratePageObjectClassFileAsync(
+ GeneratorTemplateData templateData,
+ string outputFolder)
+ {
+ var pageObjectTemplate = Template.Parse(await EmbeddedResourceLoader.ReadAsync("Legerity.Templates.AndroidPageObject.template"));
+
+ string outputFile = $"{templateData.Page}.cs";
+
+ Log.Information($"Generating {outputFile} page object file...");
+ string result = await pageObjectTemplate.RenderAsync(templateData);
+
+ FileStream output = File.Create(Path.Combine(outputFolder, outputFile));
+ var outputWriter = new StreamWriter(output, Encoding.UTF8);
+
+ await using (outputWriter)
+ {
+ await outputWriter.WriteAsync(result);
+ }
+ }
+
+ private static string? GetByLocatorType(string? id, string? contentDesc)
+ {
+ if (id != null && !id.IsNullOrWhiteSpace())
+ {
+ return "Id";
+ }
+
+ return contentDesc != null && !contentDesc.IsNullOrWhiteSpace() ? "AndroidContentDesc" : null;
+ }
+
+ private static IEnumerable? GetAxmlFilePaths(string searchFolder)
+ {
+ string[]? filePaths = default;
+
+ try
+ {
+ filePaths = Directory.GetFiles(searchFolder, "*.axml", SearchOption.AllDirectories);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ Log.Error("An error occurred while retrieving AXML files for processing");
+ }
+
+ return filePaths;
+ }
+
+ private static string GetElementWrapperType(string elementName)
+ {
+ return SupportedCoreAndroidElements.Contains(elementName, SimpleStringComparer) ? elementName : BaseElementType;
+ }
+
+ private IEnumerable FlattenElements(IEnumerable elements)
+ {
+ return elements.SelectMany(c => this.FlattenElements(c.Elements())).Concat(elements);
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Features/Generators/IPageObjectGenerator.cs b/tools/Legerity.PageObjectGenerator/Features/Generators/IPageObjectGenerator.cs
new file mode 100644
index 00000000..4d40d973
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Features/Generators/IPageObjectGenerator.cs
@@ -0,0 +1,8 @@
+namespace Legerity.Features.Generators;
+
+using System.Threading.Tasks;
+
+internal interface IPageObjectGenerator
+{
+ Task GenerateAsync(string ns, string inputPath, string outputPath);
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Features/Generators/Models/GeneratorTemplateData.cs b/tools/Legerity.PageObjectGenerator/Features/Generators/Models/GeneratorTemplateData.cs
new file mode 100644
index 00000000..87cdbf7e
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Features/Generators/Models/GeneratorTemplateData.cs
@@ -0,0 +1,28 @@
+namespace Legerity.Features.Generators.Models;
+
+using System.Collections.Generic;
+
+internal class GeneratorTemplateData
+{
+ public GeneratorTemplateData(string ns, string page, string baseElementType)
+ {
+ this.Namespace = ns;
+ this.Page = page;
+ this.Type = baseElementType;
+ }
+
+ public string Page { get; set; }
+
+ public string Type { get; set; }
+
+ public string Namespace { get; set; }
+
+ public UiElement Trait { get; set; }
+
+ public List Elements { get; set; } = new();
+
+ public override string ToString()
+ {
+ return $"[Page] {this.Page};";
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Features/Generators/Models/UiElement.cs b/tools/Legerity.PageObjectGenerator/Features/Generators/Models/UiElement.cs
new file mode 100644
index 00000000..8feb7c6d
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Features/Generators/Models/UiElement.cs
@@ -0,0 +1,25 @@
+namespace Legerity.Features.Generators.Models;
+
+internal class UiElement
+{
+ public UiElement(string type, string name, string by, string value)
+ {
+ this.Type = type;
+ this.Name = name;
+ this.By = by;
+ this.Value = value;
+ }
+
+ public string Type { get; set; }
+
+ public string Name { get; set; }
+
+ public string By { get; set; }
+
+ public string Value { get; set; }
+
+ public override string ToString()
+ {
+ return $"[Type] {this.Type}; [Name] {this.Name}; [By] {this.By}; [Value] {this.Value};";
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Features/Generators/Windows/XamlPageObjectGenerator.cs b/tools/Legerity.PageObjectGenerator/Features/Generators/Windows/XamlPageObjectGenerator.cs
new file mode 100644
index 00000000..14c73c18
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Features/Generators/Windows/XamlPageObjectGenerator.cs
@@ -0,0 +1,183 @@
+namespace Legerity.Features.Generators.Windows;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Infrastructure.IO;
+using Legerity.Features.Generators;
+using Legerity.Features.Generators.Models;
+using Legerity.Infrastructure.Extensions;
+using MADE.Collections.Compare;
+using MADE.Data.Validation.Extensions;
+using Scriban;
+using Serilog;
+
+internal class XamlPageObjectGenerator : IPageObjectGenerator
+{
+ private const string XamlNamespace = "http://schemas.microsoft.com/winfx/2006/xaml";
+
+ private const string BaseElementType = "WindowsElement";
+
+ private static readonly GenericEqualityComparer SimpleStringComparer = new(s => s.ToLower());
+
+ public static IEnumerable SupportedCoreWindowsElements => new List
+ {
+ "AppBarButton",
+ "AppBarToggleButton",
+ "AutoSuggestBox",
+ "Button",
+ "CalendarDatePicker",
+ "CalendarView",
+ "CheckBox",
+ "ComboBox",
+ "CommandBar",
+ "DatePicker",
+ "FlipView",
+ "GridView",
+ "Hub",
+ "HyperlinkButton",
+ "InkToolbar",
+ "ListBox",
+ "ListView",
+ "MenuFlyoutItem",
+ "MenuFlyoutSubItem",
+ "PasswordBox",
+ "Pivot",
+ "ProgressBar",
+ "ProgressRing",
+ "RadioButton",
+ "Slider",
+ "TextBlock",
+ "TextBox",
+ "TimePicker",
+ "ToggleButton",
+ "ToggleSwitch"
+ };
+
+ public async Task GenerateAsync(string ns, string inputPath, string outputPath)
+ {
+ IEnumerable? filePaths = GetXamlFilePaths(inputPath)?.ToList();
+
+ if (filePaths == null || !filePaths.Any())
+ {
+ Log.Warning("No XAML files found in {InputPath}", inputPath);
+ return;
+ }
+
+ foreach (string filePath in filePaths)
+ {
+ Log.Information($"Processing {filePath}...");
+
+ await using FileStream fileStream = File.Open(filePath, FileMode.Open);
+ var xaml = XDocument.Load(fileStream);
+
+ if (xaml.Root != null && xaml.Root.Name.ToString().Contains("Page"))
+ {
+ var templateData =
+ new GeneratorTemplateData(ns, Path.GetFileNameWithoutExtension(filePath), BaseElementType);
+
+ Log.Information($"Generating template for {templateData}...");
+
+ IEnumerable elements = this.FlattenElements(xaml.Root.Elements());
+ foreach (XElement element in elements)
+ {
+ string? automationId = element.Attribute("AutomationProperties.AutomationId")?.Value;
+ string? uid = element.Attribute(XName.Get("Uid", XamlNamespace))?.Value;
+ string? name = element.Attribute(XName.Get("Name", XamlNamespace))?.Value;
+
+ string? byLocatorType = GetByLocatorType(uid, automationId, name);
+
+ if (byLocatorType == null || byLocatorType.IsNullOrWhiteSpace())
+ {
+ continue;
+ }
+
+ string? wrapperAutomationId = uid ?? automationId;
+ string? byQueryValue = wrapperAutomationId ?? name;
+
+ if (byQueryValue == null || byQueryValue.IsNullOrWhiteSpace())
+ {
+ continue;
+ }
+
+ var uiElement = new UiElement(
+ GetElementWrapperType(element.Name.LocalName),
+ byQueryValue.Capitalize(),
+ byLocatorType,
+ byQueryValue);
+
+ Log.Information($"Element found on page - {uiElement}");
+
+ templateData.Trait = uiElement;
+ templateData.Elements.Add(uiElement);
+ }
+
+ await GeneratePageObjectClassFileAsync(templateData, outputPath);
+ }
+ else
+ {
+ Log.Warning($"Skipping {filePath} as a page was not detected");
+ }
+ }
+ }
+
+ private static async Task GeneratePageObjectClassFileAsync(
+ GeneratorTemplateData templateData,
+ string outputFolder)
+ {
+ var pageObjectTemplate = Template.Parse(await EmbeddedResourceLoader.ReadAsync("Legerity.Templates.WindowsPageObject.template"));
+
+ string outputFile = $"{templateData.Page}.cs";
+
+ Log.Information($"Generating {outputFile} page object file...");
+ string result = await pageObjectTemplate.RenderAsync(templateData);
+
+ FileStream output = File.Create(Path.Combine(outputFolder, outputFile));
+ var outputWriter = new StreamWriter(output, Encoding.UTF8);
+
+ await using (outputWriter)
+ {
+ await outputWriter.WriteAsync(result);
+ }
+ }
+
+ private static string? GetByLocatorType(string? uid, string? automationId, string? name)
+ {
+ if ((uid != null && !uid.IsNullOrWhiteSpace()) || (automationId != null && !automationId.IsNullOrWhiteSpace()))
+ {
+ return "AutomationId";
+ }
+
+ return name != null && !name.IsNullOrWhiteSpace() ? "Name" : null;
+ }
+
+ private static IEnumerable? GetXamlFilePaths(string searchFolder)
+ {
+ string[]? filePaths = default;
+
+ try
+ {
+ filePaths = Directory.GetFiles(searchFolder, "*.xaml", SearchOption.AllDirectories);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ Log.Error("An error occurred while retrieving XAML files for processing");
+ }
+
+ return filePaths;
+ }
+
+ private static string GetElementWrapperType(string elementName)
+ {
+ return SupportedCoreWindowsElements.Contains(elementName, SimpleStringComparer) ? elementName : BaseElementType;
+ }
+
+ private IEnumerable FlattenElements(IEnumerable elements)
+ {
+ return elements.SelectMany(c => this.FlattenElements(c.Elements())).Concat(elements);
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Infrastructure/Configuration/Options.cs b/tools/Legerity.PageObjectGenerator/Infrastructure/Configuration/Options.cs
new file mode 100644
index 00000000..05f0bc37
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Infrastructure/Configuration/Options.cs
@@ -0,0 +1,25 @@
+namespace Legerity.Infrastructure.Configuration;
+
+using System;
+using CommandLine;
+
+internal class Options
+{
+ [Option('i', "input",
+ HelpText =
+ "The path to the folder where platform pages exist that will be generating page objects for. Default to the executing folder.")]
+ public string InputPath { get; set; } = Environment.CurrentDirectory;
+
+ [Option('o', "output",
+ HelpText =
+ "The path to the folder where the generated class files should be stored. Default to the 'Generated' folder in the executing folder.")]
+ public string OutputPath { get; set; } = System.IO.Path.Combine(Environment.CurrentDirectory, "Generated");
+
+ [Option('n', "namespace",
+ HelpText =
+ "The namespace to apply to the output page objects. Default to 'LegerityTests.Pages'.")]
+ public string Namespace { get; set; } = "LegerityTests.Pages";
+
+ [Option('p', "platform", Required = true, HelpText = "The platform that will be generating page objects for.")]
+ public PlatformType PlatformType { get; set; }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Infrastructure/Configuration/PlatformType.cs b/tools/Legerity.PageObjectGenerator/Infrastructure/Configuration/PlatformType.cs
new file mode 100644
index 00000000..c640b331
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Infrastructure/Configuration/PlatformType.cs
@@ -0,0 +1,9 @@
+namespace Legerity.Infrastructure.Configuration;
+
+internal enum PlatformType
+{
+ Windows,
+ Android,
+ IOS,
+ Web
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Infrastructure/Extensions/StringExtensions.cs b/tools/Legerity.PageObjectGenerator/Infrastructure/Extensions/StringExtensions.cs
new file mode 100644
index 00000000..30b4d0f0
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Infrastructure/Extensions/StringExtensions.cs
@@ -0,0 +1,11 @@
+namespace Legerity.Infrastructure.Extensions;
+
+using System.Linq;
+
+internal static class StringExtensions
+{
+ internal static string Capitalize(this string value)
+ {
+ return value.First().ToString().ToUpper() + value.Substring(1);
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Infrastructure/IO/EmbeddedResourceLoader.cs b/tools/Legerity.PageObjectGenerator/Infrastructure/IO/EmbeddedResourceLoader.cs
new file mode 100644
index 00000000..209ac24a
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Infrastructure/IO/EmbeddedResourceLoader.cs
@@ -0,0 +1,20 @@
+namespace Legerity.Infrastructure.IO
+{
+ using System.Reflection;
+
+ internal static class EmbeddedResourceLoader
+ {
+ internal static Task ReadAsync(string fileName)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ using Stream? stream = assembly.GetManifestResourceStream(fileName);
+ if (stream == null)
+ {
+ return Task.FromResult(default(string));
+ }
+
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEndAsync()!;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Infrastructure/Logging/SerilogConfigurator.cs b/tools/Legerity.PageObjectGenerator/Infrastructure/Logging/SerilogConfigurator.cs
new file mode 100644
index 00000000..5c804f77
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Infrastructure/Logging/SerilogConfigurator.cs
@@ -0,0 +1,20 @@
+namespace Legerity.Infrastructure.Logging;
+
+using Serilog;
+using Serilog.Events;
+using Serilog.Sinks.SystemConsole.Themes;
+
+internal static class SerilogConfigurator
+{
+ private const string LoggingMessageTemplate = "[{Level}] {Message:lj}{NewLine}";
+
+ public static void ConfigureLogging()
+ {
+ Log.Logger = new LoggerConfiguration()
+ .MinimumLevel.Debug()
+ .WriteTo.Console(outputTemplate: LoggingMessageTemplate,
+ theme: AnsiConsoleTheme.Literate,
+ restrictedToMinimumLevel: LogEventLevel.Debug)
+ .CreateLogger();
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/Legerity.PageObjectGenerator.csproj b/tools/Legerity.PageObjectGenerator/Legerity.PageObjectGenerator.csproj
new file mode 100644
index 00000000..6129c55e
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Legerity.PageObjectGenerator.csproj
@@ -0,0 +1,42 @@
+
+
+
+ true
+ Exe
+ net6.0
+ enable
+ enable
+ true
+ legerity-pop
+ Legerity Page Object Generator
+ A command line tool for auto generating page objects from Windows, Android, iOS, and web pages for UI tests using the Legerity framework
+ README.md
+ Legerity
+ PageObject Appium Selenium WindowsDriver WinAppDriver Windows UWP Web Android IOS Xamarin Uno
+
+
+
+
+
+
+
+
+
+
+ Never
+
+
+ Never
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/Legerity.PageObjectGenerator/Program.cs b/tools/Legerity.PageObjectGenerator/Program.cs
new file mode 100644
index 00000000..6302fd8b
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Program.cs
@@ -0,0 +1,69 @@
+namespace Legerity;
+
+using System.IO;
+using System.Threading.Tasks;
+using CommandLine;
+using Features.Generators.Android;
+using Features.Generators.Windows;
+using Infrastructure.Configuration;
+using Infrastructure.Logging;
+using Legerity.Features.Generators;
+using Serilog;
+
+public class Program
+{
+ public static async Task Main(string[] args)
+ {
+ SerilogConfigurator.ConfigureLogging();
+ await Parser.Default.ParseArguments(args)
+ .WithNotParsed(errors =>
+ {
+ foreach (Error error in errors)
+ {
+ if (error.Tag == ErrorType.MissingRequiredOptionError)
+ {
+ Log.Error("Missing required option: {0}", error.ToString());
+ }
+ }
+ })
+ .WithParsedAsync(async options =>
+ {
+ Log.Information($"Locating {options.PlatformType:G} page files in {options.InputPath}...");
+
+ IPageObjectGenerator? pageObjectGenerator = default;
+
+ switch (options.PlatformType)
+ {
+ case PlatformType.Windows:
+ pageObjectGenerator = new XamlPageObjectGenerator();
+ break;
+ case PlatformType.Android:
+ pageObjectGenerator = new AxmlPageObjectGenerator();
+ break;
+ case PlatformType.Web:
+ Log.Warning("Web page object generation is not currently supported!");
+ break;
+ case PlatformType.IOS:
+ Log.Warning("iOS page object generation is not currently supported!");
+ break;
+ default:
+ Log.Warning("Cannot generate Legerity page objects for an unsupported platform type!");
+ return;
+ }
+
+ if (pageObjectGenerator == default)
+ {
+ return;
+ }
+
+ if (!Directory.Exists(options.OutputPath))
+ {
+ Directory.CreateDirectory(options.OutputPath);
+ }
+
+ await pageObjectGenerator.GenerateAsync(options.Namespace, options.InputPath, options.OutputPath);
+
+ Log.Information("Finished generating Legerity page objects!");
+ });
+ }
+}
\ No newline at end of file
diff --git a/tools/Legerity.PageObjectGenerator/README.md b/tools/Legerity.PageObjectGenerator/README.md
new file mode 100644
index 00000000..b6f4a78f
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/README.md
@@ -0,0 +1,54 @@
+# Legerity page object generator CLI tool
+
+[![GitHub release](https://img.shields.io/github/release/MADE-Apps/legerity.svg)](https://github.com/MADE-Apps/legerity/releases)
+[![Build status](https://github.com/MADE-Apps/legerity/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/MADE-Apps/legerity/actions/workflows/ci.yml)
+[![Twitter Followers](https://img.shields.io/twitter/follow/jamesmcroft?label=follow%20%40jamesmcroft&style=flat)](https://twitter.com/jamesmcroft)
+[![.NET Tool](https://img.shields.io/nuget/v/Legerity.PageObjectGenerator)](https://www.nuget.org/packages/Legerity.PageObjectGenerator/)
+
+Want to generate your Legerity page objects super fast? 🚀
+
+The Legerity page object generator CLI tool allows you to auto-generate page objects for your Windows and Android application based on page files. (iOS and web coming soon!)
+
+Just provide an input path, output path, and output namespace and you're away! 🤩
+
+## Getting started
+
+### Install the tool
+
+```bash
+dotnet tool install -g Legerity.PageObjectGenerator
+```
+
+**Or update an existing install**
+
+```bash
+dotnet tool update -g Legerity.PageObjectGenerator
+```
+
+### Run the tool
+
+Once you have the tool installed, it is simply a case of running the CLI and providing the input, output, and namespace arguments.
+
+```bash
+legerity-pop -i "path/to/input/folder" -o "path/to/output/folder" -n "My.Namespace" -p "Windows"
+```
+
+This will read through all the page files that can be found under the input folder, generate a Legerity `BasePage` equivalent based on supported elements, and then drop those into your output folder. And that's it!
+
+**Platform argument supports:** Windows / Android
+
+## Contributing 🤝🏻
+
+Contributions, issues and feature requests are welcome!
+
+Feel free to check the [issues page](https://github.com/MADE-Apps/legerity/issues). You can also take a look at the [contributing guide](https://github.com/MADE-Apps/legerity/blob/main/CONTRIBUTING.md).
+
+We actively encourage you to jump in and help with any issues, and if you find one, don't forget to log it!
+
+## Support this project 💗
+
+As many developers know, projects like Legerity are built and maintained in spare time. If you find this project useful, please **Star** the repo and if possible, [sponsor the project development on GitHub](https://github.com/sponsors/jamesmcroft).
+
+## License
+
+This project is made available under the terms and conditions of the [MIT license](https://github.com/MADE-Apps/legerity/blob/main/LICENSE).
diff --git a/tools/Legerity.PageObjectGenerator/Templates/AndroidPageObject.template b/tools/Legerity.PageObjectGenerator/Templates/AndroidPageObject.template
new file mode 100644
index 00000000..3ca137ac
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Templates/AndroidPageObject.template
@@ -0,0 +1,34 @@
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Legerity.Pages;
+using Legerity.Android;
+using Legerity.Android.Elements.Core;
+using Legerity.Android.Extensions;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Appium;
+using OpenQA.Selenium.Appium.Android;
+using OpenQA.Selenium.Appium.Android.UiAutomator;
+using OpenQA.Selenium.Remote;
+
+namespace {{namespace}}
+{
+ ///
+ /// Defines a Legerity page object that represents the application {{page}}.
+ ///
+ public class {{page}} : BasePage
+ {
+ ///
+ /// Gets a given trait of the page to verify that the page is in view.
+ ///
+ protected override By Trait => {{- if string.contains trait.by "Id"}} By.Id("{{trait.value}}"); {{- else if string.contains trait.by "AndroidContentDesc"}} AndroidByExtras.ContentDescription("{{trait.value}}"); {{- end}}
+ {{~ for element in elements }}
+ ///
+ /// Gets the {{element.type}} that represents the {{element.name}} UI element of the page.
+ ///
+ public {{element.type}} {{element.name}} => App.FindElement({{- if string.contains element.by "Id"}}By.Id("{{element.value}}") {{- else if string.contains element.by "AndroidContentDesc"}}AndroidByExtras.ContentDescription("{{element.value}}") {{- end}}) as {{type}};
+ {{~ end ~}}
+ }
+}
diff --git a/tools/Legerity.PageObjectGenerator/Templates/WindowsPageObject.template b/tools/Legerity.PageObjectGenerator/Templates/WindowsPageObject.template
new file mode 100644
index 00000000..8318ef26
--- /dev/null
+++ b/tools/Legerity.PageObjectGenerator/Templates/WindowsPageObject.template
@@ -0,0 +1,33 @@
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Legerity.Pages;
+using Legerity.Windows;
+using Legerity.Windows.Elements.Core;
+using Legerity.Windows.Extensions;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Appium;
+using OpenQA.Selenium.Appium.Windows;
+using OpenQA.Selenium.Remote;
+
+namespace {{namespace}}
+{
+ ///
+ /// Defines a Legerity page object that represents the application {{page}}.
+ ///
+ public class {{page}} : BasePage
+ {
+ ///
+ /// Gets a given trait of the page to verify that the page is in view.
+ ///
+ protected override By Trait => {{- if string.contains trait.by "Name"}} By.Name("{{trait.value}}"); {{- else if string.contains trait.by "AutomationId"}} WindowsByExtras.AutomationId("{{trait.value}}"); {{- end}}
+ {{~ for element in elements }}
+ ///
+ /// Gets the {{element.type}} that represents the {{element.name}} UI element of the page.
+ ///
+ public {{element.type}} {{element.name}} => App.FindElement({{- if string.contains element.by "Name"}}By.Name("{{element.value}}") {{- else if string.contains element.by "AutomationId"}}WindowsByExtras.AutomationId("{{element.value}}") {{- end}}) as {{type}};
+ {{~ end ~}}
+ }
+}
diff --git a/tools/PageObjectSamples/Android/FormInputLayout.axml b/tools/PageObjectSamples/Android/FormInputLayout.axml
new file mode 100644
index 00000000..a7b1498d
--- /dev/null
+++ b/tools/PageObjectSamples/Android/FormInputLayout.axml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/PageObjectSamples/Windows/App.xaml b/tools/PageObjectSamples/Windows/App.xaml
new file mode 100644
index 00000000..d875286a
--- /dev/null
+++ b/tools/PageObjectSamples/Windows/App.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/tools/PageObjectSamples/Windows/MainPage.xaml b/tools/PageObjectSamples/Windows/MainPage.xaml
new file mode 100644
index 00000000..7ebfac8f
--- /dev/null
+++ b/tools/PageObjectSamples/Windows/MainPage.xaml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+ Blue
+ Green
+ Red
+ Yellow
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+