diff --git a/Directory.Build.props b/Directory.Build.props index 8705d91..8b9e4b4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,6 +8,7 @@ + diff --git a/README.md b/README.md index 2c4dfbd..ee09507 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,58 @@ While the typical recommended usage of TickSpec is to keep the step definitions See the `CustomContainer` example project for usage examples - the example demonstrates wiring of Autofac including usage of lifetime scopes per scenario and usage of the [xUnit 2+ Shared Fixtures](https://xunit.github.io/docs/shared-context.html) to correctly manage the sharing/ifetime of the container where one runs xUnit Test Classes in parallel as part of a large test suite. +# Build integration + +TickSpec.Build automatically integrates into the build process as "BeforeCompile" target and generates the "wiring" needed to execute +the BDD scenarios. Currently only NUnit is supported as test framework. + +## HTML documentation + +TickSpec.Build additionally supports generating HTML documents for the feature files + +```bash +TickSpec.Build doc ./src ./html +``` + +When generating the HTML files to the output location the F# project local folders are preserved. + +Using ``--toc html`` a HTML table of contents and with ``--toc json`` a Json table of contents can be generated. + +### Styling + +The generated HTML documents intentionally only contain HTML fragments of type "article" so that those +articles can easily be integrated in an existing HTML documentation. + +These articles provide the following CSS classes for styling: + +- **gherkin-keyword** applies to the keywords like GIVEN, WHEN, THEN +- **gherkin-scenario-body** applies to the body of a scenario +- **gherkin-scenario** applies to a complete scenario +- **gherkin-tags** applies to the tags attached to scenarios +- **gherkin-description** applies to a comment provided above a scenario +- **gherkin-scenario-title** applies to the title of a scenario +- **gherkin-feature-title** applies to the feature title + +If you want to use the generated articles as a standalone documentation use ``--toc html`` to generate a +standalone HTML document. Put a ``style.css`` next to the ``ToC.html`` to define the CSS classes listed above + +### MsBuild integration + +To integrate the HTML generation into your MsBuild based build process set the property ``FeatureFileHtmlOutput`` +to the location the HTML files should be generated too. By default, only the feature files local to this project +are considered. You can change this by setting the property ``FeatureFileHtmlInput``. + +The format of the table of contents can be set using property ``TickSpecBuildTocFormat``. + + +## Story behind this project + +The following articles tell the story behind this project: + +- [Lean BDD and Code Generation](http://www.plainionist.net/TickSpec-with-Code-Generation/) +- [Lean BDD with even more Code Generation](http://www.plainionist.net/TickSpec-More-CodeGen/) + + # Contributing Contributions are welcome, particularly examples and documentation. If you'd like to chat about TickSpec, please use the [the gitter channel](https://gitter.im/fsprojects/TickSpec). diff --git a/TickSpec.sln b/TickSpec.sln index 8ae8841..43de646 100644 --- a/TickSpec.sln +++ b/TickSpec.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27130.2027 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32901.215 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{CD7A2701-42ED-47BD-8724-1E130B5C5071}" EndProject @@ -17,47 +17,53 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CommandLine", "CommandLine" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CSharp", "Examples\ByFramework\CommandLine\CSharp\CSharp.csproj", "{FBB69434-1AC8-46B9-8533-81F09B69B134}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FSharp", "Examples\ByFramework\CommandLine\FSharp\FSharp.fsproj", "{9A6D191D-73B3-48D9-A0C1-51581E8894A8}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FSharp", "Examples\ByFramework\CommandLine\FSharp\FSharp.fsproj", "{9A6D191D-73B3-48D9-A0C1-51581E8894A8}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TicTacToe", "Examples\ByFramework\CommandLine\TicTacToe\TicTacToe.fsproj", "{3D58E3C3-1A7C-426D-BC9F-555735EA622E}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TicTacToe", "Examples\ByFramework\CommandLine\TicTacToe\TicTacToe.fsproj", "{3D58E3C3-1A7C-426D-BC9F-555735EA622E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NUnit.CSharp", "Examples\ByFramework\NUnit\CSharp.NUnit\NUnit.CSharp.csproj", "{B224A186-190C-43D8-B83C-3E2F0FD7BE39}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NUnit.FSharp", "Examples\ByFramework\NUnit\FSharp.NUnit\NUnit.FSharp.fsproj", "{5DFF73BD-8C5D-4EE2-B5DD-2D244EC06A61}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "NUnit.FSharp", "Examples\ByFramework\NUnit\FSharp.NUnit\NUnit.FSharp.fsproj", "{5DFF73BD-8C5D-4EE2-B5DD-2D244EC06A61}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Interactive", "Examples\ByStyle\Interactive\Interactive.fsproj", "{BA6F3A69-34F3-4D85-8804-DD354F8E17FA}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Interactive", "Examples\ByStyle\Interactive\Interactive.fsproj", "{BA6F3A69-34F3-4D85-8804-DD354F8E17FA}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Attributes", "Examples\ByStyle\Attributes\Attributes.fsproj", "{B3AA7D1B-E83A-4C9B-97CF-12293AF46E10}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Attributes", "Examples\ByStyle\Attributes\Attributes.fsproj", "{B3AA7D1B-E83A-4C9B-97CF-12293AF46E10}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Functional", "Examples\ByStyle\Functional\Functional.fsproj", "{1D2CA398-6F02-41B1-BAA5-E63DC15508A4}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Functional", "Examples\ByStyle\Functional\Functional.fsproj", "{1D2CA398-6F02-41B1-BAA5-E63DC15508A4}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Xunit.FSharp", "Examples\ByFramework\xUnit\FSharp.xUnit\Xunit.FSharp.fsproj", "{1207F9C8-3B84-4939-B6E2-DB96D06B73E2}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Xunit.FSharp", "Examples\ByFramework\xUnit\FSharp.xUnit\Xunit.FSharp.fsproj", "{1207F9C8-3B84-4939-B6E2-DB96D06B73E2}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ByFeature", "ByFeature", "{EB1F6262-0913-4464-A034-4F4D01268E83}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TickSpec", "TickSpec\TickSpec.fsproj", "{DC4A4A1D-CCC5-42E7-8EE6-4F015E8A2E3E}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec", "TickSpec\TickSpec.fsproj", "{DC4A4A1D-CCC5-42E7-8EE6-4F015E8A2E3E}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "CustomContainer", "Examples\ByFeature\CustomContainer\CustomContainer.fsproj", "{4F45F8E2-F9FD-4C80-B4E0-A38B17A791D2}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "CustomContainer", "Examples\ByFeature\CustomContainer\CustomContainer.fsproj", "{4F45F8E2-F9FD-4C80-B4E0-A38B17A791D2}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DependencyInjection", "Examples\ByFeature\DependencyInjection\DependencyInjection.fsproj", "{6DD7F109-6798-4CAF-BD13-7BA4689BD892}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "DependencyInjection", "Examples\ByFeature\DependencyInjection\DependencyInjection.fsproj", "{6DD7F109-6798-4CAF-BD13-7BA4689BD892}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FunctionalInjection", "Examples\ByFeature\FunctionalInjection\FunctionalInjection.fsproj", "{1CE6B475-C94F-438E-97D4-5E99FD0F04D4}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "FunctionalInjection", "Examples\ByFeature\FunctionalInjection\FunctionalInjection.fsproj", "{1CE6B475-C94F-438E-97D4-5E99FD0F04D4}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TickSpec.Tests", "TickSpec.Tests\TickSpec.Tests.fsproj", "{CD2AA807-5E4F-4A2C-80F7-A7D7CA6B3C2D}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.Tests", "TickSpec.Tests\TickSpec.Tests.fsproj", "{CD2AA807-5E4F-4A2C-80F7-A7D7CA6B3C2D}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TaggedExamples", "Examples\ByFeature\TaggedExamples\TaggedExamples.fsproj", "{39C63F8E-F3A5-48D8-851C-62BEB9C701C2}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TaggedExamples", "Examples\ByFeature\TaggedExamples\TaggedExamples.fsproj", "{39C63F8E-F3A5-48D8-851C-62BEB9C701C2}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MSTest", "MSTest", "{694A8BD0-05B1-4000-BDD2-67CC4B3B8D90}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MSTest.FSharp", "Examples\ByFramework\MSTest\MSTest.FSharp\MSTest.FSharp.fsproj", "{6EAC46DB-177A-4482-970A-D43124DB2FB9}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MSTest.FSharp", "Examples\ByFramework\MSTest\MSTest.FSharp\MSTest.FSharp.fsproj", "{6EAC46DB-177A-4482-970A-D43124DB2FB9}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Expecto", "Expecto", "{750A854A-FB45-4E51-94DF-D79B4F1E46DC}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Expecto.FSharp", "Examples\ByFramework\Expecto\FSharp.Expecto\Expecto.FSharp.fsproj", "{F9B139E4-0160-4150-B420-73DCCD57B6B8}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Expecto.FSharp", "Examples\ByFramework\Expecto\FSharp.Expecto\Expecto.FSharp.fsproj", "{F9B139E4-0160-4150-B420-73DCCD57B6B8}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Wiring", "Wiring", "{373B654A-3A88-48CC-A0A3-0454514E61FF}" EndProject -Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TickSpec.Xunit", "Wiring\TickSpec.Xunit\TickSpec.Xunit.fsproj", "{DDB39875-AA8B-494E-B923-C8143930464F}" +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.Xunit", "Wiring\TickSpec.Xunit\TickSpec.Xunit.fsproj", "{DDB39875-AA8B-494E-B923-C8143930464F}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSepc.Build.Tests", "Wiring\TickSepc.Build.Tests\TickSepc.Build.Tests.fsproj", "{8FE8AF85-C728-4DD7-BD16-3520707F97A1}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.Build", "Wiring\TickSpec.Build\TickSpec.Build.fsproj", "{F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}" +EndProject +Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "TickSpec.CodeGen.NUnit", "Wiring\TickSpec.CodeGen.NUnit\TickSpec.CodeGen.NUnit.fsproj", "{3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -137,6 +143,18 @@ Global {DDB39875-AA8B-494E-B923-C8143930464F}.Debug|Any CPU.Build.0 = Debug|Any CPU {DDB39875-AA8B-494E-B923-C8143930464F}.Release|Any CPU.ActiveCfg = Release|Any CPU {DDB39875-AA8B-494E-B923-C8143930464F}.Release|Any CPU.Build.0 = Release|Any CPU + {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8FE8AF85-C728-4DD7-BD16-3520707F97A1}.Release|Any CPU.Build.0 = Release|Any CPU + {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6}.Release|Any CPU.Build.0 = Release|Any CPU + {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -166,6 +184,9 @@ Global {750A854A-FB45-4E51-94DF-D79B4F1E46DC} = {1D43105D-361B-4D27-8069-FBE067C8C114} {F9B139E4-0160-4150-B420-73DCCD57B6B8} = {750A854A-FB45-4E51-94DF-D79B4F1E46DC} {DDB39875-AA8B-494E-B923-C8143930464F} = {373B654A-3A88-48CC-A0A3-0454514E61FF} + {8FE8AF85-C728-4DD7-BD16-3520707F97A1} = {373B654A-3A88-48CC-A0A3-0454514E61FF} + {F80CF1A0-9FFD-4276-86BA-CF96FEB7F8F6} = {373B654A-3A88-48CC-A0A3-0454514E61FF} + {3DF05F3A-9100-41EC-92F9-A66AEE9AA9FC} = {373B654A-3A88-48CC-A0A3-0454514E61FF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B381617-F1EE-4BFC-8EAE-CAB4DBB4B9A2} diff --git a/Wiring/TickSepc.Build.Tests/Assertions.fs b/Wiring/TickSepc.Build.Tests/Assertions.fs new file mode 100644 index 0000000..f9c0d63 --- /dev/null +++ b/Wiring/TickSepc.Build.Tests/Assertions.fs @@ -0,0 +1,21 @@ +[] +module TickSepc.Build.Tests.Assertions + +open System.Text.RegularExpressions +open NUnit.Framework.Constraints + +type SubStringConstraintIgnoreWhitespaces(expected:string) = + inherit Constraint() + let removeWhitespaces str = Regex.Replace(str, @"\s", "") + let expected' = expected |> removeWhitespaces + override this.ApplyTo(actual) = + let actual' = actual.ToString() |> removeWhitespaces + let isSuccess = actual'.Contains(expected') + ConstraintResult(this, actual, isSuccess) + override _.Description = expected + +let haveSubstringIgnoringWhitespaces = SubStringConstraintIgnoreWhitespaces + +let dump x = + printfn "%A" x + x diff --git a/Wiring/TickSepc.Build.Tests/HtmlGenerationTests.fs b/Wiring/TickSepc.Build.Tests/HtmlGenerationTests.fs new file mode 100644 index 0000000..aee3e5e --- /dev/null +++ b/Wiring/TickSepc.Build.Tests/HtmlGenerationTests.fs @@ -0,0 +1,173 @@ +module TickSepc.Build.Tests.HtmlGenerationTests + +open NUnit.Framework +open TickSpec.Build +open FsUnit + +[] +let ``Feature title is headline``() = + """ + Feature: First feature + + Scenario: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + |> TestApi.GenerateHtmlDoc + |> should haveSubstringIgnoringWhitespaces """

First feature

""" + +[] +let ``Scenario title is headline``() = + """ + Feature: First feature + + Scenario: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + |> TestApi.GenerateHtmlDoc + |> should haveSubstringIgnoringWhitespaces """

One

""" + +[] +let ``Scenario Outline title is headline``() = + """ + Feature: First feature + + Scenario Outline: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + |> TestApi.GenerateHtmlDoc + |> should haveSubstringIgnoringWhitespaces """

One

""" + +[] +let ``Steps rendered as ``() = + """ + Feature: First feature + + Scenario: One + GIVEN some environment + AND with following setting + WHEN some event happens + THEN the system should be in this state + AND behave like this + """ + |> TestApi.GenerateHtmlDoc + |> should haveSubstringIgnoringWhitespaces """ +

One

+
Given some environment
+And with following setting
+When some event happens
+Then the system should be in this state
+And behave like this
+
""" + +[] +let ``With Background``() = + """ + Feature: First feature + + Background: + GIVEN some additional environment + + Scenario: One + GIVEN some environment + AND with following setting + WHEN some event happens + THEN the system should be in this state + AND behave like this + """ + |> TestApi.GenerateHtmlDoc + |> should haveSubstringIgnoringWhitespaces """ +
+

First feature

+
+

Background

+
Given some additional environment
+
""" + +[] +let ``Step with multi line string``() = + """ + Feature: First feature + + Scenario: One + GIVEN some environment + AND the following value + \"\"\" + line 1 + line 2 + \"\"\" + WHEN some event happens + THEN the system should be in this state + """ + |> TestApi.GenerateHtmlDoc + |> should haveSubstringIgnoringWhitespaces """ +

One

+
Given some environment
+And the following value
+    \"\"\"
+    line 1
+    line 2
+    \"\"\"
+When some event happens
+Then the system should be in this state
+
""" + +[] +let ``Tags``() = + """ + Feature: First feature + + @some-tag @one-more-tag + Scenario: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + |> TestApi.GenerateHtmlDoc + |> should haveSubstringIgnoringWhitespaces """ +
+

One

+
Tags:some-tag, one-more-tag
+
"""
+
+[]
+let ``Comments``() =
+    """
+    Feature: First feature
+
+    Scenario: One
+    GIVEN some environment
+    WHEN some event happens
+    THEN the system should be in this state
+
+    # this is a comment
+    # over multiple lines
+    @some-tag @one-more-tag
+    Scenario: Two
+    GIVEN some environment
+    WHEN some event happens
+    THEN the system should be in this state
+    """
+    |> TestApi.GenerateHtmlDoc
+    |> should haveSubstringIgnoringWhitespaces  """
+      
+

One

+
Given some environment
+When some event happens
+Then the system should be in this state
+
+
+
+

Two

+
Tags:some-tag, one-more-tag
+
this is a comment over multiple lines
+
Given some environment
+When some event happens
+Then the system should be in this state
+
""" + diff --git a/Wiring/TickSepc.Build.Tests/TestFixtureGenerationTests.fs b/Wiring/TickSepc.Build.Tests/TestFixtureGenerationTests.fs new file mode 100644 index 0000000..4188196 --- /dev/null +++ b/Wiring/TickSepc.Build.Tests/TestFixtureGenerationTests.fs @@ -0,0 +1,217 @@ +module TickSepc.Build.Tests.TestFixtureGenerationTests + +open NUnit.Framework +open TickSpec.Build +open FsUnit + +[] +let ``Single scenario``() = + [ + """ + Feature: First feature + + Scenario: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + ] + |> TestApi.GenerateTestFixtures "Dummy.feature" + |> should haveSubstringIgnoringWhitespaces """ + namespace Specification + + open System.Reflection + open NUnit.Framework + open TickSpec.CodeGen + + [] + type ``First feature``() = + inherit AbstractFeature() + + let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature") + + [] + member this.``One``() = + #line 5 "Dummy.feature" + this.RunScenario(scenarios, "Scenario: One") + """ + +[] +let ``Feature file in sub folder``() = + [ + """ + Feature: First feature + + Scenario: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + ] + |> TestApi.GenerateTestFixtures "SubFeature/Dummy.feature" + |> should haveSubstringIgnoringWhitespaces """ + namespace Specification + + open System.Reflection + open NUnit.Framework + open TickSpec.CodeGen + + [] + type ``First feature``() = + inherit AbstractFeature() + + let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "SubFeature.Dummy.feature") + + [] + member this.``One``() = + #line 5 "SubFeature/Dummy.feature" + this.RunScenario(scenarios, "Scenario: One") + """ + + + +[] +let ``With background``() = + [ + """ + Feature: First feature + + Background: + GIVEN some additional environment + + Scenario: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + ] + |> TestApi.GenerateTestFixtures "Dummy.feature" + |> should haveSubstringIgnoringWhitespaces """ + namespace Specification + + open System.Reflection + open NUnit.Framework + open TickSpec.CodeGen + + [] + type ``First feature``() = + inherit AbstractFeature() + + let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature") + + [] + member this.``One``() = + #line 8 "Dummy.feature" + this.RunScenario(scenarios, "Scenario: One") + """ + +[] +let ``Multiple features with multipe scenario``() = + [ + """ + Feature: First feature + + Scenario: One + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + + Scenario: Two + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + + """ + Feature: Second feature + + Scenario: Three + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + + Scenario: Four + GIVEN some environment + WHEN some event happens + THEN the system should be in this state + """ + ] + |> TestApi.GenerateTestFixtures "Dummy.feature" + |> should haveSubstringIgnoringWhitespaces """ + namespace Specification + + open System.Reflection + open NUnit.Framework + open TickSpec.CodeGen + + [] + type ``First feature``() = + inherit AbstractFeature() + + let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature") + + [] + member this.``One``() = + #line 5 "Dummy.feature" + this.RunScenario(scenarios, "Scenario: One") + + [] + member this.``Two``() = + #line 10 "Dummy.feature" + this.RunScenario(scenarios, "Scenario: Two") + + [] + type ``Second feature``() = + inherit AbstractFeature() + + let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature") + + [] + member this.``Three``() = + #line 5 "Dummy.feature" + this.RunScenario(scenarios, "Scenario: Three") + + [] + member this.``Four``() = + #line 10 "Dummy.feature" + this.RunScenario(scenarios, "Scenario: Four") + """ + +[] +let ``Scenario outline``() = + [ + """ + Feature: First feature + + Scenario Outline: Computing the state + GIVEN a work item + AND with "Concept Needed" set to '' + WHEN parsing the work item + THEN the computed concept state is '' + + Examples: + | ConceptNeeded | ConceptState | + | | Unset | + | yes | Needed | + | no | NotNeeded | + """ + ] + |> TestApi.GenerateTestFixtures "Dummy.feature" + |> should haveSubstringIgnoringWhitespaces """ + namespace Specification + + open System.Reflection + open NUnit.Framework + open TickSpec.CodeGen + + [] + type ``First feature``() = + inherit AbstractFeature() + + let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), "Dummy.feature") + + [] + member this.``Computing the state``() = + #line 5 "Dummy.feature" + this.RunScenario(scenarios, "Scenario Outline: Computing the state") + """ diff --git a/Wiring/TickSepc.Build.Tests/TickSepc.Build.Tests.fsproj b/Wiring/TickSepc.Build.Tests/TickSepc.Build.Tests.fsproj new file mode 100644 index 0000000..d8b35d3 --- /dev/null +++ b/Wiring/TickSepc.Build.Tests/TickSepc.Build.Tests.fsproj @@ -0,0 +1,34 @@ + + + + net6.0 + true + 1591 + 618,672 + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Wiring/TickSpec.Build/Entities.fs b/Wiring/TickSpec.Build/Entities.fs new file mode 100644 index 0000000..c4584e9 --- /dev/null +++ b/Wiring/TickSpec.Build/Entities.fs @@ -0,0 +1,23 @@ +namespace TickSpec.Build + +type Scenario = { + Name : string + Title : string + Description : string + Tags : string list + Body : string list + StartsAtLine : int +} + +type Location = { + Filename : string + /// project local folders + Folders : string list +} + +type Feature = { + Name : string + Location : Location + Background : string list + Scenarios : Scenario list +} diff --git a/Wiring/TickSpec.Build/GherkinParser.fs b/Wiring/TickSpec.Build/GherkinParser.fs new file mode 100644 index 0000000..fbb365b --- /dev/null +++ b/Wiring/TickSpec.Build/GherkinParser.fs @@ -0,0 +1,179 @@ +module TickSpec.Build.GherkinParser + +open System +open System.IO + +[] +module private Impl = + let trimLine numSpaces (line:string) = + if line |> String.IsNullOrWhiteSpace then + String.Empty + else + line.Substring(numSpaces).TrimEnd() + + // indent which exists for all non-empty lines + let detectGlobalIndent lines = + lines + |> Seq.filter (String.IsNullOrWhiteSpace >> not) + |> Seq.map(fun x -> x |> Seq.takeWhile Char.IsWhiteSpace |> Seq.length) + |> Seq.min + + // Line numbers start at 1 as in any editor + let parseLines (text:string)= + let lines = text.Split(Environment.NewLine) + + let globalIndent = lines |> detectGlobalIndent + + lines + |> Seq.mapi(fun i l -> i + 1, l |> trimLine globalIndent) + |> List.ofSeq + + let (|Title|_|) (keyword:string) (line:string) = + let prefix = keyword + ":" + if line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) then + line.Substring(prefix.Length).Trim() |> Some + else + None + + let (|Tags|_|) (line:string) = + if line.Trim().StartsWith("@") then + line.Split(' ', StringSplitOptions.RemoveEmptyEntries) + |> Seq.map(fun x -> x.Trim().TrimStart('@')) + |> List.ofSeq + |> Some + else + None + + let (|Comment|_|) (line:string) = + if line.Trim().StartsWith("#") then + line.TrimStart().TrimStart('#').Trim() |> Some + else + None + + let trimEmptyLines = + Seq.skipWhile String.IsNullOrWhiteSpace + >> Seq.rev + >> Seq.skipWhile String.IsNullOrWhiteSpace + >> Seq.rev + + let parseTags (lines:(int*string) list) lineNo = + if lineNo > 0 then + match lines |> Seq.find(fun (x,_) -> x = lineNo - 1) |> snd with + | Tags x -> x + | _ -> [] + else + [] + + let parseComment (lines:(int*string) list) lineNo = + if lineNo > 0 then + lines + |> Seq.takeWhile(fun (x,_) -> x < lineNo) + |> Seq.rev + |> Seq.takeWhile(fun (_,x) -> String.IsNullOrWhiteSpace(x) |> not) + |> Seq.map(fun (_,x) -> x.Trim()) + |> Seq.choose(function | Comment x -> x |> Some | _ -> None) + |> Seq.rev + |> String.concat " " + else + "" + + let getProjectLocalFolders (file:string) = + let rec find dir = + seq { + if Directory.GetFiles(dir, "*.fsproj").Length > 0 then + yield! [] + else + yield! dir |> Path.GetFileName |> List.singleton + yield! dir |> Path.GetDirectoryName |> find + } + + + file |> Path.GetDirectoryName |> find |> List.ofSeq + +let Parse location (feature:string) = + let linesWithLineNo = feature |> parseLines + + let featureName = + linesWithLineNo + |> Seq.map snd + |> Seq.choose(function | Title "Feature" x -> x |> Some | _ -> None) + |> Seq.exactlyOne + + let scenarios = + linesWithLineNo + |> Seq.filter(snd >> function | Tags _ -> false | Comment _ -> false | _ -> true) + |> Seq.mapFold(fun scenario (lineNo, line) -> + match scenario, line with + | _, Title "Scenario" x + | _, Title "Scenario Outline" x -> + let newScenario = + { + Name = line + Title = x + StartsAtLine = lineNo + 1 // skip scenario title + Body = [] + Tags = parseTags linesWithLineNo lineNo + Description = parseComment linesWithLineNo lineNo + } + scenario, newScenario |> Some + | Some scenario, _ -> None, { scenario with Body = line::scenario.Body } |> Some + | None, _ -> None, None // ignore lines outside scenario + ) None + |> fun (scenarios, scenario) -> scenario |> List.singleton |> Seq.append scenarios + |> Seq.choose id + |> Seq.map(fun x -> + let globalIndent = x.Body |> detectGlobalIndent + { x with Body = x.Body |> Seq.map (trimLine globalIndent) |> trimEmptyLines |> Seq.rev |> List.ofSeq } ) + |> List.ofSeq + + let background = + linesWithLineNo + |> Seq.map snd + |> Seq.filter(function | Tags _ -> false | Comment _ -> false | _ -> true) + |> Seq.skipWhile ((function | Title "Background" _ -> true | _ -> false) >> not) + |> Seq.takeWhile ((function | Title "Scenario" _ -> true | Title "Scenario Outline" _ -> true | _ -> false) >> not) + |> trimEmptyLines + |> List.ofSeq + |> function + | [] -> [] + | h::t -> + let globalIndent = t |> detectGlobalIndent + t |> List.map (trimLine globalIndent) + + { + Name = featureName + Background = background + Location = location + Scenarios = scenarios + } + +let Read (file:string) = + let location = + { + Filename = file |> Path.GetFileName + Folders = file |> getProjectLocalFolders + } + + file |> File.ReadAllText |> Parse location + +let FindAllFeatureFiles folder = + let subDirectoriesToSkip = + [ + "node_modules" + "obj" + "bin" + "dist" + ] + + let rec getAllFiles dir = + seq { + yield! Directory.EnumerateFiles(dir, "*.feature") + for subDir in Directory.EnumerateDirectories(dir) do + let dirName = Path.GetFileName(subDir) + if subDirectoriesToSkip |> Seq.exists(fun x -> x.Equals(dirName, StringComparison.OrdinalIgnoreCase)) |> not then + yield! getAllFiles subDir + } + + folder + |> getAllFiles + |> List.ofSeq diff --git a/Wiring/TickSpec.Build/HtmlGenerator.fs b/Wiring/TickSpec.Build/HtmlGenerator.fs new file mode 100644 index 0000000..a574edb --- /dev/null +++ b/Wiring/TickSpec.Build/HtmlGenerator.fs @@ -0,0 +1,182 @@ +module TickSpec.Build.HtmlGenerator + +open System +open System.IO +open System.Xml.Linq +open System.Xml + +type TocFormat = + | Html + | Json + +type TocEntry = { + Title: string + Folders : string list + Filename: string +} + +[] +module private Impl = + open System.Text.Json + + let (|Keyword|_|) (keyword:string) (line:string) = + if line.TrimStart().StartsWith(keyword + " ", StringComparison.OrdinalIgnoreCase) then + let indent = line |> Seq.takeWhile Char.IsWhiteSpace |> Seq.length + (keyword.PadLeft(indent, ' '), line.Substring(keyword.Length + indent)) |> Some + else + None + + let generateStep (line:string) = + match line with + | Keyword "Given" (k,l) + | Keyword "When" (k,l) + | Keyword "Then" (k,l) + | Keyword "And" (k,l) + | Keyword "But" (k,l) -> + [ + new XElement("span", new XAttribute("class", "gherkin-keyword"), k) :> obj + l + Environment.NewLine + ] + | _ -> + [ + line + Environment.NewLine + ] + + let generateScenarioBody (lines:string list) = + new XElement("pre", + new XAttribute("class", "gherkin-scenario-body"), + new XElement("code", + lines + |> Seq.map generateStep)) + + let generateScenario (scenario:Scenario) = + let doc = new XElement("div", new XAttribute("class", "gherkin-scenario")) + + doc.Add(new XElement("h3", [| + new XAttribute("class", "gherkin-scenario-title") :> obj + scenario.Title :> obj |])) + + match scenario.Tags with + | [] -> () + | tags -> + new XElement("div", + new XElement("span", new XAttribute("class", "gherkin-tags"), "Tags:"), + String.Join(", ", tags)) + |> doc.Add + + match scenario.Description with + | "" -> () + | text -> + doc.Add(new XElement("div", new XAttribute("class", "gherkin-description"), text)) + + doc.Add(generateScenarioBody scenario.Body) + + doc + + let generateBackground (lines:string list) = + let doc = new XElement("div", new XAttribute("class", "gherkin-scenario")) + + doc.Add(new XElement("h3", [| + new XAttribute("class", "gherkin-scenario-title") :> obj + "Background" :> obj |])) + + doc.Add(generateScenarioBody lines) + + doc + + let generateFeature stylesheet (feature:Feature) = + let doc = new XElement("article") + + stylesheet + |> Option.iter(fun (x:string) -> + doc.Add(new XElement("link", + new XAttribute("rel", "stylesheet"), + new XAttribute("href", x)))) + + doc.Add(new XElement("h2", [| + new XAttribute("class", "gherkin-feature-title") :> obj + feature.Name |])) + + match feature.Background with + | [] -> () + | x -> doc.Add(generateBackground x) + + feature.Scenarios + |> Seq.map generateScenario + |> Seq.iter doc.Add + + doc + + let write (writer:TextWriter) (doc:XElement) = + let settings = new XmlWriterSettings() + // explicitly disable so that
 formatting is kept
+        settings.Indent <- false
+
+        use xmlWriter = XmlWriter.Create(writer, settings)
+        doc.WriteTo(xmlWriter)
+
+    let generateHtmlToc (features:Feature list) =
+        let head = new XElement("head",
+            new XElement("link",
+                new XAttribute("rel", "stylesheet"),
+                new XAttribute("href", "style.css")))
+        
+        let body = 
+            let doc = new XElement("body")
+            doc.Add(new XElement("h2", "Table of contents"))
+
+            features
+            |> Seq.map(fun x -> [x.Name + ".html"] |> List.append x.Location.Folders |> String.concat "/",x)
+            |> Seq.sortBy fst
+            |> Seq.map(fun (file,x) ->
+                new XElement("li", 
+                    new XElement("a",[|
+                        new XAttribute("href", file) :> obj
+                        new XAttribute("target", "article") :> obj
+                        x.Name |])))
+            |> fun x -> doc.Add(new XElement("ul", x))
+
+            doc.Add(new XElement("iframe",
+                new XAttribute("id", "article"),
+                new XAttribute("name", "article"),
+                new XAttribute("width", "100%"),
+                new XAttribute("height", "80%"),
+                new XElement("div")))
+
+            doc
+
+        new XElement("html", head, body)
+
+    let generateJsonToc (writer:TextWriter) (features:Feature list) =
+        let entries =
+            features
+            |> Seq.map(fun x -> 
+                { 
+                    Title = x.Name
+                    Folders = x.Location.Folders
+                    Filename = x.Name + ".html" 
+                })
+            |> List.ofSeq
+
+        let options = new JsonSerializerOptions()
+        options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
+        options.WriteIndented <- true
+
+        let json = JsonSerializer.Serialize(entries, options)
+        writer.WriteLine(json)
+
+let GenerateArticle (writer:TextWriter) stylesheet (feature:Feature) =
+    feature
+    |> generateFeature stylesheet
+    |> write writer
+
+let GenerateToC tocFormat (features:Feature list) (output:string) =
+    match tocFormat with
+    | Html -> 
+        use writer = new StreamWriter(Path.Combine(output, "toc.html"))
+        generateHtmlToc features |> write writer
+    | Json -> 
+        use writer = new StreamWriter(Path.Combine(output, "toc.json"))
+        generateJsonToc writer features 
diff --git a/Wiring/TickSpec.Build/Program.fs b/Wiring/TickSpec.Build/Program.fs
new file mode 100644
index 0000000..61133d1
--- /dev/null
+++ b/Wiring/TickSpec.Build/Program.fs
@@ -0,0 +1,80 @@
+module TickSpec.Build.Program
+
+open System
+open CommandLine
+
+module Unions =
+    open System.Reflection
+    open Microsoft.FSharp.Reflection
+
+    let private bindingFlags = BindingFlags.Public ||| BindingFlags.NonPublic
+    
+    let fromString<'a> (s:string) =
+        try
+            match FSharpType.GetUnionCases(typeof<'a>, bindingFlags) |> Array.filter (fun case -> case.Name.Equals(s,StringComparison.OrdinalIgnoreCase)) with
+            |[|case|] -> Some(FSharpValue.MakeUnion(case,[||]) :?> 'a)
+            |_ -> None
+        with
+            | _ -> None
+
+[]
+type FixturesOptions = {
+    [] File : string
+}
+
+[]
+type DocOptions = {
+    [] Input : string
+    [] Output : string
+    [] TocFormat : string option
+}
+
+[]
+module private Impl =
+    let runFixtures opts =
+        if String.IsNullOrEmpty(opts.File) then
+            failwith "Name of the file to generate missing"
+
+        Targets.GenerateTestFixtures opts.File
+
+        0
+
+    let runDoc opts = 
+        if String.IsNullOrEmpty(opts.Input) then
+            failwith "Folder to scan for feature files missing"
+
+        if String.IsNullOrEmpty(opts.Output) then
+            failwith "Output folder missing"
+
+        let tocFormat = opts.TocFormat |> Option.bind Unions.fromString
+        Targets.GenerateHtmlDocs tocFormat opts.Input opts.Output
+        0
+
+    let printErrors (errors:Error seq) =
+        let help = new Text.HelpText()
+        errors
+        |> Seq.filter(fun x -> x.Tag <> ErrorType.HelpRequestedError)
+        |> Seq.filter(fun x -> x.Tag <> ErrorType.HelpVerbRequestedError)
+        |> Seq.filter(fun x -> x.Tag <> ErrorType.VersionRequestedError)
+        |> Seq.map(fun x -> x |> printf "%A"; help.SentenceBuilder.FormatError.Invoke(x))
+        |> Seq.iter Console.Error.WriteLine
+        1
+
+[]
+let main args =
+
+    try
+        let parser = new Parser(fun settings ->
+            settings.AutoHelp <- true
+            settings.AutoVersion <- false
+            settings.CaseSensitive <- false
+            settings.CaseInsensitiveEnumValues <- true
+            settings.HelpWriter <- Console.Out
+        )
+
+        let result = parser.ParseArguments args
+        result.MapResult(runFixtures, runDoc, printErrors)
+    with
+        | ex -> 
+            Console.Error.WriteLine($"ERROR: {ex.Message}")
+            1
diff --git a/Wiring/TickSpec.Build/Targets.fs b/Wiring/TickSpec.Build/Targets.fs
new file mode 100644
index 0000000..b990e95
--- /dev/null
+++ b/Wiring/TickSpec.Build/Targets.fs
@@ -0,0 +1,70 @@
+module TickSpec.Build.Targets
+
+open System.IO
+
+let GenerateTestFixtures (output:string) = 
+    printfn $"Generating test fixtures '{output}' ..."
+
+    let featuresFolder = Path.GetDirectoryName(output)
+
+    let features = 
+        featuresFolder 
+        |> GherkinParser.FindAllFeatureFiles
+        |> List.map GherkinParser.Read
+
+    if features.Length = 0 then
+        printfn "No feature files found in %s" featuresFolder
+    else
+        use writer = new StreamWriter(output)
+        TestFixtureGenerator.Generate writer features
+
+let GenerateHtmlDocs tocFormat (input:string) (output:string) =
+    printfn $"Generating documenation for '{input}' ..."
+
+    let stylesheet (feature:Feature) = 
+        match tocFormat with
+        | Some(HtmlGenerator.Html) ->
+            (feature.Location.Folders |> List.map(fun _ -> ".."))@["style.css"]
+            |> String.concat "/"
+            |> Some
+        | _ -> None
+
+    let createFilePath feature =
+        [
+            [output]
+            feature.Location.Folders
+            [feature.Name + ".html"]
+        ]
+        |> List.concat
+        |> Array.ofList
+        |> Path.Combine
+
+    let ensureFolderExists (file:string) =
+        let folder = Path.GetDirectoryName(file)
+        if folder |> Directory.Exists |> not then
+            Directory.CreateDirectory(folder) |> ignore
+
+    let generate (feature:Feature) =
+        let file = feature |> createFilePath
+        
+        file |> ensureFolderExists
+
+        use writer = new StreamWriter(file)
+        HtmlGenerator.GenerateArticle writer (feature |> stylesheet) feature
+
+    let features =
+        input
+        |> GherkinParser.FindAllFeatureFiles
+        |> List.map GherkinParser.Read
+
+    if features |> Seq.isEmpty |> not then
+        features
+        |> Seq.iter generate
+
+        tocFormat
+        |> Option.iter(fun f -> HtmlGenerator.GenerateToC f features output)
+
+        printfn $"Documentation generated to '{output}'"
+    else
+        printfn $"No feature files found"
+
diff --git a/Wiring/TickSpec.Build/TestApi.fs b/Wiring/TickSpec.Build/TestApi.fs
new file mode 100644
index 0000000..cc11fde
--- /dev/null
+++ b/Wiring/TickSpec.Build/TestApi.fs
@@ -0,0 +1,34 @@
+module TickSpec.Build.TestApi
+
+open System.IO
+
+[]
+module private Impl =
+    let getLocation (file:string) =
+        let tokens = file.Split([| '/'; '\\'|])
+        {
+            Filename = tokens |> Seq.last
+            Folders = tokens |> Seq.take (tokens.Length - 1) |> List.ofSeq
+        }
+
+let GenerateHtmlDoc (featureText:string) =
+    use writer = new StringWriter()
+
+    let location = "Dummy.feature" |> getLocation
+
+    featureText
+    |> GherkinParser.Parse location
+    |> HtmlGenerator.GenerateArticle writer None
+
+    writer.ToString()        
+
+let GenerateTestFixtures file (featureText:string list) = 
+    use writer = new StringWriter()
+
+    let location = file |> getLocation
+
+    featureText 
+    |> List.map (GherkinParser.Parse location)
+    |> TestFixtureGenerator.Generate writer
+
+    writer.ToString()        
diff --git a/Wiring/TickSpec.Build/TestFixtureGenerator.fs b/Wiring/TickSpec.Build/TestFixtureGenerator.fs
new file mode 100644
index 0000000..f8e4148
--- /dev/null
+++ b/Wiring/TickSpec.Build/TestFixtureGenerator.fs
@@ -0,0 +1,40 @@
+module TickSpec.Build.TestFixtureGenerator
+
+open System.IO
+
+[]
+module private Impl =
+    let writeHeader (writer:TextWriter) =
+        writer.WriteLine("namespace Specification");
+        writer.WriteLine()
+        writer.WriteLine("open System.Reflection")
+        writer.WriteLine("open NUnit.Framework")
+        writer.WriteLine("open TickSpec.CodeGen")
+        writer.WriteLine()
+
+    let writeTestCase (writer:TextWriter) location scenario =
+        let file = location.Folders@[location.Filename] |> String.concat "/"
+        writer.WriteLine($"    []")
+        writer.WriteLine($"    member this.``{scenario.Title}``() =")
+        writer.WriteLine($"#line {scenario.StartsAtLine} \"{file}\"")
+        writer.WriteLine($"        this.RunScenario(scenarios, \"{scenario.Name}\")")
+        writer.WriteLine()
+
+    let writeTestFixture (writer:TextWriter) feature =
+        let resourceId = feature.Location.Folders@[feature.Location.Filename] |> String.concat "." 
+        writer.WriteLine($"[]")
+        writer.WriteLine($"type ``{feature.Name}``() = ")
+        writer.WriteLine($"    inherit AbstractFeature()")
+        writer.WriteLine()
+        writer.WriteLine($"    let scenarios = AbstractFeature.GetScenarios(Assembly.GetExecutingAssembly(), \"{resourceId}\")")
+        writer.WriteLine()
+
+        feature.Scenarios
+        |> Seq.iter (writeTestCase writer feature.Location)
+
+let Generate (writer:TextWriter) features =
+    
+    writeHeader writer
+
+    features
+    |> Seq.iter (writeTestFixture writer)
diff --git a/Wiring/TickSpec.Build/TickSpec.Build.fsproj b/Wiring/TickSpec.Build/TickSpec.Build.fsproj
new file mode 100644
index 0000000..8268e39
--- /dev/null
+++ b/Wiring/TickSpec.Build/TickSpec.Build.fsproj
@@ -0,0 +1,30 @@
+
+
+  
+    net6.0
+    exe
+    true
+    1591
+    618,672
+  
+
+  
+    
+    
+    
+    
+    
+    
+    
+  
+
+  
+    
+    
+  
+
+  
+    
+  
+
+
\ No newline at end of file
diff --git a/Wiring/TickSpec.CodeGen.NUnit/AbstractFeature.fs b/Wiring/TickSpec.CodeGen.NUnit/AbstractFeature.fs
new file mode 100644
index 0000000..82a29e2
--- /dev/null
+++ b/Wiring/TickSpec.CodeGen.NUnit/AbstractFeature.fs
@@ -0,0 +1,41 @@
+namespace TickSpec.CodeGen
+
+open System
+open System.Reflection
+open System.Runtime.ExceptionServices
+open TickSpec
+open NUnit.Framework
+
+type AbstractFeature() =
+    static member GetScenarios(assembly:Assembly, featureFilename) =
+        let definitions = new StepDefinitions(assembly.GetTypes())
+
+        let getScenarios (featureFile:string) =
+            let feature = definitions.GenerateFeature(featureFile, assembly.GetManifestResourceStream(featureFile))
+            feature.Scenarios
+
+        assembly.GetManifestResourceNames()
+        |> Seq.filter(fun x -> x.EndsWith(".feature", StringComparison.OrdinalIgnoreCase))
+        |> Seq.filter(fun x -> x.EndsWith("." + featureFilename, StringComparison.OrdinalIgnoreCase))
+        |> Seq.collect getScenarios
+        |> List.ofSeq
+
+    member __.RunScenario (scenarios:Scenario list, name:string) = 
+        let run (scenario:Scenario) =
+            if scenario.Tags |> Seq.exists ((=) "ignore") then
+                raise (new IgnoreException("Ignored: " + scenario.ToString()))
+            try
+                scenario.Action.Invoke()
+            with
+            | :? TargetInvocationException as ex -> ExceptionDispatchInfo.Capture(ex.InnerException).Throw()
+
+        let matching = 
+            scenarios 
+            // in case of "Scenario Outline" there are multiple scenarios starting with same name
+            |> Seq.filter(fun x -> x.Name = name || x.Name.StartsWith(name + " ("))
+
+        Assert.That(matching, Is.Not.Empty, "No matching scenarios found")
+
+        matching
+        |> Seq.iter run
+
diff --git a/Wiring/TickSpec.CodeGen.NUnit/TickSpec.CodeGen.NUnit.fsproj b/Wiring/TickSpec.CodeGen.NUnit/TickSpec.CodeGen.NUnit.fsproj
new file mode 100644
index 0000000..09c86d6
--- /dev/null
+++ b/Wiring/TickSpec.CodeGen.NUnit/TickSpec.CodeGen.NUnit.fsproj
@@ -0,0 +1,23 @@
+
+
+  
+    netstandard2.0
+    true
+    1591
+    618,672
+  
+
+  
+    
+  
+  
+  
+    
+    
+  
+  
+  
+    
+  
+
+
\ No newline at end of file