diff --git a/.github/workflows/sanity-workflow.yml b/.github/workflows/sanity-workflow.yml new file mode 100644 index 0000000..5a3873b --- /dev/null +++ b/.github/workflows/sanity-workflow.yml @@ -0,0 +1,123 @@ +# Sanity workflow that verifies the xUnit + Reqnroll + Playwright BrowserStack +# SDK sample against a full commit id, mirroring browserstack/csharp-playwright-browserstack. +# Two test runs: +# 1. Public bstackdemo scenario (browserstackLocal: false in yml). +# 2. BrowserStack Local scenario (yml flipped to true; a python http.server +# hosts a tiny title-matching page on port 45454, the SDK starts the tunnel, +# and the test asserts that the cloud browser sees that page through bs-local.com). + +name: xUnit Reqnroll Playwright SDK sanity workflow on workflow_dispatch + +on: + workflow_dispatch: + inputs: + commit_sha: + description: 'The full commit id to build' + required: true + +permissions: + contents: read + checks: write + +jobs: + sanity: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + max-parallel: 1 + matrix: + dotnet: ['8.0.x'] + os: [windows-latest] + name: xUnit Repo ${{ matrix.dotnet }} - ${{ matrix.os }} Sample + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.commit_sha }} + + - uses: actions/github-script@98814c53be79b1d30f795b907e553d8679345975 + id: status-check-in-progress + env: + job_name: xUnit Repo ${{ matrix.dotnet }} - ${{ matrix.os }} Sample + commit_sha: ${{ github.event.inputs.commit_sha }} + with: + github-token: ${{ github.token }} + script: | + const result = await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: process.env.job_name, + head_sha: process.env.commit_sha, + status: 'in_progress' + }).catch((err) => ({status: err.status, response: err.response})); + console.log(`The status-check response : ${result.status} Response : ${JSON.stringify(result.response)}`) + if (result.status !== 201) { + console.log('Failed to create check run') + } + + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ matrix.dotnet }} + + - name: Strip credential placeholders so env vars take effect + # The yml ships with literal YOUR_USERNAME / YOUR_ACCESS_KEY placeholders; + # the .NET SDK only falls back to env vars when those lines are absent. + shell: bash + working-directory: XunitReqnrollPlaywrightBrowserstack.Tests + run: | + sed -i '/^userName:/d; /^accessKey:/d' browserstack.yml + + - name: Install dependencies + run: dotnet build + + - name: Run sample tests (public bstackdemo) + working-directory: XunitReqnrollPlaywrightBrowserstack.Tests + run: dotnet test --filter "FullyQualifiedName~BStackDemoCart" + + - name: Run local tests (BrowserStack Local + python http.server harness) + shell: bash + working-directory: XunitReqnrollPlaywrightBrowserstack.Tests + run: | + set -u + # 1. Stand up a tiny static page with a known . + mkdir -p "$RUNNER_TEMP/bs-local-harness" + cat > "$RUNNER_TEMP/bs-local-harness/index.html" <<'HTML' + <!doctype html> + <html><head><title>BrowserStack Local Test + OK + HTML + ( cd "$RUNNER_TEMP/bs-local-harness" && python -m http.server 45454 ) & + HTTP_PID=$! + trap 'kill "$HTTP_PID" 2>/dev/null || true' EXIT + sleep 2 + # 2. Flip the SDK Local toggle so the SDK starts/stops the tunnel. + sed -i 's/^browserstackLocal: false/browserstackLocal: true/' browserstack.yml + # 3. Run only the local scenario; cloud browser reaches the harness through bs-local.com. + dotnet test --filter "FullyQualifiedName~BStackLocalSample" + + - if: always() + uses: actions/github-script@98814c53be79b1d30f795b907e553d8679345975 + id: status-check-completed + env: + conclusion: ${{ job.status }} + job_name: xUnit Repo ${{ matrix.dotnet }} - ${{ matrix.os }} Sample + commit_sha: ${{ github.event.inputs.commit_sha }} + with: + github-token: ${{ github.token }} + script: | + const result = await github.rest.checks.create({ + owner: context.repo.owner, + repo: context.repo.repo, + name: process.env.job_name, + head_sha: process.env.commit_sha, + status: 'completed', + conclusion: process.env.conclusion + }).catch((err) => ({status: err.status, response: err.response})); + console.log(`The status-check response : ${result.status} Response : ${JSON.stringify(result.response)}`) + if (result.status !== 201) { + console.log('Failed to create check run') + } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cfb67c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +bin/ +obj/ +log/ +TestResults/ +.vs/ +.vscode/ +.idea/ +.config/ +.browserstack/ +*.user +*.suo +.DS_Store +browserstack.err + +# Reqnroll-generated code-behind for .feature files +**/Features/*.feature.cs diff --git a/README.md b/README.md index bc789fb..30c66c8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,97 @@ # xunit-reqnroll-playwright-browserstack -Sample repo for customers + +This sample shows how to run [xUnit](https://xunit.net/) + [Reqnroll](https://reqnroll.net/) + [Playwright](https://playwright.dev/dotnet) tests on BrowserStack using the [BrowserStack .NET SDK](https://www.nuget.org/packages/BrowserStack.TestAdapter). The SDK reads `browserstack.yml`, fans your scenarios out across the platforms listed there, starts and stops BrowserStack Local automatically, and reports test status to the BrowserStack dashboard. Your test code stays pure `Microsoft.Playwright` + Reqnroll -- no manual `ConnectAsync`, no caps in code. + +![BrowserStack Logo](https://d98b8t1nnulk5.cloudfront.net/production/images/layout/logo-header.png?1469004780) + +## Run Sample Build + +* Clone the repo +* Open the solution `XunitReqnrollPlaywrightBrowserstack.sln` in Visual Studio (or your IDE of choice) +* Build the solution (`dotnet build`) +* Replace the `userName` and `accessKey` placeholders in `browserstack.yml` with your [BrowserStack Username and Access Key](https://www.browserstack.com/accounts/settings). Alternatively, remove those two lines from the yml and set `BROWSERSTACK_USERNAME` and `BROWSERSTACK_ACCESS_KEY` as environment variables -- the SDK falls back to env vars only when the yml fields are absent + +### Running your tests from CLI + +```sh +cd XunitReqnrollPlaywrightBrowserstack.Tests +dotnet test +``` + +The sample runs across both platforms declared in `browserstack.yml` (Windows 11 / Chrome and macOS / WebKit) in parallel. + +Understand how many parallel sessions you need by using our [Parallel Test Calculator](https://www.browserstack.com/automate/parallel-calculator?ref=github). + +### Testing a private host (BrowserStack Local) + +If your app lives on `localhost`, a staging host, or behind a firewall, set `browserstackLocal: true` in `browserstack.yml` and rerun `dotnet test`. The SDK starts and stops the BrowserStack Local tunnel for you -- no manual binary download or lifecycle management. Then point your scenarios at `http://bs-local.com:/` (a hostname BrowserStack Local resolves to your machine) instead of a public URL. + +## Integrate your test suite + +This repository uses the BrowserStack SDK to run tests on BrowserStack. To wire the SDK into your own test suite: + +* Create a `browserstack.yml` at the project root with your BrowserStack credentials and platform list (see this repo for a working template) +* Add the `BrowserStack.TestAdapter` NuGet package: + + ```sh + dotnet add package BrowserStack.TestAdapter + ``` + +* Build the project (`dotnet build`); the SDK installs the `browserstack-sdk` dotnet tool and patches the test assembly so Playwright launches are routed to BrowserStack at runtime + +## How the SDK changes things + +- **One `browserstack.yml`** declares platforms, parallelism, the Local toggle, and reporting; the SDK picks them up automatically +- **The SDK runs platforms in parallel for you** -- one xUnit run per `(platform x parallelsPerPlatform)` cell, no per-platform branching needed +- **The SDK rewrites Playwright launches** -- `Hooks/PlaywrightHooks.cs` calls `pw.Chromium.LaunchAsync()` and the SDK transparently redirects to the per-platform browser configured in the yml (`chrome` / `playwright-webkit` / `playwright-firefox` / etc.). No `Chromium.ConnectAsync(wss_url)` plumbing +- **The SDK starts and stops BrowserStack Local** when `browserstackLocal: true` -- no manual tunnel lifecycle management +- **Reqnroll generates xUnit test classes** from `.feature` files at build time, so behaviour-driven scenarios run as standard xUnit tests under `dotnet test` + +## Repo layout + +``` +. +├── XunitReqnrollPlaywrightBrowserstack.sln +└── XunitReqnrollPlaywrightBrowserstack.Tests/ + ├── XunitReqnrollPlaywrightBrowserstack.Tests.csproj + ├── browserstack.yml # SDK config: credentials, platforms, Local toggle, reporting + ├── Features/ + │ └── Sample.feature # bstackdemo add-to-cart scenario + ├── StepDefinitions/ + │ └── SampleSteps.cs + └── Hooks/ + └── PlaywrightHooks.cs # creates IPage per scenario; SDK routes the launch +``` + +## Notes + +* You can view your test results on the [BrowserStack Automate dashboard](https://www.browserstack.com/automate) +* To test on a different set of browsers, see our [list of supported browsers and platforms](https://www.browserstack.com/list-of-browsers-and-platforms?product=automate) +* You can export the environment variables for the Username and Access Key of your BrowserStack account: + + * For Unix-like or Mac machines: + ```sh + export BROWSERSTACK_USERNAME= && + export BROWSERSTACK_ACCESS_KEY= + ``` + * For Windows Cmd: + ```cmd + set BROWSERSTACK_USERNAME= + set BROWSERSTACK_ACCESS_KEY= + ``` + * For Windows Powershell: + ```powershell + $env:BROWSERSTACK_USERNAME= + $env:BROWSERSTACK_ACCESS_KEY= + ``` + +## Further Reading + +- [xUnit](https://xunit.net/) +- [Reqnroll](https://reqnroll.net/) +- [Playwright .NET](https://playwright.dev/dotnet/) +- [BrowserStack documentation for Playwright in C#](https://www.browserstack.com/docs/automate/playwright/getting-started/c-sharp) +- [BrowserStack.TestAdapter on NuGet](https://www.nuget.org/packages/BrowserStack.TestAdapter) +- [NUnit reference sample](https://github.com/browserstack/csharp-playwright-browserstack) + +Happy Testing! diff --git a/XunitReqnrollPlaywrightBrowserstack.Tests/Features/LocalSample.feature b/XunitReqnrollPlaywrightBrowserstack.Tests/Features/LocalSample.feature new file mode 100644 index 0000000..5a74d44 --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.Tests/Features/LocalSample.feature @@ -0,0 +1,9 @@ +Feature: BStackLocalSample + + As a developer testing a private host + I want BrowserStack Local to tunnel my localhost to BrowserStack + So that the cloud browser can reach a page only my machine can serve + + Scenario: Reach a private host via BrowserStack Local + Given I open the local sample page on bs-local + Then the local sample page title contains "BrowserStack Local" diff --git a/XunitReqnrollPlaywrightBrowserstack.Tests/Features/Sample.feature b/XunitReqnrollPlaywrightBrowserstack.Tests/Features/Sample.feature new file mode 100644 index 0000000..c9e7c6c --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.Tests/Features/Sample.feature @@ -0,0 +1,10 @@ +Feature: BStackDemo cart + + As a shopper on bstackdemo.com + I want to add an item to my cart + So that I can verify the cart shows what I picked + + Scenario: Add the first item to cart + Given I open the bstackdemo home page + When I add the first product to the cart + Then the cart shows 1 item that matches the product I added diff --git a/XunitReqnrollPlaywrightBrowserstack.Tests/Hooks/PlaywrightHooks.cs b/XunitReqnrollPlaywrightBrowserstack.Tests/Hooks/PlaywrightHooks.cs new file mode 100644 index 0000000..cf206b1 --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.Tests/Hooks/PlaywrightHooks.cs @@ -0,0 +1,31 @@ +using Microsoft.Playwright; +using Reqnroll; + +namespace XunitReqnrollPlaywrightBrowserstack.Tests.Hooks; + +[Binding] +public class PlaywrightHooks +{ + private readonly ScenarioContext _scenario; + private IPlaywright? _pw; + private IBrowser? _browser; + + public PlaywrightHooks(ScenarioContext scenario) => _scenario = scenario; + + [BeforeScenario] + public async Task SetUp() + { + _pw = await Playwright.CreateAsync(); + _browser = await _pw.Chromium.LaunchAsync(); + var context = await _browser.NewContextAsync(); + var page = await context.NewPageAsync(); + _scenario.Set(page, "page"); + } + + [AfterScenario] + public async Task TearDown() + { + if (_browser is not null) await _browser.CloseAsync(); + _pw?.Dispose(); + } +} diff --git a/XunitReqnrollPlaywrightBrowserstack.Tests/StepDefinitions/LocalSampleSteps.cs b/XunitReqnrollPlaywrightBrowserstack.Tests/StepDefinitions/LocalSampleSteps.cs new file mode 100644 index 0000000..a3dbc14 --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.Tests/StepDefinitions/LocalSampleSteps.cs @@ -0,0 +1,28 @@ +using Microsoft.Playwright; +using Reqnroll; + +namespace XunitReqnrollPlaywrightBrowserstack.Tests.StepDefinitions; + +// Mirrors browserstack/csharp-playwright-browserstack -> SampleLocalTest.cs: +// page.GotoAsync("http://bs-local.com:45454/") + title.Contains("BrowserStack Local") +[Binding] +public class LocalSampleSteps +{ + private readonly ScenarioContext _scenario; + private IPage Page => _scenario.Get("page"); + + public LocalSampleSteps(ScenarioContext scenario) => _scenario = scenario; + + [Given(@"I open the local sample page on bs-local")] + public async Task OpenLocalSamplePage() + { + await Page.GotoAsync("http://bs-local.com:45454/"); + } + + [Then(@"the local sample page title contains ""(.*)""")] + public async Task LocalSamplePageTitleContains(string expected) + { + var actual = await Page.TitleAsync(); + Assert.Contains(expected, actual); + } +} diff --git a/XunitReqnrollPlaywrightBrowserstack.Tests/StepDefinitions/SampleSteps.cs b/XunitReqnrollPlaywrightBrowserstack.Tests/StepDefinitions/SampleSteps.cs new file mode 100644 index 0000000..e57d31f --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.Tests/StepDefinitions/SampleSteps.cs @@ -0,0 +1,39 @@ +using Microsoft.Playwright; +using Reqnroll; + +namespace XunitReqnrollPlaywrightBrowserstack.Tests.StepDefinitions; + +[Binding] +public class SampleSteps +{ + private readonly ScenarioContext _scenario; + private IPage Page => _scenario.Get("page"); + private string _productTitle = string.Empty; + + public SampleSteps(ScenarioContext scenario) => _scenario = scenario; + + [Given(@"I open the bstackdemo home page")] + public async Task OpenHome() + { + await Page.GotoAsync("https://bstackdemo.com/"); + } + + [When(@"I add the first product to the cart")] + public async Task AddFirstProduct() + { + var firstProduct = Page.Locator("[id=\"\\31 \"]"); + var titles = await firstProduct.Locator(".shelf-item__title").AllInnerTextsAsync(); + _productTitle = titles[0]; + await firstProduct.GetByText("Add to Cart").ClickAsync(); + } + + [Then(@"the cart shows 1 item that matches the product I added")] + public async Task CartHasOneMatchingItem() + { + var quantity = await Page.Locator(".bag__quantity").InnerTextAsync(); + Assert.Equal("1", quantity); + + var cartTitle = await Page.Locator(".shelf-item__details").Locator(".title").InnerTextAsync(); + Assert.Equal(_productTitle, cartTitle); + } +} diff --git a/XunitReqnrollPlaywrightBrowserstack.Tests/XunitReqnrollPlaywrightBrowserstack.Tests.csproj b/XunitReqnrollPlaywrightBrowserstack.Tests/XunitReqnrollPlaywrightBrowserstack.Tests.csproj new file mode 100644 index 0000000..5a366c6 --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.Tests/XunitReqnrollPlaywrightBrowserstack.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/XunitReqnrollPlaywrightBrowserstack.Tests/browserstack.yml b/XunitReqnrollPlaywrightBrowserstack.Tests/browserstack.yml new file mode 100644 index 0000000..c1c21db --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.Tests/browserstack.yml @@ -0,0 +1,53 @@ +# ============================= +# Set BrowserStack Credentials +# ============================= +# Replace the placeholders below with your real BrowserStack credentials, +# or remove these two lines entirely and set BROWSERSTACK_USERNAME and +# BROWSERSTACK_ACCESS_KEY as environment variables. NOTE: when these +# fields are present in the yml, the SDK uses their literal values -- +# env vars override them only if the lines are absent. +userName: YOUR_USERNAME +accessKey: YOUR_ACCESS_KEY + +# ====================== +# BrowserStack Reporting +# ====================== +projectName: BrowserStack Samples +buildName: xunit-reqnroll-playwright-browserstack +buildIdentifier: '#${BUILD_NUMBER}' + +# ======================================= +# Platforms (Browsers / Devices to test) +# ======================================= +# Each entry is one cross-browser cell. The SDK runs `parallelsPerPlatform` +# parallel sessions per entry. Customer code in Hooks/PlaywrightHooks.cs calls +# `pw.Chromium.LaunchAsync()` -- the SDK transparently routes the launch to +# the per-platform browser configured here at runtime. +platforms: + - os: Windows + osVersion: 11 + browserName: chrome + browserVersion: latest + - os: OS X + osVersion: Ventura + browserName: playwright-webkit + browserVersion: latest + +parallelsPerPlatform: 1 + +# =================================== +# BrowserStack Local (private hosts) +# =================================== +# Set to true to test localhost / staging hosts. The SDK starts and stops +# the BrowserStack Local tunnel for you -- no manual binary management. +browserstackLocal: false + +# =========== +# Diagnostics +# =========== +debug: false +networkLogs: false +consoleLogs: errors + +# Identifier so BrowserStack can tag the sample source -- please leave as-is. +source: xunit-reqnroll-playwright:sample-master:v1.0 diff --git a/XunitReqnrollPlaywrightBrowserstack.sln b/XunitReqnrollPlaywrightBrowserstack.sln new file mode 100644 index 0000000..daea4cc --- /dev/null +++ b/XunitReqnrollPlaywrightBrowserstack.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XunitReqnrollPlaywrightBrowserstack.Tests", "XunitReqnrollPlaywrightBrowserstack.Tests\XunitReqnrollPlaywrightBrowserstack.Tests.csproj", "{A6D6CB01-9FC5-4A8E-94E8-1C450639104D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A6D6CB01-9FC5-4A8E-94E8-1C450639104D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6D6CB01-9FC5-4A8E-94E8-1C450639104D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6D6CB01-9FC5-4A8E-94E8-1C450639104D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6D6CB01-9FC5-4A8E-94E8-1C450639104D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal