diff --git a/.github/actions/restore-dependencies/action.yml b/.github/actions/restore-dependencies/action.yml index f9f249d6..9bbd5049 100644 --- a/.github/actions/restore-dependencies/action.yml +++ b/.github/actions/restore-dependencies/action.yml @@ -11,3 +11,7 @@ runs: working-directory: CSF.Screenplay.JsonToHtmlReport.Template/src run: npm ci shell: bash + - name: Restore Sample project Node modules + working-directory: Samples/CSF.Screenplay.Samples.SimpleToDoApp + run: npm ci + shell: bash diff --git a/.github/actions/run-all-tests-with-coverage/action.yml b/.github/actions/run-all-tests-with-coverage/action.yml index b50b5835..3765135d 100644 --- a/.github/actions/run-all-tests-with-coverage/action.yml +++ b/.github/actions/run-all-tests-with-coverage/action.yml @@ -54,13 +54,29 @@ runs: echo "One or more tests have failed; this build should eventually fail" echo "failures=true" >> "$GITHUB_OUTPUT" fi + - name: Run sample project tests + env: + Configuration: ${{ inputs.configuration }} + Tfm: ${{ inputs.tfm }} + DISPLAY: ${{ inputs.x-display }} + id: sample_project_tests + shell: bash {0} + run: | + dotnet test Samples/CSF.Screenplay.Samples.SimpleToDoAppTests -c $Configuration --no-build --logger:nunit --test-adapter-path:. + exitCode=$? + if [ $exitCode -ne 0 ] + then + echo "One or more tests have failed; this build should eventually fail" + echo "failures=true" >> "$GITHUB_OUTPUT" + fi - name: Emit output id: emit_output env: DotnetFailures: ${{ steps.dotnet_tests.outputs.failures }} JsFailures: ${{ steps.js_tests.outputs.failures }} + SampleProjFailures: ${{ steps.sample_project_tests.outputs.failures }} run: | - if [ "$DotnetFailures" = 'true' -o "$JsFailures" = 'true' ] + if [ "$DotnetFailures" = 'true' -o "$JsFailures" = 'true' -o "$SampleProjFailures" = 'true' ] then echo "Emitting failure" echo "failures=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/dotnetCi.yml b/.github/workflows/dotnetCi.yml index dd871696..a5d4ad97 100644 --- a/.github/workflows/dotnetCi.yml +++ b/.github/workflows/dotnetCi.yml @@ -26,6 +26,7 @@ jobs: with: fetch-depth: 0 lfs: true + submodules: recursive - name: Setup build dependencies uses: ./.github/actions/setup-build-dependencies with: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..914b54d2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Samples/CSF.Screenplay.Samples.SimpleToDoApp/todomvc"] + path = Samples/CSF.Screenplay.Samples.SimpleToDoApp/todomvc + url = https://github.com/tastejs/todomvc diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7e9e591f..30706b48 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -56,7 +56,7 @@ "command": "npm", "args": ["run", "test"], "problemMatcher": [], - "label": "npm: test (Javascript)", + "label": "npm: test (JsonToHtmlReport)", "options": { "cwd": "${workspaceFolder}/CSF.Screenplay.JsonToHtmlReport.Template/src" } @@ -88,7 +88,7 @@ "command": "npm", "args": ["i"], "problemMatcher": [], - "label": "npm: install (Javascript)", + "label": "npm: install (JsonToHtmlReport)", "options": { "cwd": "${workspaceFolder}/CSF.Screenplay.JsonToHtmlReport.Template/src" } @@ -101,7 +101,7 @@ "command": "npm", "args": ["run", "dev"], "problemMatcher": [], - "label": "npm: dev (Javascript)", + "label": "npm: dev (JsonToHtmlReport)", "options": { "cwd": "${workspaceFolder}/CSF.Screenplay.JsonToHtmlReport.Template/src" } @@ -114,10 +114,23 @@ "command": "npm", "args": ["run", "build"], "problemMatcher": [], - "label": "npm: build (Javascript)", + "label": "npm: build (JsonToHtmlReport)", "options": { "cwd": "${workspaceFolder}/CSF.Screenplay.JsonToHtmlReport.Template/src" } + }, + { + "group": { + "kind": "none" + }, + "type": "shell", + "command": "npm", + "args": ["run", "serve"], + "problemMatcher": [], + "label": "npm: serve (TodoMVC)", + "options": { + "cwd": "${workspaceFolder}/Samples/CSF.Screenplay.Samples.SimpleToDoApp" + } } ] } \ No newline at end of file diff --git a/CSF.Screenplay.Abstractions/Abilities/UseAStopwatch.cs b/CSF.Screenplay.Abstractions/Abilities/UseAStopwatch.cs index de2ed081..36c927d9 100644 --- a/CSF.Screenplay.Abstractions/Abilities/UseAStopwatch.cs +++ b/CSF.Screenplay.Abstractions/Abilities/UseAStopwatch.cs @@ -9,7 +9,7 @@ namespace CSF.Screenplay.Abilities /// /// /// Use this ability with the actions which exposed by - /// . + /// . /// This ability wraps a instance, allowing the actor /// to control & read it from the related actions. /// diff --git a/CSF.Screenplay.Abstractions/PerformableBuilder.Assets.cs b/CSF.Screenplay.Abstractions/PerformableBuilder.Assets.cs index 62d7442e..94111137 100644 --- a/CSF.Screenplay.Abstractions/PerformableBuilder.Assets.cs +++ b/CSF.Screenplay.Abstractions/PerformableBuilder.Assets.cs @@ -13,7 +13,7 @@ namespace CSF.Screenplay /// This will allow you use the method names in this class in a more human-readable fashion. /// /// - public static partial class PerforamableBuilder + public static partial class PerformableBuilder { /// /// Gets a builder for an Action which copies a file at the specified source path into the assets for the current performance. diff --git a/CSF.Screenplay.Abstractions/PerformableBuilder.Stopwatch.cs b/CSF.Screenplay.Abstractions/PerformableBuilder.Stopwatch.cs index a02cc5ec..0d0d2333 100644 --- a/CSF.Screenplay.Abstractions/PerformableBuilder.Stopwatch.cs +++ b/CSF.Screenplay.Abstractions/PerformableBuilder.Stopwatch.cs @@ -3,7 +3,7 @@ namespace CSF.Screenplay { - public static partial class PerforamableBuilder + public static partial class PerformableBuilder { /// /// Gets a performable which starts the stopwatch. diff --git a/CSF.Screenplay.Abstractions/Performables/ReadTheStopwatch.cs b/CSF.Screenplay.Abstractions/Performables/ReadTheStopwatch.cs index ce404158..7edca29a 100644 --- a/CSF.Screenplay.Abstractions/Performables/ReadTheStopwatch.cs +++ b/CSF.Screenplay.Abstractions/Performables/ReadTheStopwatch.cs @@ -12,7 +12,7 @@ namespace CSF.Screenplay.Performables /// /// /// This performable requires the actor has the ability . - /// Use this performable via the builder method . + /// Use this performable via the builder method . /// /// public class ReadTheStopwatch : IPerformableWithResult, ICanReport diff --git a/CSF.Screenplay.Abstractions/Performables/ResetTheStopwatch.cs b/CSF.Screenplay.Abstractions/Performables/ResetTheStopwatch.cs index ba607230..f05db09b 100644 --- a/CSF.Screenplay.Abstractions/Performables/ResetTheStopwatch.cs +++ b/CSF.Screenplay.Abstractions/Performables/ResetTheStopwatch.cs @@ -11,7 +11,7 @@ namespace CSF.Screenplay.Performables /// /// /// This performable requires the actor has the ability . - /// Use this performable via the builder method . + /// Use this performable via the builder method . /// /// public class ResetTheStopwatch : IPerformable, ICanReport diff --git a/CSF.Screenplay.Abstractions/Performables/StartTheStopwatch.cs b/CSF.Screenplay.Abstractions/Performables/StartTheStopwatch.cs index e7c90fb9..1840b4fb 100644 --- a/CSF.Screenplay.Abstractions/Performables/StartTheStopwatch.cs +++ b/CSF.Screenplay.Abstractions/Performables/StartTheStopwatch.cs @@ -11,7 +11,7 @@ namespace CSF.Screenplay.Performables /// /// /// This performable requires the actor has the ability . - /// Use this performable via the builder method . + /// Use this performable via the builder method . /// /// public class StartTheStopwatch : IPerformable, ICanReport diff --git a/CSF.Screenplay.Abstractions/Performables/StopTheStopwatch.cs b/CSF.Screenplay.Abstractions/Performables/StopTheStopwatch.cs index 198804c6..fb10d4ea 100644 --- a/CSF.Screenplay.Abstractions/Performables/StopTheStopwatch.cs +++ b/CSF.Screenplay.Abstractions/Performables/StopTheStopwatch.cs @@ -11,7 +11,7 @@ namespace CSF.Screenplay.Performables /// /// /// This performable requires the actor has the ability . - /// Use this performable via the builder method . + /// Use this performable via the builder method . /// /// public class StopTheStopwatch : IPerformable, ICanReport diff --git a/CSF.Screenplay.Docs/docs/index.md b/CSF.Screenplay.Docs/docs/index.md index c652b639..61b34345 100644 --- a/CSF.Screenplay.Docs/docs/index.md +++ b/CSF.Screenplay.Docs/docs/index.md @@ -25,3 +25,7 @@ If you'd like, you are also encouraged to [write your own extension], adding new [extensions]: ../glossary/Extension.md [a list of the extensions which are authored alongside Screenplay's core]: extensions/index.md [write your own extension]: extendingScreenplay/index.md +## A sample Screenplay project +[The `Samples` directory within the git repository] contains a small UI-only web application and a Screenplay test project which tests it. The tests make use of the Reqnroll test framework and Selenium extension, to test the web application. + +[The `Samples` directory within the git repository]: https://github.com/csf-dev/CSF.Screenplay/tree/master/Samples \ No newline at end of file diff --git a/CSF.Screenplay.Docs/glossary/Persona.md b/CSF.Screenplay.Docs/glossary/Persona.md index 8719d982..f4a93980 100644 --- a/CSF.Screenplay.Docs/glossary/Persona.md +++ b/CSF.Screenplay.Docs/glossary/Persona.md @@ -1,3 +1,7 @@ +--- +uid: PersonaGlossaryItem +--- + # Persona A Persona is a class which serves as [a factory] for a specific **[Actor]**. diff --git a/CSF.Screenplay.JsonToHtmlReport.Template/README.md b/CSF.Screenplay.JsonToHtmlReport.Template/README.md index d9d23257..c13bc47b 100644 --- a/CSF.Screenplay.JsonToHtmlReport.Template/README.md +++ b/CSF.Screenplay.JsonToHtmlReport.Template/README.md @@ -1,6 +1,6 @@ # HTML template source -This project contains a small node/Webpack project, used to build the HTML file template for **CSF.Screenplay.JsonToHtmlReport**. +This project contains a small node/Vite project, used to build the HTML file template for **CSF.Screenplay.JsonToHtmlReport**. The HTML template is used as an embedded resource in that app/library. This project contains the _source code_ for building the HTML template; the build output of this project is sent to `CSF.Screenplay.JsonToHtmlReport/template/template.html`, where it is consumed as an embedded resource. diff --git a/CSF.Screenplay.Reqnroll/CSF.Screenplay.Reqnroll.csproj b/CSF.Screenplay.Reqnroll/CSF.Screenplay.Reqnroll.csproj index a4def43a..a9df8cc4 100644 --- a/CSF.Screenplay.Reqnroll/CSF.Screenplay.Reqnroll.csproj +++ b/CSF.Screenplay.Reqnroll/CSF.Screenplay.Reqnroll.csproj @@ -16,6 +16,7 @@ + diff --git a/CSF.Screenplay.Reqnroll/ServiceProviderAdapter.cs b/CSF.Screenplay.Reqnroll/ServiceProviderAdapter.cs index 4684d5c1..f7e50b4c 100644 --- a/CSF.Screenplay.Reqnroll/ServiceProviderAdapter.cs +++ b/CSF.Screenplay.Reqnroll/ServiceProviderAdapter.cs @@ -1,4 +1,6 @@ using System; +using Microsoft.Extensions.Logging; + #if SPECFLOW using BoDi; #else @@ -16,7 +18,61 @@ public class ServiceProviderAdapter : IServiceProvider readonly IObjectContainer wrapped; /// - public object GetService(Type serviceType) => wrapped.Resolve(serviceType); + /// + /// + /// The design of BoDi means that the ObjectContainer.IsRegistered method (and variants) are unsafe to use here. + /// This means that we cannot reliably check whether a service type is already available in the container in a side-effect free manner. + /// This is because when using IsRegistered or its other overloads, can return for types which BoDi was capable + /// of resolving, due to its registration-free resolution functionality. That is - when resolving concrete types which are not registered, it + /// will do its best to create an instance regardless. + /// Thus, whilst IsRegistered is correctly named, it is not what we want here. What we wanted is "Can you resolve type X?" + /// + /// + /// The alternative, used here, is to attempt to resolve the type, but contained within a try/catch which catched BoDi's + /// , which is thrown if BoDi cannot resolve the specified type. At that point, if it can't be resolved, + /// then we use the catch to revert to the behaviour, which is to return for a service + /// which cannot be resolved. + /// + /// + /// Beware when debugging that this comes with an unfortunate trade-off! If BoDi encounters an error whilst resolving something, then we can't tell at this + /// point whether that was an expected or unexpected error. An example of an expected error would be that we're trying to resolve an interface which + /// is not registered. An unexpected error might be an exception encountered within the constructor of something we are resolving. + /// Because the contract of is to return null when attempting to resolve things which are not registered, then we must not + /// throw for those expected errors. The dilemma this offers us (because we can't tell expected and unexpected apart) is that we either have to throw for + /// every problem, or never throw. + /// I have picked the "never throw" route, but the trade-off is that this logic masks unexpected errors. That will make debugging more difficult, because unexpected + /// exceptions from BoDi will be squashed and replaced with null returns. What I have done to make that less painful is to integrate with MS Logging Abstractions here. + /// If you install and register a logging abstraction within DI, then an implementation of will be resolved and the original error + /// recorded in the log. + /// + /// + public object GetService(Type serviceType) + { + try + { + return wrapped.Resolve(serviceType); + } + catch(ObjectContainerException ex) + { + if(TryGetLogger(out var logger)) + logger.LogError(ex, "An error occured within BoDi's DI resolution. As we cannot differentiate between expected and unexpected errors, the service provider adapter logs it and returns null."); + return null; + } + } + + bool TryGetLogger(out ILogger logger) + { + try + { + logger = wrapped.Resolve>(); + return true; + } + catch(ObjectContainerException) + { + logger = null; + return false; + } + } /// /// Initialises an instance of . diff --git a/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj b/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj index cc241118..48f15482 100644 --- a/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj +++ b/CSF.Screenplay.Selenium/CSF.Screenplay.Selenium.csproj @@ -21,7 +21,7 @@ - + diff --git a/CSF.Screenplay.Selenium/Tasks/TakeAndSaveScreenshot.cs b/CSF.Screenplay.Selenium/Tasks/TakeAndSaveScreenshot.cs index db808c9a..d3dc4653 100644 --- a/CSF.Screenplay.Selenium/Tasks/TakeAndSaveScreenshot.cs +++ b/CSF.Screenplay.Selenium/Tasks/TakeAndSaveScreenshot.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using CSF.Screenplay.Abilities; using OpenQA.Selenium; using static CSF.Screenplay.Selenium.PerformableBuilder; @@ -51,6 +52,8 @@ public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment form /// public async ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) { + if(!actor.HasAbility() && !throwIfUnsupported) return; + var screenshot = await actor.PerformAsync(GetTakeAScreenshotPerformable(), cancellationToken); if(screenshot == null) return; await actor.PerformAsync(SaveTheScreenshot(screenshot).WithTheName(name).WithTheFormat(format), cancellationToken); diff --git a/CSF.Screenplay.SpecFlow/CSF.Screenplay.SpecFlow.csproj b/CSF.Screenplay.SpecFlow/CSF.Screenplay.SpecFlow.csproj index fc6dce5d..31a8e2db 100644 --- a/CSF.Screenplay.SpecFlow/CSF.Screenplay.SpecFlow.csproj +++ b/CSF.Screenplay.SpecFlow/CSF.Screenplay.SpecFlow.csproj @@ -16,6 +16,7 @@ + diff --git a/CSF.Screenplay.sln b/CSF.Screenplay.sln index 4f45b46d..1598a71b 100644 --- a/CSF.Screenplay.sln +++ b/CSF.Screenplay.sln @@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSF.Screenplay.Reqnroll", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSF.Screenplay.Reqnroll.Tests", "Tests\CSF.Screenplay.Reqnroll.Tests\CSF.Screenplay.Reqnroll.Tests.csproj", "{A3D505D9-FE84-4032-8356-B90A30A8E4C4}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{9C0561D5-F438-4A32-973A-979B5378E5A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSF.Screenplay.Samples.SimpleToDoAppTests", "Samples\CSF.Screenplay.Samples.SimpleToDoAppTests\CSF.Screenplay.Samples.SimpleToDoAppTests.csproj", "{7C44F380-B414-40D7-8B30-EA9908AD70E1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -138,6 +142,12 @@ Global {A3D505D9-FE84-4032-8356-B90A30A8E4C4}.Release|Any CPU.Build.0 = Release|Any CPU {A3D505D9-FE84-4032-8356-B90A30A8E4C4}.Docs|Any CPU.ActiveCfg = Debug|Any CPU {A3D505D9-FE84-4032-8356-B90A30A8E4C4}.Docs|Any CPU.Build.0 = Debug|Any CPU + {7C44F380-B414-40D7-8B30-EA9908AD70E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C44F380-B414-40D7-8B30-EA9908AD70E1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C44F380-B414-40D7-8B30-EA9908AD70E1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C44F380-B414-40D7-8B30-EA9908AD70E1}.Release|Any CPU.Build.0 = Release|Any CPU + {7C44F380-B414-40D7-8B30-EA9908AD70E1}.Docs|Any CPU.ActiveCfg = Debug|Any CPU + {7C44F380-B414-40D7-8B30-EA9908AD70E1}.Docs|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {7369BCFA-DA98-4C05-9708-7949AF3E485B} = {2C3ABE29-B5F7-404E-B20A-E3679E2ECEC5} @@ -146,5 +156,6 @@ Global {AEF2B39D-5129-41DA-AD51-02C8617A344F} = {2C3ABE29-B5F7-404E-B20A-E3679E2ECEC5} {B5C9CDE0-9EAB-4381-8C79-D3F2ED1A09D2} = {2C3ABE29-B5F7-404E-B20A-E3679E2ECEC5} {A3D505D9-FE84-4032-8356-B90A30A8E4C4} = {2C3ABE29-B5F7-404E-B20A-E3679E2ECEC5} + {7C44F380-B414-40D7-8B30-EA9908AD70E1} = {9C0561D5-F438-4A32-973A-979B5378E5A1} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 8c89e5d3..41df1f04 100755 --- a/README.md +++ b/README.md @@ -27,3 +27,18 @@ There is nothing hand-editable inside. [![.NET CI](https://github.com/csf-dev/CSF.Screenplay/actions/workflows/dotnetCi.yml/badge.svg)](https://github.com/csf-dev/CSF.Screenplay/actions/workflows/dotnetCi.yml) [![Test coverage](https://sonarcloud.io/api/project_badges/measure?project=csf-dev_CSF.Screenplay&metric=coverage)](https://sonarcloud.io/summary/new_code?id=csf-dev_CSF.Screenplay) + +### This repository uses git submodules + +Note that - for the sample application - this repository makes use of [git submodules]. +When cloning this repository, you should either use the [`--recurse-submodules`] option, or after cloning use: + +```txt +git submodule update --init --recursive +``` + +Failure to do so will leave you without [the sample web app] files. + +[git submodules]: https://git-scm.com/docs/gitsubmodules +[`--recurse-submodules`]: https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---recurse-submodulespathspec +[the sample web app]: ./Samples/CSF.Screenplay.Samples.SimpleToDoApp/README.md diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoApp/.node-version b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/.node-version new file mode 100644 index 00000000..9c173dc9 Binary files /dev/null and b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/.node-version differ diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoApp/README.md b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/README.md new file mode 100644 index 00000000..dd4c184c --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/README.md @@ -0,0 +1,34 @@ +# Simple TODO app + +This project contains a [git submodule] which points to [the TodoMVC project]. +TodoMVC is a small, self-contained web application for which we may write tests using Screenplay. +The sample app uses **version 2.0.0** of TodoMVC. + +[git submodule]: https://git-scm.com/docs/gitsubmodules +[the TodoMVC project]: https://github.com/tastejs/todomvc + +## If the `todomvc` directory is empty + +If the directory is empty in your working copy then most likely you did not initialise the submodules, as directed [in the instructions for cloning the repo]. +You can fix this by running the command: + +```txt +git submodule update --init --recursive +``` + +[in the instructions for cloning the repo]: ../../README.md + +## Running the sample app + +To run the sample app, use the following commands, from this directory. + +```txt +npm ci +npm run serve +``` + +The app will be available at `http://localhost:5173`. +You may change the port if desired from `package.json`. + +Note: The test project `CSF.Screenplay.Samples.SimpleToDoAppTests` performs these steps automatically when it runs. +You only need run it manually if you'd like to see it for yourself. diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoApp/package-lock.json b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/package-lock.json new file mode 100644 index 00000000..ac738bf4 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/package-lock.json @@ -0,0 +1,645 @@ +{ + "name": "simpletodoapp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simpletodoapp", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "http-server": "^14.1.1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz", + "integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/qs": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + } + } +} diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoApp/package.json b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/package.json new file mode 100644 index 00000000..451baca1 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/package.json @@ -0,0 +1,21 @@ +{ + "name": "simpletodoapp", + "version": "1.0.0", + "description": "A simple To Do List application, which will have tests written in CSF.Screenplay & Reqnroll", + "scripts": { + "serve": "http-server ./todomvc/examples/javascript-es6/dist -p 5173 -c-1 --cors" + }, + "homepage": "https://github.com/csf-dev/CSF.Screenplay#readme", + "bugs": { + "url": "https://github.com/csf-dev/CSF.Screenplay/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/csf-dev/CSF.Screenplay.git" + }, + "license": "MIT", + "author": "craigfowler", + "devDependencies": { + "http-server": "^14.1.1" + } +} diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoApp/todomvc b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/todomvc new file mode 160000 index 00000000..53b5b616 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoApp/todomvc @@ -0,0 +1 @@ +Subproject commit 53b5b616c2b456ca980aa463c9b5f0e6a0a75bc6 diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CSF.Screenplay.Samples.SimpleToDoAppTests.csproj b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CSF.Screenplay.Samples.SimpleToDoAppTests.csproj new file mode 100644 index 00000000..7f8920ce --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CSF.Screenplay.Samples.SimpleToDoAppTests.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + false + true + CSF.Screenplay + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CiChromeOptionsCustomizer.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CiChromeOptionsCustomizer.cs new file mode 100644 index 00000000..86d4bf95 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CiChromeOptionsCustomizer.cs @@ -0,0 +1,21 @@ +using CSF.Extensions.WebDriver.Factories; +using OpenQA.Selenium.Chrome; + +namespace CSF.Screenplay; + +/// +/// A customization class which sets up Google Chrome browser with some headless options. +/// +/// +/// +/// This is not strictly part of Screenplay or its Selenium plugin. This is used with , +/// to pre-configure a browser. +/// +/// +public class CiChromeOptionsCustomizer : ICustomizesOptions +{ + public void CustomizeOptions(ChromeOptions options) + { + options.AddArguments("--disable-dev-shm-usage", "--headless", "--no-sandbox", "--disable-gpu", "--window-size=1280,1024"); + } +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CreateTodos.feature b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CreateTodos.feature new file mode 100644 index 00000000..43754409 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CreateTodos.feature @@ -0,0 +1,28 @@ +Feature: Creating some Todo items + + Users should be able to create a number of Todo items. + Each item is independent and adds to the list of displayed items. + +Background: + Given Webster has opened the todo app + +Scenario: A user can create a single todo and it appears in the list + When he creates a todo item reading feed the cat + Then the count of displayed todos should be 1 + +Scenario: A user can create three todos and they all appear in the list + When he creates several todo items + | Item | + | feed the cat | + | feed the dog | + | buy milk | + Then the count of displayed todos should be 3 + +Scenario: Todo items should be listed in inverse order in which they are created + When he creates several todo items + | Item | + | feed the cat | + | feed the dog | + | buy milk | + Then the item displayed at position 3 should read feed the cat + And the item displayed at position 1 should read buy milk \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CreateTodos.feature.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CreateTodos.feature.cs new file mode 100644 index 00000000..2c0abd32 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/CreateTodos.feature.cs @@ -0,0 +1,197 @@ +// ------------------------------------------------------------------------------ +// +// 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 CSF.Screenplay +{ + 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("Creating some Todo items")] + public partial class CreatingSomeTodoItemsFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private static string[] featureTags = ((string[])(null)); + +#line 1 "CreateTodos.feature" +#line hidden + + [NUnit.Framework.OneTimeSetUpAttribute()] + public virtual async System.Threading.Tasks.Task FeatureSetupAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(null, NUnit.Framework.TestContext.CurrentContext.WorkerId); + global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "", "Creating some Todo items", " Users should be able to create a number of Todo items.\r\n Each item is inde" + + "pendent and adds to the list of displayed items.", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags); + await testRunner.OnFeatureStartAsync(featureInfo); + } + + [NUnit.Framework.OneTimeTearDownAttribute()] + public virtual async System.Threading.Tasks.Task FeatureTearDownAsync() + { + await testRunner.OnFeatureEndAsync(); + 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(); + } + + public virtual async System.Threading.Tasks.Task FeatureBackgroundAsync() + { +#line 6 +#line hidden +#line 7 + await testRunner.GivenAsync("Webster has opened the todo app", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("A user can create a single todo and it appears in the list")] + public async System.Threading.Tasks.Task AUserCanCreateASingleTodoAndItAppearsInTheList() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("A user can create a single todo and it appears in the list", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 9 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + await this.ScenarioStartAsync(); +#line 6 +await this.FeatureBackgroundAsync(); +#line hidden +#line 10 + await testRunner.WhenAsync("he creates a todo item reading feed the cat", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 11 + await testRunner.ThenAsync("the count of displayed todos should be 1", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("A user can create three todos and they all appear in the list")] + public async System.Threading.Tasks.Task AUserCanCreateThreeTodosAndTheyAllAppearInTheList() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("A user can create three todos and they all appear in the list", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 13 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + await this.ScenarioStartAsync(); +#line 6 +await this.FeatureBackgroundAsync(); +#line hidden + global::Reqnroll.Table table1 = new global::Reqnroll.Table(new string[] { + "Item"}); + table1.AddRow(new string[] { + "feed the cat"}); + table1.AddRow(new string[] { + "feed the dog"}); + table1.AddRow(new string[] { + "buy milk"}); +#line 14 + await testRunner.WhenAsync("he creates several todo items", ((string)(null)), table1, "When "); +#line hidden +#line 19 + await testRunner.ThenAsync("the count of displayed todos should be 3", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("Todo items should be listed in inverse order in which they are created")] + public async System.Threading.Tasks.Task TodoItemsShouldBeListedInInverseOrderInWhichTheyAreCreated() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Todo items should be listed in inverse order in which they are created", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 21 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + await this.ScenarioStartAsync(); +#line 6 +await this.FeatureBackgroundAsync(); +#line hidden + global::Reqnroll.Table table2 = new global::Reqnroll.Table(new string[] { + "Item"}); + table2.AddRow(new string[] { + "feed the cat"}); + table2.AddRow(new string[] { + "feed the dog"}); + table2.AddRow(new string[] { + "buy milk"}); +#line 22 + await testRunner.WhenAsync("he creates several todo items", ((string)(null)), table2, "When "); +#line hidden +#line 27 + await testRunner.ThenAsync("the item displayed at position 3 should read feed the cat", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 28 + await testRunner.AndAsync("the item displayed at position 1 should read buy milk", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Filtering.feature b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Filtering.feature new file mode 100644 index 00000000..f413c11f --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Filtering.feature @@ -0,0 +1,30 @@ +Feature: Filtering the todo list + + Users should be able to filter their todo items. + +Background: + Given Webster has opened the todo app + And he has created several todo items + | Item | + | feed the cat | + | feed the dog | + | buy milk | + +Scenario: The Active filter should not show completed items + When he marks the item at position 1 as completed + And he marks the item at position 2 as completed + And he clicks the filter named Active + Then the count of displayed todos should be 1 + +Scenario: The Completed filter should not show completed items + When he marks the item at position 1 as completed + And he marks the item at position 2 as completed + And he clicks the filter named Completed + Then the count of displayed todos should be 2 + +Scenario: The All filter should show all items + When he marks the item at position 1 as completed + And he marks the item at position 2 as completed + And he clicks the filter named Completed + And he clicks the filter named All + Then the count of displayed todos should be 3 \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Filtering.feature.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Filtering.feature.cs new file mode 100644 index 00000000..c637dd55 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Filtering.feature.cs @@ -0,0 +1,209 @@ +// ------------------------------------------------------------------------------ +// +// 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 CSF.Screenplay +{ + 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("Filtering the todo list")] + public partial class FilteringTheTodoListFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private static string[] featureTags = ((string[])(null)); + +#line 1 "Filtering.feature" +#line hidden + + [NUnit.Framework.OneTimeSetUpAttribute()] + public virtual async System.Threading.Tasks.Task FeatureSetupAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(null, NUnit.Framework.TestContext.CurrentContext.WorkerId); + global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "", "Filtering the todo list", " Users should be able to filter their todo items.", global::Reqnroll.ProgrammingLanguage.CSharp, featureTags); + await testRunner.OnFeatureStartAsync(featureInfo); + } + + [NUnit.Framework.OneTimeTearDownAttribute()] + public virtual async System.Threading.Tasks.Task FeatureTearDownAsync() + { + await testRunner.OnFeatureEndAsync(); + 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(); + } + + public virtual async System.Threading.Tasks.Task FeatureBackgroundAsync() + { +#line 5 +#line hidden +#line 6 + await testRunner.GivenAsync("Webster has opened the todo app", ((string)(null)), ((global::Reqnroll.Table)(null)), "Given "); +#line hidden + global::Reqnroll.Table table3 = new global::Reqnroll.Table(new string[] { + "Item"}); + table3.AddRow(new string[] { + "feed the cat"}); + table3.AddRow(new string[] { + "feed the dog"}); + table3.AddRow(new string[] { + "buy milk"}); +#line 7 + await testRunner.AndAsync("he has created several todo items", ((string)(null)), table3, "And "); +#line hidden + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("The Active filter should not show completed items")] + public async System.Threading.Tasks.Task TheActiveFilterShouldNotShowCompletedItems() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("The Active filter should not show completed items", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 13 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + await this.ScenarioStartAsync(); +#line 5 +await this.FeatureBackgroundAsync(); +#line hidden +#line 14 + await testRunner.WhenAsync("he marks the item at position 1 as completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 15 + await testRunner.AndAsync("he marks the item at position 2 as completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 16 + await testRunner.AndAsync("he clicks the filter named Active", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 17 + await testRunner.ThenAsync("the count of displayed todos should be 1", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("The Completed filter should not show completed items")] + public async System.Threading.Tasks.Task TheCompletedFilterShouldNotShowCompletedItems() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("The Completed filter should not show completed items", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 19 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + await this.ScenarioStartAsync(); +#line 5 +await this.FeatureBackgroundAsync(); +#line hidden +#line 20 + await testRunner.WhenAsync("he marks the item at position 1 as completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 21 + await testRunner.AndAsync("he marks the item at position 2 as completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 22 + await testRunner.AndAsync("he clicks the filter named Completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 23 + await testRunner.ThenAsync("the count of displayed todos should be 2", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + + [NUnit.Framework.TestAttribute()] + [NUnit.Framework.DescriptionAttribute("The All filter should show all items")] + public async System.Threading.Tasks.Task TheAllFilterShouldShowAllItems() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("The All filter should show all items", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 25 +this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + await this.ScenarioStartAsync(); +#line 5 +await this.FeatureBackgroundAsync(); +#line hidden +#line 26 + await testRunner.WhenAsync("he marks the item at position 1 as completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 27 + await testRunner.AndAsync("he marks the item at position 2 as completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 28 + await testRunner.AndAsync("he clicks the filter named Completed", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 29 + await testRunner.AndAsync("he clicks the filter named All", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); +#line hidden +#line 30 + await testRunner.ThenAsync("the count of displayed todos should be 3", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/GlobalUsings.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/GlobalUsings.cs new file mode 100644 index 00000000..24c84b10 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System.Threading; +global using System.Threading.Tasks; +global using Reqnroll; +global using CSF.Screenplay.Selenium; diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/OpenTheApp.feature b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/OpenTheApp.feature new file mode 100644 index 00000000..68b5f841 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/OpenTheApp.feature @@ -0,0 +1,5 @@ +Feature: Opening the app + +Scenario: Webster should be able to open the app in a web browser + When Webster opens the todo app + Then the page heading should read "todos" \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/OpenTheApp.feature.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/OpenTheApp.feature.cs new file mode 100644 index 00000000..342ac97f --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/OpenTheApp.feature.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------------ +// +// 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 CSF.Screenplay +{ + 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("Opening the app")] + public partial class OpeningTheAppFeature + { + + private global::Reqnroll.ITestRunner testRunner; + + private static string[] featureTags = ((string[])(null)); + +#line 1 "OpenTheApp.feature" +#line hidden + + [NUnit.Framework.OneTimeSetUpAttribute()] + public virtual async System.Threading.Tasks.Task FeatureSetupAsync() + { + testRunner = global::Reqnroll.TestRunnerManager.GetTestRunnerForAssembly(null, NUnit.Framework.TestContext.CurrentContext.WorkerId); + global::Reqnroll.FeatureInfo featureInfo = new global::Reqnroll.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "", "Opening the app", null, global::Reqnroll.ProgrammingLanguage.CSharp, featureTags); + await testRunner.OnFeatureStartAsync(featureInfo); + } + + [NUnit.Framework.OneTimeTearDownAttribute()] + public virtual async System.Threading.Tasks.Task FeatureTearDownAsync() + { + await testRunner.OnFeatureEndAsync(); + 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("Webster should be able to open the app in a web browser")] + public async System.Threading.Tasks.Task WebsterShouldBeAbleToOpenTheAppInAWebBrowser() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Webster should be able to open the app in a web browser", 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.WhenAsync("Webster opens the todo app", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); +#line hidden +#line 5 + await testRunner.ThenAsync("the page heading should read \"todos\"", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden + } + await this.ScenarioCleanupAsync(); + } + } +} +#pragma warning restore +#endregion diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/PerformableBuilder.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/PerformableBuilder.cs new file mode 100644 index 00000000..884b57ff --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/PerformableBuilder.cs @@ -0,0 +1,27 @@ +using CSF.Screenplay.Tasks; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay; + +/// +/// Builder class which runs tasks. +/// +/// +/// +/// This class is an example of which is used to construct performable instances. +/// +/// +public static class TodoPerformableBuilder +{ + public static IPerformable OpenTheApp() => NavigateTo(TodoApp.RootUri); + + public static IPerformable CreateATodoReading(string text) => new CreateATodoItem(text); + + public static IPerformableWithResult GetTheCountOfDisplayedTodos() => new GetTheCountOfDisplayedItems(); + + public static IPerformableWithResult GetTheTodoItemAtPosition(int position) => new GetTheItemDisplayedAtAPosition(position); + + public static IPerformable ActivateAFilter(string name) => new ActivateAFilter(name); + + public static IPerformable ToggleTheCompletedStateOfItem(int position) => new ToggleAnItemsCompletedState(position); +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/AppSteps.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/AppSteps.cs new file mode 100644 index 00000000..857d12a4 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/AppSteps.cs @@ -0,0 +1,33 @@ +using NUnit.Framework; +using static CSF.Screenplay.PerformanceStarter; +using static CSF.Screenplay.Selenium.PerformableBuilder; +using static CSF.Screenplay.TodoPerformableBuilder; + +namespace CSF.Screenplay.StepDefinitions; + +[Binding] +public class AppSteps(IStage stage) +{ + [Given("Webster has opened the todo app")] + public async Task GivenWebsterOpenedTheTodoApp() + { + var webster = stage.Spotlight(); + await Given(webster).WasAbleTo(OpenTheApp()); + } + + [When("Webster opens the todo app")] + public async Task WhenWebsterOpensTheTodoApp() + { + var webster = stage.Spotlight(); + await When(webster).AttemptsTo(OpenTheApp()); + } + + [Then(@"the page heading should read ""([^""]+)""")] + public async Task ThenThePageHeadingShouldRead(string expected) + { + var actor = stage.GetSpotlitActor(); + var heading = await Then(actor).Should(ReadFromTheElement(TodoApp.PageHeading).TheText()); + + Assert.That(heading, Is.EqualTo(expected)); + } +} diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/FilteringSteps.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/FilteringSteps.cs new file mode 100644 index 00000000..85fe64dd --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/FilteringSteps.cs @@ -0,0 +1,22 @@ +using static CSF.Screenplay.PerformanceStarter; +using static CSF.Screenplay.TodoPerformableBuilder; + +namespace CSF.Screenplay.StepDefinitions; + +[Binding] +public class FilteringSteps(IStage stage) +{ + [When("(?:s?he|they) clicks? the filter named ([^\\s]+)")] + public async Task WhenTheyClickTheFilter(string filterName) + { + var actor = stage.GetSpotlitActor(); + await When(actor).AttemptsTo(ActivateAFilter(filterName)); + } + + [Given("(?:s?he|they) clicked the filter named ([^\\s]+)")] + public async Task GivenTheyClickedTheFilter(string filterName) + { + var actor = stage.GetSpotlitActor(); + await Given(actor).WasAbleTo(ActivateAFilter(filterName)); + } +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/TodoSteps.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/TodoSteps.cs new file mode 100644 index 00000000..4627c54d --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/StepDefinitions/TodoSteps.cs @@ -0,0 +1,58 @@ +using System.Linq; +using static CSF.Screenplay.PerformanceStarter; +using static CSF.Screenplay.TodoPerformableBuilder; +using NUnit.Framework; + +namespace CSF.Screenplay.StepDefinitions; + +[Binding] +public class TodoSteps(IStage stage) +{ + [When("(?:s?he|they) creates? a todo item reading (.+)")] + public async Task WhenTheyCreateATodoItem(string text) + { + var actor = stage.GetSpotlitActor(); + await When(actor).AttemptsTo(CreateATodoReading(text)); + } + + [When("he creates several todo items")] + public async Task WhenTheyCreateSomeTodoItems(DataTable dataTable) + { + var items = dataTable.Rows.Select(x => x.Values.Single()).ToArray(); + var actor = stage.GetSpotlitActor(); + foreach(var item in items) + await When(actor).AttemptsTo(CreateATodoReading(item)); + } + + [Given("he has created several todo items")] + public async Task GivenTheyCreatedSomeTodoItems(DataTable dataTable) + { + var items = dataTable.Rows.Select(x => x.Values.Single()).ToArray(); + var actor = stage.GetSpotlitActor(); + foreach(var item in items) + await Given(actor).WasAbleTo(CreateATodoReading(item)); + } + + [Then("the count of displayed todos should be (\\d+)")] + public async Task ThenAssertTheCountOfDisplayedTodos(int expected) + { + var actor = stage.GetSpotlitActor(); + var actual = await Then(actor).Should(GetTheCountOfDisplayedTodos()); + Assert.That(actual, Is.EqualTo(expected)); + } + + [Then("the item displayed at position (\\d+) should read (.+)")] + public async Task ThenTheItemAtAPositionShouldRead(int position, string expected) + { + var actor = stage.GetSpotlitActor(); + var actual = await Then(actor).Should(GetTheTodoItemAtPosition(position)); + Assert.That(actual, Is.EqualTo(expected)); + } + + [When("(?:s?he|they) marks the item at position (\\d+) as completed")] + public async Task WhenTheyMarkAnItemAsCompleted(int position) + { + var actor = stage.GetSpotlitActor(); + await When(actor).AttemptsTo(ToggleTheCompletedStateOfItem(position)); + } +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/ActivateAFilter.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/ActivateAFilter.cs new file mode 100644 index 00000000..88fb4369 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/ActivateAFilter.cs @@ -0,0 +1,21 @@ +using System; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Tasks; +public class ActivateAFilter(string filterName) : IPerformable, ICanReport +{ + public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment formatter) + => formatter.Format("{Actor} activates the filter {Name}", actor, filterName); + + public ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) + { + var filter = filterName.ToLowerInvariant() switch + { + "all" => TodoApp.ShowAllFilter, + "active" => TodoApp.ActiveOnlyFilter, + "completed" => TodoApp.CompletedOnlyFilter, + _ => throw new InvalidOperationException("The filter name must be one of 'all', 'active' or 'completed' (case-invariant)") + }; + return actor.PerformAsync(ClickOn(filter)); + } +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/CreateATodoItem.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/CreateATodoItem.cs new file mode 100644 index 00000000..dbc92340 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/CreateATodoItem.cs @@ -0,0 +1,13 @@ +using OpenQA.Selenium; +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Tasks; + +public class CreateATodoItem(string text) : IPerformable, ICanReport +{ + public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment formatter) + => formatter.Format("{Actor} creates a todo item reading \"{text}\"", actor, text); + + public ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) + => actor.PerformAsync(EnterTheText(text, Keys.Enter).Into(TodoApp.NewItem), cancellationToken); +} diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/GetTheCountOfDisplayedItems.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/GetTheCountOfDisplayedItems.cs new file mode 100644 index 00000000..dfbf5021 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/GetTheCountOfDisplayedItems.cs @@ -0,0 +1,15 @@ +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Tasks; + +public class GetTheCountOfDisplayedItems : IPerformableWithResult, ICanReport +{ + public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment formatter) + => formatter.Format("{Actor} gets the count of displayed todo items"); + + public async ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) + { + var displayedTodos = await actor.PerformAsync(FindElementsOnThePage().WhichMatch(TodoApp.DisplayedTodos), cancellationToken); + return displayedTodos.Count; + } +} diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/GetTheItemDisplayedAtAPosition.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/GetTheItemDisplayedAtAPosition.cs new file mode 100644 index 00000000..d0a287b9 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/GetTheItemDisplayedAtAPosition.cs @@ -0,0 +1,12 @@ +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Tasks; + +public class GetTheItemDisplayedAtAPosition(int position) : IPerformableWithResult, ICanReport +{ + public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment formatter) + => formatter.Format("{Actor} gets the item displayed at position {Position}", actor, position); + + public ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) + => actor.PerformAsync(ReadFromTheElement(TodoApp.TodoAtPosition(position)).TheText(), cancellationToken); +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/ToggleAnItemsCompletedState.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/ToggleAnItemsCompletedState.cs new file mode 100644 index 00000000..9e59aa14 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Tasks/ToggleAnItemsCompletedState.cs @@ -0,0 +1,12 @@ +using static CSF.Screenplay.Selenium.PerformableBuilder; + +namespace CSF.Screenplay.Tasks; + +public class ToggleAnItemsCompletedState(int position) : IPerformable, ICanReport +{ + public ReportFragment GetReportFragment(Actor actor, IFormatsReportFragment formatter) + => formatter.Format("{Actor} toggles the completion state of the item at position {Position}", actor, position); + + public ValueTask PerformAsAsync(ICanPerform actor, CancellationToken cancellationToken = default) + => actor.PerformAsync(ClickOn(TodoApp.CompletedToggle(position))); +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/TestRunSetup.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/TestRunSetup.cs new file mode 100644 index 00000000..5fe51489 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/TestRunSetup.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Reflection; +using System.Text; +using CSF.Extensions.WebDriver.Factories; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Reqnroll.BoDi; + +namespace CSF.Screenplay; + +/// +/// Configures the Reqnroll test run appropriately to use Screenplay/Selenium, and to ensure that the TodoMVC sample app +/// is started and running automatically when the test run is performed. +/// +[Binding] +public static class TestRunSetup +{ + const string + testProjectName = "CSF.Screenplay.Samples.SimpleToDoAppTests", + sampleAppName = "CSF.Screenplay.Samples.SimpleToDoApp", + sampleAppUrl = "http://localhost:5173/"; + + internal static readonly Uri SampleAppUri = new(sampleAppUrl); + + static Process? sampleAppProcess; + static readonly ConcurrentQueue sampleAppOutput = new(); + + [BeforeTestRun] + public static async Task BeforeTestRunAsync(IObjectContainer container) + { + SetupDependencyInjection(container); + await StartSampleAppAsync(); + } + + [AfterTestRun] + public static async Task AfterTestRunAsync() + { + await StopSampleAppAsync(); + } + + /// + /// Gets the working directory to the sample application. + /// + /// A string directory path + static string GetSampleAppWorkingDirectory() + { + var path = Path.Combine(GetTestProjectDirectory(), "..", sampleAppName); + if(!Directory.Exists(path)) throw new DriveNotFoundException($"The sample application directory must exist: {path}"); + return path; + } + + /// + /// Gets the file system directory path to the location which contains this test project + /// + /// A string directory path + /// If this assembly is loaded other than from the file system + /// If the test project is not an ancestor of the directory which contains this test project + static string GetTestProjectDirectory() + { + var testDirectory = Assembly.GetExecutingAssembly().Location; + if(string.IsNullOrWhiteSpace(testDirectory)) + throw new InvalidOperationException("The current assembly must be loaded from a file system path"); + + for(var currentPath = testDirectory; !string.IsNullOrEmpty(currentPath); currentPath = Directory.GetParent(currentPath!)?.FullName) + { + var directory = new DirectoryInfo(currentPath); + if(directory.Exists && directory.Name == testProjectName) + return directory.FullName; + } + + throw new DirectoryNotFoundException($"The assembly {testDirectory} must be contained within the test project directory: {testProjectName}"); + } + + /// + /// Sets up Reqnroll dependency injection for this test run. + /// + /// + /// + /// The Reqnroll Screenplay plugin adds Screenplay to DI automatically, but we must add a few things manually: + /// + /// + /// The configuration, using Microsoft.Extensions.Configuration. This must be added manually because we are not using + /// Generic Host here. + /// The Screenplay/Selenium plugin. + /// An adapter for the MS Options pattern. This is required because the Reqnroll DI container comes with some limitations + /// which cannot be worked around automatically. One of these means that it is not compatible with the MS Options pattern without this + /// additional DI registration. + /// The Webster persona, which would need registering in any case. + /// + /// + /// The Reqnroll DI container + static void SetupDependencyInjection(IObjectContainer container) + { + container.ToServiceCollection() + .AddSingleton(GetConfiguration) + .AddSelenium() + .AddOptionsAdapters() + .AddTransient(); + } + + static IConfiguration GetConfiguration(IServiceProvider _) + { + var builder = new ConfigurationBuilder().AddJsonFile("appsettings.json"); + return builder.Build(); + } + + /// + /// Creates and starts an OS process which runs NPM, to start up the TodoMVC sample application. + /// + /// + /// + /// The OS process handle is stored at , so that it may be stopped gracefully at a later stage. + /// + /// + /// This includes logic which waits for the sample app to come up (via npm run serve) and which attempts to clean up a dangling + /// process should there be a failure of some kind. + /// Additionally, if we are unable to connect to the sample app, an is thrown, which includes + /// the output from NPM. This should help diagnose the problem. + /// + /// + /// An awaitable task + static async Task StartSampleAppAsync() + { + var startInfo = GetSampleAppStartInfo(); + sampleAppProcess = new Process { StartInfo = startInfo }; + sampleAppProcess.OutputDataReceived += (_, e) => { if (e.Data is not null) sampleAppOutput.Enqueue($"[stdout] {e.Data}"); }; + sampleAppProcess.ErrorDataReceived += (_, e) => { if (e.Data is not null) sampleAppOutput.Enqueue($"[stderr] {e.Data}"); }; + + try + { + sampleAppProcess.Start(); + sampleAppProcess.BeginOutputReadLine(); + sampleAppProcess.BeginErrorReadLine(); + + await WaitForSampleAppAsync(sampleAppProcess); + } + catch (Exception ex) + { + try { if (!sampleAppProcess.HasExited) sampleAppProcess.Kill(true); } catch { /* intentional no-op */ } + + throw new InvalidOperationException( + $"The sample app did not start correctly, or was unreachable.{Environment.NewLine}" + + $"Working directory: {startInfo.WorkingDirectory}{Environment.NewLine}" + + $"Command: {startInfo.FileName} {string.Join(' ', startInfo.ArgumentList)}{Environment.NewLine}" + + $"--- npm output ---{Environment.NewLine}{GetSampleAppOutput()}{Environment.NewLine}--- end npm output ---", + ex); + } + } + + /// + /// Gets the start info for the sample app process. + /// + /// + /// + /// Executing NPM via Windows requires cmd.exe, because NPM is a npm.cmd batch-file formatted file. + /// On Linux (CI environments) though, it is not. + /// + /// + /// Process start info + static ProcessStartInfo GetSampleAppStartInfo() + { + var startInfo = new ProcessStartInfo + { + WorkingDirectory = GetSampleAppWorkingDirectory(), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + if (OperatingSystem.IsWindows()) + { + startInfo.FileName = "cmd.exe"; + foreach (var arg in new[] { "/c", "npm", "run", "serve" }) startInfo.ArgumentList.Add(arg); + } + else + { + startInfo.FileName = "npm"; + foreach (var arg in new[] { "run", "serve" }) startInfo.ArgumentList.Add(arg); + } + + return startInfo; + } + static string GetSampleAppOutput() + { + var sb = new StringBuilder(); + foreach (var line in sampleAppOutput) sb.AppendLine(line); + return sb.Length == 0 ? "(no output captured)" : sb.ToString(); + } + + /// + /// Makes up to 10 attempts to connect to the sample application, over a maximum of approx 28 seconds. + /// + /// + /// + /// If the NPM process has exited early then this algorithm fails fast with an . + /// This method uses a very simple exponential backoff algorithm for the retries, capped at a 5 second wait between retries. + /// Over the total of 10 attempts, this puts us at a maximum wait of approx 28 seconds. + /// + /// + /// + /// + /// + /// + static async Task WaitForSampleAppAsync(Process process) + { + const int maxConnectionAttempts = 10; + + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + Exception? lastError = null; + + for (var attempt = 1; attempt <= maxConnectionAttempts; attempt++) + { + if (process.HasExited) + { + process.WaitForExit(); + throw new InvalidOperationException($"The npm process exited prematurely with exit code {process.ExitCode}."); + } + + try + { + using var response = await httpClient.GetAsync(sampleAppUrl); + response.EnsureSuccessStatusCode(); + return; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + lastError = ex; + if (attempt == maxConnectionAttempts) break; + var delay = TimeSpan.FromMilliseconds(Math.Min(250 * Math.Pow(2, attempt - 1), 5000)); + await Task.Delay(delay); + } + } + + throw new TimeoutException($"The sample app did not respond after {maxConnectionAttempts} attempts.", lastError); + } + + /// + /// Stops the OS process which runs the TodoMVC sample application. + /// + /// An awaitable task + static async Task StopSampleAppAsync() + { + sampleAppProcess?.Kill(true); + } +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/TodoApp.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/TodoApp.cs new file mode 100644 index 00000000..bbdd64bd --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/TodoApp.cs @@ -0,0 +1,42 @@ +using CSF.Screenplay; +using CSF.Screenplay.Selenium.Elements; + +/// +/// Constants which represent URLs and UI elements associated with the Todo app. +/// +/// +/// +/// In the Screenplay pattern (which is a heavy refactoring of the Page Object Pattern), types like this are all that remains of the Page Object Model. +/// This class carries runtime constants & functions which correspond mainly to the interactive UI elements which this test suite interacts with. +/// The only other is a named URI which represents the app's web address (within a base URL, configured in the persona). +/// +/// +/// Obviously, the TodoMVC app is very small, so just one static class is ample to contain all these values without confusion. +/// In a more realistic-size test project, there might be many such classes, each representing either a page/screen, or even area/logical section of a screen. +/// Whatever is suitable to organise such constants/functions, appropriate to the design of the app. +/// +/// +/// and instances are given human-readable names, so that they are readable (to devs and non-devs alike) +/// when they show up in reports. +/// +/// +public static class TodoApp +{ + public static NamedUri RootUri = new NamedUri("/", "The root of the todo app"); + + public static Locator PageHeading = new CssSelector(".header h1", "The main page heading"); + + public static Locator NewItem = new CssSelector(".header .new-todo", "The new-item entry control"); + + public static Locator DisplayedTodos = new CssSelector(".main .todo-list li", "The displayed todo items"); + + public static Locator TodoAtPosition(int position) => new CssSelector($".main .todo-list li:nth-child({position}) .view label", $"The text of the displayed todo item at position {position}"); + + public static Locator CompletedToggle(int position) => new CssSelector($".main .todo-list li:nth-child({position}) .view input", $"The 'completed' toggle for the todo item at position {position}"); + + public static Locator ShowAllFilter => new CssSelector(".footer .filters [href=\"#/\"]", "The clear-filter (show all) button"); + + public static Locator ActiveOnlyFilter => new CssSelector(".footer .filters [href=\"#/active\"]", "The active-only filter button"); + + public static Locator CompletedOnlyFilter => new CssSelector(".footer .filters [href=\"#/completed\"]", "The completed-only filter button"); +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Webster.cs b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Webster.cs new file mode 100644 index 00000000..ae27a0ef --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/Webster.cs @@ -0,0 +1,32 @@ +using System; +using CSF.Extensions.WebDriver; + +namespace CSF.Screenplay; + +/// +/// Webster is a Persona who can browse the web. +/// +/// +/// +/// The has more information about Personas and how to use them. +/// +/// +/// An web driver factory +public class Webster(IGetsWebDriver webDriverFactory) : IPersona +{ + /// + public string Name => nameof(Webster); + + /// + public Actor GetActor(Guid performanceIdentity) + { + var webster = new Actor(Name, performanceIdentity); + webster.IsAbleTo(BrowseTheWeb()); + webster.IsAbleTo(UseABaseUri()); + return webster; + } + + BrowseTheWeb BrowseTheWeb() => new(webDriverFactory); + + static UseABaseUri UseABaseUri() => new(TestRunSetup.SampleAppUri); +} \ No newline at end of file diff --git a/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/appsettings.json b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/appsettings.json new file mode 100644 index 00000000..c1927be9 --- /dev/null +++ b/Samples/CSF.Screenplay.Samples.SimpleToDoAppTests/appsettings.json @@ -0,0 +1,12 @@ +{ + "WebDriverFactory": { + "DriverConfigurations": { + "CiHeadlessChrome": { + "DriverType": "ChromeDriver", + "OptionsCustomizerType ": "CSF.Screenplay.CiChromeOptionsCustomizer, CSF.Screenplay.Samples.SimpleToDoAppTests", + "BrowserLogLevel": "Info" + } + }, + "SelectedConfiguration": "CiHeadlessChrome" + } +} diff --git a/Samples/README.md b/Samples/README.md new file mode 100644 index 00000000..b76ccca8 --- /dev/null +++ b/Samples/README.md @@ -0,0 +1,7 @@ +# Samples + +This directory contains sample projects which consume CSF.Screenplay. +These could be considered _as part of the documentation_. + +* **CSF.Screenplay.Samples.SimpleToDoApp** is a small/simple "To Do List" web app which can be tested by Screenplay tests. +* **CSF.Screenplay.Samples.SimpleToDoAppTests** is a BDD test suite written using Reqnroll, Selenium and Screenplay to test the simple To Do List app. diff --git a/Tests/CSF.Screenplay.Reqnroll.Tests/CSF.Screenplay.Reqnroll.Tests.csproj b/Tests/CSF.Screenplay.Reqnroll.Tests/CSF.Screenplay.Reqnroll.Tests.csproj index a68fbe5b..9c19e01f 100644 --- a/Tests/CSF.Screenplay.Reqnroll.Tests/CSF.Screenplay.Reqnroll.Tests.csproj +++ b/Tests/CSF.Screenplay.Reqnroll.Tests/CSF.Screenplay.Reqnroll.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/Tests/CSF.Screenplay.Reqnroll.Tests/ServiceProviderAdapterTests.cs b/Tests/CSF.Screenplay.Reqnroll.Tests/ServiceProviderAdapterTests.cs new file mode 100644 index 00000000..cabe1147 --- /dev/null +++ b/Tests/CSF.Screenplay.Reqnroll.Tests/ServiceProviderAdapterTests.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.Extensions.Logging; +using Moq; +using Reqnroll.BoDi; + +namespace CSF.Screenplay +{ + [TestFixture, Parallelizable] + public class ServiceProviderAdapterTests + { + [Test] + public void GetServiceShouldResolveFromTheWrappedContainer() + { + var expected = new object(); + var wrapped = new Mock(); + wrapped.Setup(x => x.Resolve(typeof(object), null)).Returns(expected); + var sut = new ServiceProviderAdapter(wrapped.Object); + + Assert.That(sut.GetService(typeof(object)), Is.SameAs(expected)); + } + + [Test] + public void GetServiceShouldReturnNullIfWrappedContainerThrowsObjectContainerException() + { + var wrapped = new Mock(); + var logger = new Mock>(); + wrapped.Setup(x => x.Resolve(typeof(object), null)).Throws(new ObjectContainerException("Cannot resolve", Array.Empty())); + wrapped.Setup(x => x.Resolve>()).Returns(logger.Object); + var sut = new ServiceProviderAdapter(wrapped.Object); + + using var scrope = Assert.EnterMultipleScope(); + Assert.That(() => sut.GetService(typeof(object)), Throws.Nothing, "Does not throw"); + Assert.That(sut.GetService(typeof(object)), Is.Null, "Returns null"); + } + + [Test] + public void GetServiceShouldGetALoggerIfRegisteredAndTheWrappedContainerThrowsObjectContainerException() + { + var wrapped = new Mock(); + var logger = new Mock>(); + wrapped.Setup(x => x.Resolve(typeof(object), null)).Throws(new ObjectContainerException("Cannot resolve", Array.Empty())); + wrapped.Setup(x => x.Resolve>()).Returns(logger.Object); + var sut = new ServiceProviderAdapter(wrapped.Object); + + sut.GetService(typeof(object)); + + wrapped.Verify(x => x.Resolve>(), Times.Once); + } + + [Test] + public void GetServiceShouldNotThrowIfGettingALoggerThrowsObjectContainerException() + { + var wrapped = new Mock(); + wrapped.Setup(x => x.Resolve(typeof(object), null)).Throws(new ObjectContainerException("Cannot resolve", Array.Empty())); + wrapped.Setup(x => x.Resolve>()).Throws(new ObjectContainerException("Cannot resolve", Array.Empty())); + var sut = new ServiceProviderAdapter(wrapped.Object); + + Assert.That(() => sut.GetService(typeof(object)), Throws.Nothing); + } + + [Test] + public void GetServiceShouldNotCatchAnyOtherKindOfException() + { + var wrapped = new Mock(); + wrapped.Setup(x => x.Resolve(typeof(object), null)).Throws(new InvalidOperationException()); + var sut = new ServiceProviderAdapter(wrapped.Object); + + Assert.That(() => sut.GetService(typeof(object)), Throws.InvalidOperationException); + } + } +} \ No newline at end of file diff --git a/Tests/CSF.Screenplay.Tests/Performables/CopyFileAsAnAssetTests.cs b/Tests/CSF.Screenplay.Tests/Performables/CopyFileAsAnAssetTests.cs index c728fdb0..5cf645d2 100644 --- a/Tests/CSF.Screenplay.Tests/Performables/CopyFileAsAnAssetTests.cs +++ b/Tests/CSF.Screenplay.Tests/Performables/CopyFileAsAnAssetTests.cs @@ -2,7 +2,7 @@ using CSF.Screenplay.Abilities; using CSF.Screenplay.Reporting; using NUnit.Framework.Legacy; -using static CSF.Screenplay.PerforamableBuilder; +using static CSF.Screenplay.PerformableBuilder; namespace CSF.Screenplay.Performables; diff --git a/Tests/CSF.Screenplay.Tests/Performables/SaveAStreamAsAnAssetTests.cs b/Tests/CSF.Screenplay.Tests/Performables/SaveAStreamAsAnAssetTests.cs index c01262e7..6f4b09da 100644 --- a/Tests/CSF.Screenplay.Tests/Performables/SaveAStreamAsAnAssetTests.cs +++ b/Tests/CSF.Screenplay.Tests/Performables/SaveAStreamAsAnAssetTests.cs @@ -2,7 +2,7 @@ using CSF.Screenplay.Abilities; using CSF.Screenplay.Reporting; using NUnit.Framework.Legacy; -using static CSF.Screenplay.PerforamableBuilder; +using static CSF.Screenplay.PerformableBuilder; namespace CSF.Screenplay.Performables; diff --git a/Tests/CSF.Screenplay.Tests/Performables/SaveTextAsAnAssetTests.cs b/Tests/CSF.Screenplay.Tests/Performables/SaveTextAsAnAssetTests.cs index f76e170b..f419e2aa 100644 --- a/Tests/CSF.Screenplay.Tests/Performables/SaveTextAsAnAssetTests.cs +++ b/Tests/CSF.Screenplay.Tests/Performables/SaveTextAsAnAssetTests.cs @@ -2,7 +2,7 @@ using CSF.Screenplay.Abilities; using CSF.Screenplay.Reporting; using NUnit.Framework.Legacy; -using static CSF.Screenplay.PerforamableBuilder; +using static CSF.Screenplay.PerformableBuilder; namespace CSF.Screenplay.Performables; diff --git a/Tests/CSF.Screenplay.Tests/Performables/StopwatchTests.cs b/Tests/CSF.Screenplay.Tests/Performables/StopwatchTests.cs index fe5a85e8..d5e06a0e 100644 --- a/Tests/CSF.Screenplay.Tests/Performables/StopwatchTests.cs +++ b/Tests/CSF.Screenplay.Tests/Performables/StopwatchTests.cs @@ -1,5 +1,5 @@ using CSF.Screenplay.Abilities; -using static CSF.Screenplay.PerforamableBuilder; +using static CSF.Screenplay.PerformableBuilder; using static CSF.Screenplay.PerformanceStarter; namespace CSF.Screenplay.Performables;