diff --git a/.github/workflows/admin-sample.cd.yml b/.github/workflows/admin-sample.cd.yml
index 00c444c49d1..cc934caa97d 100644
--- a/.github/workflows/admin-sample.cd.yml
+++ b/.github/workflows/admin-sample.cd.yml
@@ -18,193 +18,6 @@ permissions:
jobs:
- build_api_blazor:
- name: build api + blazor web
- runs-on: windows-2025
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - name: Create project from Boilerplate
- run: |
- cd src/Templates/Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ../../../ && dotnet new bit-bp --name AdminPanel --database PostgreSQL --module Admin --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --notification --captcha reCaptcha --signalR --ads --api Standalone --brouter true
-
- - name: Use Bit.ResxTranslator
- run: |
- cd AdminPanel
- dotnet tool install --global Bit.ResxTranslator --prerelease
- bit-resx-translate
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'AdminPanel/**/appsettings*json'
- env:
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- WebAppRender.BlazorMode: 'BlazorWebAssembly'
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
- AdsPushVapid.PublicKey: ${{ secrets.ADMINPANEL_PUBLIC_VAPIDKEY }}
- ApplicationInsights.ConnectionString: ${{ secrets.APPLICATION_INSIGHTS_CONNECTION_STRING }}
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Install wasm
- run: cd src && dotnet workload install wasm-tools
-
- - name: Configure text embedding
- run: |
- sed -i 's/IsEmbeddingEnabled = false/IsEmbeddingEnabled = true/' AdminPanel/src/Server/AdminPanel.Server.Api/Infrastructure/Data/AppDbContext.cs
-
- - name: Generate CSS/JS files
- run: |
- dotnet build AdminPanel/src/Client/AdminPanel.Client.Core/AdminPanel.Client.Core.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" --no-restore -c Release
- dotnet build AdminPanel/src/Client/AdminPanel.Client.Web/AdminPanel.Client.Web.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" --no-restore -c Release
-
- - name: Publish Server Web
- run: dotnet publish AdminPanel/src/Server/AdminPanel.Server.Web/AdminPanel.Server.Web.csproj -c Release -o server-web -p:Version="${{ vars.APP_VERSION}}"
-
- - name: Publish Server API
- run: dotnet publish AdminPanel/src/Server/AdminPanel.Server.Api/AdminPanel.Server.Api.csproj -c Release -o server-api -p:Version="${{ vars.APP_VERSION}}"
-
- - name: Upload server web artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: AdminPanel
- path: server-web
- include-hidden-files: true # Required for wwwroot/.well-known folder
-
- - name: Upload server api artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: AdminPanelApi
- path: server-api
- include-hidden-files: true # Required for wwwroot/.well-known folder
-
- deploy_blazor_wasm_standalone:
- name: build blazor wasm standalone
- runs-on: ubuntu-24.04
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - name: Create project from Boilerplate
- run: |
- cd src/Templates/Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ../../../ && dotnet new bit-bp --name AdminPanel --database PostgreSQL --module Admin --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --notification --captcha reCaptcha --signalR --ads --brouter false --theme Material
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'AdminPanel/**/appsettings*json'
- env:
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
- AdsPushVapid.PublicKey: ${{ secrets.ADMINPANEL_PUBLIC_VAPIDKEY }}
- ApplicationInsights.ConnectionString: ${{ secrets.APPLICATION_INSIGHTS_CONNECTION_STRING }}
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Install wasm
- run: cd src && dotnet workload install wasm-tools
-
- - name: Generate CSS/JS files
- run: dotnet build AdminPanel/src/Client/AdminPanel.Client.Core/AdminPanel.Client.Core.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" -p:WasmEnableSIMD=true --no-restore -c Release
-
- - name: Publish
- run: dotnet publish AdminPanel/src/Client/AdminPanel.Client.Web/AdminPanel.Client.Web.csproj -c Release -o client -p:WasmEnableSIMD=true -p:Version="${{ vars.APP_VERSION}}"
-
- - name: Upload to asw
- run: |
- npm install -g @azure/static-web-apps-cli
- swa deploy --deployment-token ${{ secrets.ADMINPANEL_ASW_TOKEN }} --env production --app-location client/wwwroot
-
- build_blazor_hybrid_windows:
- name: build blazor hybrid (windows)
- runs-on: windows-2025
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src\global.json
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Create project from Boilerplate
- run: |
- cd src\Templates\Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ..\..\..\ && dotnet new bit-bp --name AdminPanel --database PostgreSQL --module Admin --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --captcha reCaptcha --signalR --ads --brouter true
-
- - name: Use Bit.ResxTranslator
- run: |
- cd AdminPanel
- dotnet tool install --global Bit.ResxTranslator --prerelease
- bit-resx-translate
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'AdminPanel\**\appsettings*json'
- env:
- WebAppUrl: ${{ env.SERVER_WEB_ADDRESS }}
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- Logging.Sentry.Dsn: ${{ secrets.ADMINPANEL_SENTRY_DSN }}
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
- WindowsUpdate.FilesUrl: https://windows-adminpanel.bitplatform.dev
-
- - name: Generate CSS/JS files
- run: dotnet build AdminPanel\src\Client\AdminPanel.Client.Core\AdminPanel.Client.Core.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" --no-restore -c Release
-
- - name: Publish
- run: |
- cd AdminPanel\src\Client\AdminPanel.Client.Windows\
- dotnet publish AdminPanel.Client.Windows.csproj -c Release -o .\publish-result -r win-x64 -p:Version="${{ vars.APP_VERSION}}" -p:PublishReadyToRun=true -p:PublishReadyToRunComposite=true --self-contained
- dotnet tool restore
- dotnet vpk pack -u AdminPanel.Client.Windows -v "${{ vars.APP_VERSION }}" -p .\publish-result -e AdminPanel.Client.Windows.exe -r win-x64 --framework webview2 --icon .\wwwroot\favicon.ico --packTitle 'AdminPanel'
-
- - name: Upload artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: WinAdminPanel
- path: AdminPanel\src\Client\AdminPanel.Client.Windows\Releases
-
build_blazor_hybrid_android:
name: build blazor hybrid (android)
runs-on: ubuntu-24.04
diff --git a/.github/workflows/blazorui.demo.cd.yml b/.github/workflows/blazorui.demo.cd.yml
index 8adf3b9841b..b7c4dab8bce 100644
--- a/.github/workflows/blazorui.demo.cd.yml
+++ b/.github/workflows/blazorui.demo.cd.yml
@@ -12,96 +12,6 @@ permissions:
jobs:
- build_api_blazor:
- name: build api + blazor web
- runs-on: windows-2025
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - name: Update appsettings.json api server address
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/appsettings.json'
- env:
- ApiServerAddress: ${{ env.SERVER_ADDRESS }}
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Install wasm
- run: cd src && dotnet workload install wasm-tools
-
- - name: Enable pre rendering
- run: sed -i 's/public static readonly bool PrerenderEnabled = false;/public static readonly bool PrerenderEnabled = true;/g' src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Services/AppRenderMode.cs
-
- - name: Generate CSS/JS files
- run: dotnet build src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Bit.BlazorUI.Demo.Server.csproj -p:Version="${{ vars.APP_VERSION}}" -c Release
-
- - name: Publish
- run: dotnet publish src/BlazorUI/Demo/Bit.BlazorUI.Demo.Server/Bit.BlazorUI.Demo.Server.csproj -c Release -p:Version="${{ vars.APP_VERSION}}" -o server
-
- - name: Upload server artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: BlazorUIDemo
- path: server
- include-hidden-files: true # Required for wwwroot/.well-known folder
-
- build_blazor_hybrid_windows:
- name: build blazor hybrid (windows)
- runs-on: windows-2025
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src\global.json
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Update appsettings.json api server address
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'src\BlazorUI\Demo\Client\Bit.BlazorUI.Demo.Client.Core\appsettings.json'
- env:
- ApiServerAddress: ${{ env.SERVER_ADDRESS }}
- WindowsUpdateSettings.FilesUrl: https://windows-components.bitplatform.dev
-
- - name: Generate CSS/JS files
- run: dotnet build src\BlazorUI\Demo\Client\Bit.BlazorUI.Demo.Client.Core\Bit.BlazorUI.Demo.Client.Core.csproj -p:Version="${{ vars.APP_VERSION}}" -c Release
-
- - name: Publish
- run: |
- cd src\BlazorUI\Demo\Client\Bit.BlazorUI.Demo.Client.Windows\
- dotnet publish Bit.BlazorUI.Demo.Client.Windows.csproj -c Release -o .\publish-result -r win-x86 -p:Version="${{ vars.APP_VERSION}}" -p:CompressionEnabled=false -p:PublishReadyToRun=true -p:PublishReadyToRunComposite=true --self-contained
- dotnet tool restore
- dotnet vpk pack -u Bit.BlazorUI.Demo.Client.Windows -v "${{ vars.APP_VERSION }}" -p .\publish-result -e Bit.BlazorUI.Demo.Client.Windows.exe -r win-x86 --framework webview2 --icon .\wwwroot\favicon.ico --packTitle 'Bit Blazor UI'
-
- - name: Upload artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: WinBlazorUIDemo
- path: src\BlazorUI\Demo\Client\Bit.BlazorUI.Demo.Client.Windows\Releases
-
build_blazor_hybrid_android:
name: build blazor hybrid (android)
runs-on: ubuntu-24.04
diff --git a/.github/workflows/todo-sample.cd.yml b/.github/workflows/todo-sample.cd.yml
index 9b2bb140ec5..18b476ed135 100644
--- a/.github/workflows/todo-sample.cd.yml
+++ b/.github/workflows/todo-sample.cd.yml
@@ -17,341 +17,6 @@ permissions:
jobs:
- build_api:
- name: build api
- runs-on: windows-2025
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Create project from Boilerplate
- run: |
- cd src/Templates/Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ../../../ && dotnet new bit-bp --name TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --notification --captcha reCaptcha --offlineDb --signalR --ads --api Standalone --redis --multitenant false --brouter false
-
- - name: Use Bit.ResxTranslator
- run: |
- cd TodoSample
- dotnet tool install --global Bit.ResxTranslator --prerelease
- bit-resx-translate
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'TodoSample/**/appsettings*json'
- env:
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- AdsPushVapid.PublicKey: ${{ secrets.TODO_PUBLIC_VAPIDKEY }}
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
- ApplicationInsights.ConnectionString: ${{ secrets.APPLICATION_INSIGHTS_CONNECTION_STRING }}
-
- - name: Publish Server API
- run: dotnet publish TodoSample/src/Server/TodoSample.Server.Api/TodoSample.Server.Api.csproj -c Release -o server-api -p:Version="${{ vars.APP_VERSION}}"
-
- - name: Upload server api artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: TodoApi
- path: server-api
- include-hidden-files: true # Required for wwwroot/.well-known folder
-
- build_blazor:
- name: build blazor web
- runs-on: windows-2025
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Create project from Boilerplate
- run: |
- cd src/Templates/Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ../../../ && dotnet new bit-bp --name TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --notification --captcha reCaptcha --signalR --ads --api Standalone --multitenant false --brouter false
-
- - name: Use Bit.ResxTranslator
- run: |
- cd TodoSample
- dotnet tool install --global Bit.ResxTranslator --prerelease
- bit-resx-translate
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'TodoSample/**/appsettings*json'
- env:
- WebAppRender.PrerenderEnabled: true
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- WebAppRender.BlazorMode: 'BlazorWebAssembly'
- AdsPushVapid.PublicKey: ${{ secrets.TODO_PUBLIC_VAPIDKEY }}
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
- ApplicationInsights.ConnectionString: ${{ secrets.APPLICATION_INSIGHTS_CONNECTION_STRING }}
-
- - name: Install wasm
- run: cd src && dotnet workload install wasm-tools
-
- - name: Configure bswup # Always AlwaysPrerender is the last item, so overriding it has the desired effect
- run: |
- sed -i "s/\/\/ self.mode = 'AlwaysPrerender'/self.mode = 'InitialPrerender'/g" TodoSample/src/Client/TodoSample.Client.Web/wwwroot/service-worker.published.js
-
- - name: Generate CSS/JS files
- run: |
- dotnet build TodoSample/src/Client/TodoSample.Client.Core/TodoSample.Client.Core.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" --no-restore -c Release
- dotnet build TodoSample/src/Client/TodoSample.Client.Web/TodoSample.Client.Web.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" --no-restore -c Release
-
- - name: Publish Server Web
- run: dotnet publish TodoSample/src/Server/TodoSample.Server.Web/TodoSample.Server.Web.csproj -c Release -o server-web -p:Version="${{ vars.APP_VERSION}}"
-
- - name: Upload server web artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: Todo
- path: server-web
- include-hidden-files: true # Required for wwwroot/.well-known folder
-
- deploy_blazor_wasm_standalone_aot:
- name: build blazor wasm standalone (AOT)
- runs-on: ubuntu-24.04
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - name: Create project from Boilerplate
- run: |
- cd src/Templates/Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ../../../ && dotnet new bit-bp --name TodoSample --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --notification --captcha reCaptcha --ads --multitenant false --brouter false --theme Cupertino
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'TodoSample/**/appsettings*json'
- env:
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- AdsPushVapid.PublicKey: ${{ secrets.TODO_PUBLIC_VAPIDKEY }}
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Install wasm
- run: cd src && dotnet workload install wasm-tools
-
- - name: Generate CSS/JS files
- run: dotnet build TodoSample/src/Client/TodoSample.Client.Core/TodoSample.Client.Core.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" -p:WasmEnableSIMD=true --no-restore -c Release
-
- - name: Publish
- run: dotnet publish TodoSample/src/Client/TodoSample.Client.Web/TodoSample.Client.Web.csproj -c Release -o client -p:Version="${{ vars.APP_VERSION}}" -p:RunAOTCompilation=true -p:InvariantGlobalization=true -p:WasmEnableSIMD=true
-
- - name: Upload to asw
- run: |
- npm install -g @azure/static-web-apps-cli
- swa deploy --deployment-token ${{ secrets.TODO_AOT_ASW_TOKEN }} --env production --app-location client/wwwroot
-
- deploy_blazor_wasm_standalone_offlineDb:
- name: build blazor wasm standalone (Offline database)
- runs-on: ubuntu-24.04
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - name: Create project from Boilerplate
- run: |
- cd src/Templates/Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ../../../ && dotnet new bit-bp --name TodoSample --database PostgreSQL --sample --appInsights --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --notification --captcha reCaptcha --offlineDb --signalR --ads --multitenant false --brouter false --theme Fluent
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'TodoSample/**/appsettings*json'
- env:
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- AdsPushVapid.PublicKey: ${{ secrets.TODO_PUBLIC_VAPIDKEY }}
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Install wasm
- run: cd src && dotnet workload install wasm-tools
-
- - name: Configure bswup # Always AlwaysPrerender is the last item, so overriding it has the desired effect
- run: |
- sed -i "s/\/\/ self.mode = 'AlwaysPrerender'/self.mode = 'FullOffline'/g" TodoSample/src/Client/TodoSample.Client.Web/wwwroot/service-worker.published.js
-
- - name: Optimize DbContext
- run: |
- cd TodoSample/src/Server/TodoSample.Server.Web && dotnet tool restore && dotnet build -c Release && dotnet ef dbcontext optimize --context AppOfflineDbContext --output-dir Infrastructure/Data/CompiledModel --namespace TodoSample.Client.Core.Infrastructure.Data --project ../../Client/TodoSample.Client.Core/TodoSample.Client.Core.csproj --verbose
-
- - name: Publish
- run: dotnet publish TodoSample/src/Client/TodoSample.Client.Web/TodoSample.Client.Web.csproj -c Release -o client -p:Version="${{ vars.APP_VERSION}}" -p:WasmEnableSIMD=true -p:InvariantGlobalization=true
-
- - name: Upload to asw
- run: |
- npm install -g @azure/static-web-apps-cli
- swa deploy --deployment-token ${{ secrets.TODO_OFFLINE_ASW_TOKEN }} --env production --app-location client/wwwroot
-
- deploy_blazor_wasm_standalone_small:
- name: build blazor wasm standalone (small)
- runs-on: ubuntu-24.04
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - name: Create project from Boilerplate
- run: |
- cd src/Templates/Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ../../../ && dotnet new bit-bp --name TodoSample --database PostgreSQL --sample --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --notification --captcha reCaptcha --ads --multitenant false --brouter false
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'TodoSample/**/appsettings*json'
- env:
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- AdsPushVapid.PublicKey: ${{ secrets.TODO_PUBLIC_VAPIDKEY }}
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Install wasm
- run: cd src && dotnet workload install wasm-tools
-
- - name: Generate CSS/JS files
- run: dotnet build TodoSample/src/Client/TodoSample.Client.Core/TodoSample.Client.Core.csproj -t:BeforeBuildTasks -p:WasmEnableSIMD=true -p:Version="${{ vars.APP_VERSION}}" --no-restore -c Release
-
- - name: Publish
- run: dotnet publish TodoSample/src/Client/TodoSample.Client.Web/TodoSample.Client.Web.csproj -c Release -o client -p:Version="${{ vars.APP_VERSION}}" -p:WasmEnableSIMD=true -p:InvariantGlobalization=true
-
- - name: Upload to asw
- run: |
- npm install -g @azure/static-web-apps-cli
- swa deploy --deployment-token ${{ secrets.TODO_SMALL_ASW_TOKEN }} --env production --app-location client/wwwroot
-
- build_blazor_hybrid_windows:
- name: build blazor hybrid (windows)
- runs-on: windows-2025
-
- steps:
-
- - name: Checkout source code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- with:
- persist-credentials: false
-
- - name: Setup .NET
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
- with:
- global-json-file: src/global.json
-
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- with:
- node-version: 24
-
- - name: Create project from Boilerplate
- run: |
- cd src\Templates\Boilerplate && dotnet build -c Release
- dotnet pack -c Release -o . -p:ReleaseVersion=0.0.0 -p:PackageVersion=0.0.0
- dotnet new install Bit.Boilerplate.0.0.0.nupkg
- cd ..\..\..\ && dotnet new bit-bp --name TodoSample --database PostgreSQL --sample --sentry --apiServerUrl ${{ env.SERVER_API_ADDRESS }} --webAppUrl ${{ env.SERVER_WEB_ADDRESS }} --filesStorage AzureBlobStorage --captcha reCaptcha --signalR --ads --multitenant false --brouter false
-
- - name: Use Bit.ResxTranslator
- run: |
- cd TodoSample
- dotnet tool install --global Bit.ResxTranslator --prerelease
- bit-resx-translate
-
- - name: Update core appsettings.json
- uses: devops-actions/variable-substitution@ae7b1f3676ae374dc70282e6b9650bc50b611222 # v1.2
- with:
- files: 'TodoSample\**\appsettings*json'
- env:
- WebAppUrl: ${{ env.SERVER_WEB_ADDRESS }}
- ServerAddress: ${{ env.SERVER_API_ADDRESS }}
- Logging.Sentry.Dsn: ${{ secrets.TODO_SENTRY_DSN }}
- WindowsUpdate.FilesUrl: https://windows-todo.bitplatform.dev
- GoogleRecaptchaSiteKey: ${{ secrets.GOOGLE_RECAPTCHA_SITE_KEY }}
-
- - name: Generate CSS/JS files
- run: dotnet build TodoSample\src\Client\TodoSample.Client.Core\TodoSample.Client.Core.csproj -t:BeforeBuildTasks -p:Version="${{ vars.APP_VERSION}}" --no-restore -c Release
-
- - name: Publish
- run: |
- cd TodoSample\src\Client\TodoSample.Client.Windows\
- dotnet publish TodoSample.Client.Windows.csproj -c Release -o .\publish-result -r win-x86 -p:Version="${{ vars.APP_VERSION}}"
- dotnet tool restore
- dotnet vpk pack -u TodoSample.Client.Windows -v "${{ vars.APP_VERSION }}" -p .\publish-result -e TodoSample.Client.Windows.exe -r win-x86 --framework webview2 --icon .\wwwroot\favicon.ico --packTitle TodoSample
-
- - name: Upload artifact
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: WinTodo
- path: TodoSample\src\Client\TodoSample.Client.Windows\Releases
-
build_blazor_hybrid_android:
name: build blazor hybrid (android)
runs-on: ubuntu-24.04
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Maui/Bit.BlazorUI.Demo.Client.Maui.csproj b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Maui/Bit.BlazorUI.Demo.Client.Maui.csproj
index 5bfcc0c35ca..eef25852f64 100644
--- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Maui/Bit.BlazorUI.Demo.Client.Maui.csproj
+++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Maui/Bit.BlazorUI.Demo.Client.Maui.csproj
@@ -19,6 +19,8 @@
com.bitplatform.BlazorUI.Demo
AC87AA5B-4B37-4E52-8468-2D5DF24AF258
+ false
+
True
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json
index 88ee5b1decc..f895a1f916b 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/.template.config/template.json
@@ -609,6 +609,9 @@
"**/*.nuspec",
"src/Server/**/Data/Migrations/**",
"src/**/App_Data/**",
+ // E2E tests against the deployed demo apps (adminpanel/todo/sales.bitplatform.dev and friends),
+ // which only exist for this repository - a generated project has no such deployments.
+ "src/Internal/**",
// Needs ProductController (Admin module) and ProductViewController + the product page (Sales
// module) at once, and "module" is a single choice, so it can only ever run against the
// template's own source tree.
@@ -736,6 +739,7 @@
"src/Tests/Features/Identity/EmailUniquenessIndexTests.cs",
"src/Tests/Features/Identity/TestAccountUtils.cs",
"src/Tests/Features/Identity/AccountErasureTests.cs",
+ "src/Tests/Features/Identity/UnconfirmedUsersRetentionTests.cs",
"src/Tests/Features/Attachments/AttachmentMetadataStrippingTests.cs",
"src/Tests/Features/Identity/RoleAdministrationGuardTests.cs",
"src/Tests/Features/Identity/ConfirmPageTwoFactorTests.cs",
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.Web.slnf b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.Web.slnf
index 9ad01f851f5..50814f92dba 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.Web.slnf
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.Web.slnf
@@ -4,6 +4,9 @@
"projects": [
"src\\Client\\Boilerplate.Client.Core\\Boilerplate.Client.Core.csproj",
"src\\Client\\Boilerplate.Client.Web\\Boilerplate.Client.Web.csproj",
+//#if (IsInsideProjectTemplate == true)
+ "src\\Internal\\Boilerplate.Tests.E2E\\Boilerplate.Tests.E2E.csproj",
+//#endif
//#if (aspire == true)
"src\\Server\\Boilerplate.Server.AppHost\\Boilerplate.Server.AppHost.csproj",
//#endif
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx
index dcf320469df..7145105cc8e 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/Boilerplate.slnx
@@ -108,4 +108,11 @@
+
+
+
+
+
+
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Boilerplate.Client.Maui.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Boilerplate.Client.Maui.csproj
index e15f818c294..86d4f3c8248 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Boilerplate.Client.Maui.csproj
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Maui/Boilerplate.Client.Maui.csproj
@@ -1,4 +1,4 @@
-
+
net10.0-android;net10.0-ios;net10.0-maccatalyst
@@ -18,6 +18,8 @@
com.bitplatform.template
+ false
+
True
true
@@ -80,8 +82,6 @@
True
-
- -all
Platforms/iOS/Entitlements.Production.plist
@@ -94,8 +94,6 @@
Platforms/MacCatalyst/Entitlements.Production.plist
true
-
- -all
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/.runsettings b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/.runsettings
new file mode 100644
index 00000000000..789f53a9c25
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/.runsettings
@@ -0,0 +1,32 @@
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ npx playwright@1.62.0 install; npx playwright@1.62.0 run-server --port 4444 --host 0.0.0.0
+
+
+ 1
+
+
+
+
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Boilerplate.Tests.E2E.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Boilerplate.Tests.E2E.csproj
new file mode 100644
index 00000000000..d31235d0dcf
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Boilerplate.Tests.E2E.csproj
@@ -0,0 +1,34 @@
+
+
+
+
+
+ net10.0
+ enable
+ 154C08DF-B089-4D8F-91F3-A95D2A4A27D2
+ $(MSBuildProjectDirectory)\.runsettings
+ --settings ./.runsettings --report-trx --report-trx-filename TestResults.trx --results-directory ./TestResults
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Android/AndroidSmokeTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Android/AndroidSmokeTests.cs
new file mode 100644
index 00000000000..c72ddb23d2a
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Android/AndroidSmokeTests.cs
@@ -0,0 +1,13 @@
+using Boilerplate.Tests.E2E.Features.Core;
+
+namespace Boilerplate.Tests.E2E.Features.Android;
+
+///
+/// Not parallelized: both apps run on the single connected device/emulator, and launching one backgrounds the other.
+/// See for what a test machine must have connected and installed.
+///
+[TestClass, TestCategory(TestCategories.Android), DoNotParallelize, Retry(2)]
+public partial class AndroidSmokeTests : SmokeTestsBase
+{
+ protected override IAppOpener AppOpener => new AndroidAppOpener();
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Core/SmokeTestsBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Core/SmokeTestsBase.cs
new file mode 100644
index 00000000000..70678c70e82
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Core/SmokeTestsBase.cs
@@ -0,0 +1,53 @@
+namespace Boilerplate.Tests.E2E.Features.Core;
+
+///
+/// Opens each app and proves it not only rendered but is actually interactive. Written once here; the Web, Windows and
+/// Android shells decide where the app runs, and the web shell additionally re-targets chromium/firefox/webkit through
+/// BROWSER.
+///
+public abstract class SmokeTestsBase : AppsTestBase
+{
+ ///
+ /// Waits for the app shell every app renders through Client.Core's AppShell.razor, then opens the header's account
+ /// menu (AppMenu.razor's BitDropMenu) and expects its callout. Prerendered html can show the shell - and even the
+ /// menu's trigger - with no .NET app running behind it, but only a booted app handles the click.
+ ///
+ [TestMethod]
+ [DataRow(App.AdminPanel, DisplayName = nameof(App.AdminPanel))]
+ [DataRow(App.AdminPanelWasmStandalone, DisplayName = nameof(App.AdminPanelWasmStandalone))]
+ [DataRow(App.Todo, DisplayName = nameof(App.Todo))]
+ [DataRow(App.TodoAot, DisplayName = nameof(App.TodoAot))]
+ [DataRow(App.TodoSmall, DisplayName = nameof(App.TodoSmall))]
+ [DataRow(App.TodoOffline, DisplayName = nameof(App.TodoOffline))]
+ [DataRow(App.Sales, DisplayName = nameof(App.Sales))]
+ public async Task App_Should_BecomeInteractive(App app)
+ {
+ var page = await OpenApp(app);
+
+ // Generous because a first visit includes the WebAssembly boot / bswup precache on a cold cache.
+ await Expect(page.Locator("main .main-container").First)
+ .ToBeVisibleAsync(new() { Timeout = (float)TimeSpan.FromMinutes(2).TotalMilliseconds });
+
+ var menuTrigger = page.Locator("header .bit-drm").First;
+ var menuCallout = page.Locator(".app-menu-callout .app-menu-card").First;
+
+ var deadline = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2);
+
+ // A click landing in the gap between prerendered html and the app taking over is simply swallowed, so the
+ // click is retried against a deadline rather than trusted once (the same reasoning as
+ // PlaywrightLocatorExtensions' FillEnsuringStable).
+ while (true)
+ {
+ await menuTrigger.ClickAsync();
+
+ try
+ {
+ await Expect(menuCallout).ToBeVisibleAsync(new() { Timeout = 2_000 });
+ return;
+ }
+ catch (PlaywrightException) when (DateTimeOffset.UtcNow < deadline)
+ {
+ }
+ }
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Web/WebAppDownloadSizeTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Web/WebAppDownloadSizeTests.cs
new file mode 100644
index 00000000000..f05b55e04ff
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Web/WebAppDownloadSizeTests.cs
@@ -0,0 +1,113 @@
+using System.Collections.Concurrent;
+
+namespace Boilerplate.Tests.E2E.Features.Web;
+
+[TestClass, TestCategory(TestCategories.Web), Retry(2)]
+public partial class WebAppDownloadSizeTests : AppPageTest
+{
+ ///
+ /// Growing past the expected size by more than this fails the test - the regression this test exists to catch.
+ ///
+ private const double growthTolerance = 0.10;
+
+ ///
+ /// Shrinking below the expected size by more than this also fails: either the measurement broke (a deployment is
+ /// down, the service worker no longer precaches) or the app genuinely got smaller and the expected size in the
+ /// DataRow should be re-baselined to the measured value the failure message prints.
+ ///
+ private const double shrinkTolerance = 0.30;
+
+ ///
+ /// The download is considered complete once the context has started and finished no request for this long.
+ /// Long enough to bridge the gaps in a bswup install (page load -> service worker precache -> app start),
+ /// short enough that recurring telemetry beacons cannot keep the wait alive forever on their own.
+ ///
+ private static readonly TimeSpan networkQuietPeriod = TimeSpan.FromSeconds(10);
+
+ private static readonly TimeSpan measurementDeadline = TimeSpan.FromMinutes(4);
+
+ ///
+ /// First visit with a cold cache: everything the app makes the browser download - the page's own requests plus the
+ /// bswup service worker's precache - summed as compressed-over-the-wire bytes. The expected sizes are simply
+ /// earlier measurements of this same method, so re-baseline them from the failure message whenever a deliberate
+ /// change moves an app's size.
+ ///
+ [TestMethod]
+ [DataRow(DeployedApps.AdminPanel, 5.3, DisplayName = nameof(DeployedApps.AdminPanel))]
+ [DataRow(DeployedApps.AdminPanelWasmStandalone, 4.9, DisplayName = nameof(DeployedApps.AdminPanelWasmStandalone))]
+ [DataRow(DeployedApps.Todo, 5.0, DisplayName = nameof(DeployedApps.Todo))]
+ [DataRow(DeployedApps.TodoAot, 8.0, DisplayName = nameof(DeployedApps.TodoAot))]
+ [DataRow(DeployedApps.TodoSmall, 3.5, DisplayName = nameof(DeployedApps.TodoSmall))]
+ [DataRow(DeployedApps.TodoOffline, 7.3, DisplayName = nameof(DeployedApps.TodoOffline))]
+ [DataRow(DeployedApps.Sales, 5.4, DisplayName = nameof(DeployedApps.Sales))]
+ public async Task FirstVisitDownloadSize_Should_NotRegress(string url, double expectedMegabytes)
+ {
+ // Qualified because the inherited BrowserType property (an IBrowserType) shadows the type of the same name.
+ if (BrowserName is not Microsoft.Playwright.BrowserType.Chromium)
+ Assert.Inconclusive("Only chromium honors PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS, so on other engines the service worker's downloads go uncounted and the totals here would be meaningless. The payload is engine-independent anyway; a BROWSER=firefox/webkit run covers compatibility through the other web tests.");
+
+ var (totalBytes, requestCount) = await MeasureFirstVisitDownloadSize(url);
+
+ var actualMegabytes = totalBytes / 1024d / 1024d;
+
+ TestContext.WriteLine($"{url} downloaded {actualMegabytes:F2}MB over {requestCount} requests (expected ~{expectedMegabytes:F2}MB).");
+
+ Assert.IsLessThanOrEqualTo(expectedMegabytes * (1 + growthTolerance), actualMegabytes,
+ $"{url} now downloads {actualMegabytes:F2}MB, more than {growthTolerance:P0} over the expected {expectedMegabytes:F2}MB - a size regression.");
+
+ Assert.IsGreaterThanOrEqualTo(expectedMegabytes * (1 - shrinkTolerance), actualMegabytes,
+ $"{url} downloaded only {actualMegabytes:F2}MB against the expected {expectedMegabytes:F2}MB - either the measurement broke or the app shrank and this DataRow should be re-baselined.");
+ }
+
+ ///
+ /// Navigates a fresh, empty-cache context to , waits until the network has been quiet for
+ /// , and sums the encoded (compressed) body + header bytes of every request the
+ /// context made - the service worker's included, thanks to the PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS flag
+ /// .runsettings sets.
+ ///
+ private async Task<(long totalBytes, int requestCount)> MeasureFirstVisitDownloadSize(string url)
+ {
+ var requests = new ConcurrentQueue();
+ var lastActivityTicks = DateTimeOffset.UtcNow.UtcTicks;
+
+ void Touch() => Interlocked.Exchange(ref lastActivityTicks, DateTimeOffset.UtcNow.UtcTicks);
+
+ Context.Request += (_, request) => { requests.Enqueue(request); Touch(); };
+ Context.RequestFinished += (_, _) => Touch();
+ Context.RequestFailed += (_, _) => Touch();
+
+ await Page.GotoAsync(url);
+
+ var deadline = DateTimeOffset.UtcNow + measurementDeadline;
+
+ while (DateTimeOffset.UtcNow < deadline)
+ {
+ var lastActivity = new DateTimeOffset(Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
+
+ if (DateTimeOffset.UtcNow - lastActivity >= networkQuietPeriod)
+ break;
+
+ await Task.Delay(TimeSpan.FromMilliseconds(500), TestContext.CancellationToken);
+ }
+
+ long totalBytes = 0;
+ var requestCount = 0;
+
+ foreach (var request in requests)
+ {
+ try
+ {
+ var sizes = await request.SizesAsync();
+ totalBytes += Math.Max(0, sizes.ResponseBodySize) + Math.Max(0, sizes.ResponseHeadersSize);
+ requestCount++;
+ }
+ catch (PlaywrightException)
+ {
+ // A request that never finished (cancelled, or still in flight past the deadline) has no sizes; the
+ // bytes it did move are not part of what the app needs, so skipping it is the right call.
+ }
+ }
+
+ return (totalBytes, requestCount);
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Web/WebSmokeTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Web/WebSmokeTests.cs
new file mode 100644
index 00000000000..313fcf4deb0
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Web/WebSmokeTests.cs
@@ -0,0 +1,9 @@
+using Boilerplate.Tests.E2E.Features.Core;
+
+namespace Boilerplate.Tests.E2E.Features.Web;
+
+[TestClass, TestCategory(TestCategories.Web), Retry(2)]
+public partial class WebSmokeTests : SmokeTestsBase
+{
+ protected override IAppOpener AppOpener => new WebAppOpener();
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Windows/WindowsSmokeTests.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Windows/WindowsSmokeTests.cs
new file mode 100644
index 00000000000..f322cf30fc7
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Features/Windows/WindowsSmokeTests.cs
@@ -0,0 +1,13 @@
+using Boilerplate.Tests.E2E.Features.Core;
+
+namespace Boilerplate.Tests.E2E.Features.Windows;
+
+///
+/// Not parallelized: every Client.Windows app answers on the same hard-coded CDP port 9222, so two sessions at once
+/// would attach to whichever app won the port. See for what a test machine must have installed.
+///
+[TestClass, TestCategory(TestCategories.Windows), DoNotParallelize, Retry(2)]
+public partial class WindowsSmokeTests : SmokeTestsBase
+{
+ protected override IAppOpener AppOpener => new WindowsAppOpener();
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/App.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/App.cs
new file mode 100644
index 00000000000..7f2589e9dcd
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/App.cs
@@ -0,0 +1,17 @@
+namespace Boilerplate.Tests.E2E.Infrastructure;
+
+///
+/// The demo apps as platform-agnostic identities. Which platforms actually carry an app - and how to reach it there -
+/// is the s' knowledge; a test written against just names the app
+/// and runs wherever it exists.
+///
+public enum App
+{
+ AdminPanel,
+ AdminPanelWasmStandalone,
+ Todo,
+ TodoAot,
+ TodoSmall,
+ TodoOffline,
+ Sales,
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/AppOpeners.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/AppOpeners.cs
new file mode 100644
index 00000000000..979f5109900
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/AppOpeners.cs
@@ -0,0 +1,77 @@
+namespace Boilerplate.Tests.E2E.Infrastructure;
+
+///
+/// How a platform turns an into a live page: the web opener navigates the test's own browser page,
+/// the hybrid ones launch the installed app and attach over CDP. Null means the app has no build on that platform,
+/// which turns into an inconclusive test rather than a failure.
+///
+public interface IAppOpener
+{
+ Task TryOpen(AppsTestBase test, App app);
+}
+
+public sealed class WebAppOpener : IAppOpener
+{
+ public async Task TryOpen(AppsTestBase test, App app)
+ {
+ var url = app switch
+ {
+ App.AdminPanel => DeployedApps.AdminPanel,
+ App.AdminPanelWasmStandalone => DeployedApps.AdminPanelWasmStandalone,
+ App.Todo => DeployedApps.Todo,
+ App.TodoAot => DeployedApps.TodoAot,
+ App.TodoSmall => DeployedApps.TodoSmall,
+ App.TodoOffline => DeployedApps.TodoOffline,
+ App.Sales => DeployedApps.Sales,
+ _ => throw new ArgumentOutOfRangeException(nameof(app), app, "Unknown app"),
+ };
+
+ await test.Page.GotoAsync(url);
+
+ return test.Page;
+ }
+}
+
+public sealed class WindowsAppOpener : IAppOpener
+{
+ public async Task TryOpen(AppsTestBase test, App app)
+ {
+ var windowsAppId = app switch
+ {
+ App.Todo => DeployedApps.TodoWindowsAppId,
+ App.AdminPanel => DeployedApps.AdminPanelWindowsAppId,
+ _ => null,
+ };
+
+ if (windowsAppId is null)
+ return null;
+
+ var session = await test.Playwright.LaunchWindowsApp(windowsAppId);
+
+ test.RegisterForCleanup(session);
+
+ return session.Page;
+ }
+}
+
+public sealed class AndroidAppOpener : IAppOpener
+{
+ public async Task TryOpen(AppsTestBase test, App app)
+ {
+ var applicationId = app switch
+ {
+ App.Todo => DeployedApps.TodoAndroidAppId,
+ App.AdminPanel => DeployedApps.AdminPanelAndroidAppId,
+ _ => null,
+ };
+
+ if (applicationId is null)
+ return null;
+
+ var session = await test.Playwright.LaunchAndroidApp(applicationId);
+
+ test.RegisterForCleanup(session);
+
+ return session.Page;
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/AppsTestBase.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/AppsTestBase.cs
new file mode 100644
index 00000000000..76f7df8cd31
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/AppsTestBase.cs
@@ -0,0 +1,47 @@
+namespace Boilerplate.Tests.E2E.Infrastructure;
+
+///
+/// Base for tests written once and run against every platform that carries the app: the feature's tests live in an
+/// abstract class under Features/Core, and a shell class per platform inherits it, only picking its
+/// and the platform's category/parallelism attributes. The tests call
+/// and drive the returned page without knowing whether it lives in a browser tab or an installed app's WebView.
+///
+/// The hybrid shells inherit the browser-page plumbing too and simply never touch the tab it opens; that costs
+/// milliseconds and keeps the timeout, remote-server and video-on-retry setup single-sourced in AppPageTest.
+///
+///
+public abstract class AppsTestBase : AppPageTest
+{
+ private readonly List cleanups = [];
+
+ protected abstract IAppOpener AppOpener { get; }
+
+ ///
+ /// What an created lives exactly as long as the test: registered here, closed by
+ /// (e.g. the launched hybrid app).
+ ///
+ public void RegisterForCleanup(IAsyncDisposable disposable) => cleanups.Add(disposable);
+
+ ///
+ /// A live page of on this shell's platform; inconclusive when the app has no build there,
+ /// so a coverage gap shows up as skipped rather than hiding as a pass.
+ ///
+ protected async Task OpenApp(App app)
+ {
+ var page = await AppOpener.TryOpen(this, app);
+
+ if (page is null)
+ Assert.Inconclusive($"{app} has no build on this platform.");
+
+ return page!;
+ }
+
+ [TestCleanup]
+ public async ValueTask AppsCleanup()
+ {
+ foreach (var cleanup in cleanups)
+ await cleanup.DisposeAsync();
+
+ cleanups.Clear();
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/DeployedApps.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/DeployedApps.cs
new file mode 100644
index 00000000000..36b8759bab0
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/DeployedApps.cs
@@ -0,0 +1,48 @@
+namespace Boilerplate.Tests.E2E.Infrastructure;
+
+///
+/// The always-on demo apps that .github/workflows/{admin-sample,todo-sample,sales-module-demo}.cd.yml build from this
+/// very template (dotnet new bit-bp) and deploy. Together they cover Client.Web, Server.Web, Client.Maui and
+/// Client.Windows, so tests in this project run against real production deployments instead of a locally started server.
+///
+public static class DeployedApps
+{
+ /// Admin module, prerendered PWA served by Server.Web with a standalone API (Azure Web App + Cloudflare CDN).
+ public const string AdminPanel = "https://adminpanel.bitplatform.dev/";
+
+ /// Admin module, Blazor WebAssembly Standalone (Azure Static Web App).
+ public const string AdminPanelWasmStandalone = "https://adminpanel.bitplatform.cc/";
+
+ /// Todo sample, prerendered PWA served by Server.Web with a standalone API (Azure Web App + Cloudflare CDN).
+ public const string Todo = "https://todo.bitplatform.dev/";
+
+ /// Todo sample, AOT compiled Blazor WebAssembly Standalone (Azure Static Web App).
+ public const string TodoAot = "https://todo-aot.bitplatform.cc/";
+
+ /// Todo sample, Blazor WebAssembly Standalone published for the smallest download footprint (Azure Static Web App).
+ public const string TodoSmall = "https://todo-small.bitplatform.cc/";
+
+ /// Todo sample with the offline in-browser SQLite database and sync, full-offline bswup mode (Azure Static Web App).
+ public const string TodoOffline = "https://todo-offline.bitplatform.cc/";
+
+ /// Sales module, prerendered PWA served by Server.Web with an integrated API (Azure Web App + Cloudflare CDN).
+ public const string Sales = "https://sales.bitplatform.dev/";
+
+ /// Application id of the published Todo Android app, for .
+ public const string TodoAndroidAppId = "com.bitplatform.Todo.Template";
+
+ /// Application id of the published AdminPanel Android app, for .
+ public const string AdminPanelAndroidAppId = "com.bitplatform.AdminPanel.Template";
+
+ /// Velopack app id of the published Todo Windows app, for .
+ public const string TodoWindowsAppId = "TodoSample.Client.Windows";
+
+ /// Velopack app id of the published AdminPanel Windows app, for .
+ public const string AdminPanelWindowsAppId = "AdminPanel.Client.Windows";
+
+ /// Installer that puts the Todo Windows app where expects it.
+ public const string TodoWindowsSetupUrl = "https://windows-todo.bitplatform.dev/TodoSample.Client.Windows-win-Setup.exe";
+
+ /// Installer that puts the AdminPanel Windows app where expects it.
+ public const string AdminPanelWindowsSetupUrl = "https://windows-admin.bitplatform.dev/AdminPanel.Client.Windows-win-Setup.exe";
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/HybridAppConnector.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/HybridAppConnector.cs
new file mode 100644
index 00000000000..7a80f8a375f
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/HybridAppConnector.cs
@@ -0,0 +1,297 @@
+namespace Boilerplate.Tests.E2E.Infrastructure;
+
+///
+/// Launches an installed Blazor Hybrid app and attaches Playwright to its WebView over the Chrome DevTools Protocol,
+/// so the Windows (WebView2) and Android (Android WebView) apps are driven exactly like a web page.
+///
+/// A machine running these tests is assumed to be prepared like this:
+/// the Windows apps are installed through their Velopack setup (see the setup urls in ),
+/// which puts them at %LocalAppData%\{appId}\current\{appId}.exe, and exactly one Android device or emulator
+/// is connected through adb with both Android apps installed - or, when adb sees nothing, an AVD with them installed
+/// exists locally, and the first one is booted automatically.
+///
+///
+/// Unlike a launched browser, the attached browser already contains the app's context and page: use the session's
+/// Page instead of creating a new one.
+///
+///
+public static class HybridAppConnector
+{
+ ///
+ /// How long a freshly started app gets to bring up its WebView's CDP endpoint and first page. Generous because a
+ /// cold start includes the Velopack update check on Windows and process + WebView spin-up on Android.
+ ///
+ private static readonly TimeSpan connectDeadline = TimeSpan.FromMinutes(1);
+
+ extension(IPlaywright playwright)
+ {
+ ///
+ /// Starts the installed Client.Windows app identified by
+ /// (e.g. ) and attaches to it.
+ ///
+ /// Every Client.Windows app hard-codes --remote-debugging-port=9222 (see Client.Windows/Program.cs), so
+ /// only one Windows app can be driven at a time, and a leftover instance of any of them would be the one
+ /// answering on the port - this session would silently drive the wrong window. Hence every Client.Windows
+ /// process still running is stopped first: on a test machine a fresh app per session is worth more than any
+ /// window someone left open.
+ ///
+ ///
+ public async Task LaunchWindowsApp(string windowsAppId, int port = 9222)
+ {
+ var exePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), windowsAppId, "current", $"{windowsAppId}.exe");
+
+ if (File.Exists(exePath) is false)
+ throw new InvalidOperationException($"'{exePath}' does not exist. Install the app from its setup exe first (see {nameof(DeployedApps)}).");
+
+ StopWindowsApps();
+
+ Process.Start(new ProcessStartInfo(exePath) { UseShellExecute = true });
+
+ var browser = await ConnectWithRetry(playwright, $"http://localhost:{port}");
+
+ return new HybridAppSession(browser, stopApp: () =>
+ {
+ StopWindowsApps();
+ return Task.CompletedTask;
+ });
+ }
+
+ ///
+ /// (Re)starts the Client.Maui Android app identified by
+ /// (e.g. ) on the connected device/emulator and attaches to it.
+ /// The WebView's CDP endpoint is an abstract socket on the device, so it is forwarded to
+ /// first - a port other than 9222 by default, so a Windows app session can exist
+ /// alongside an Android one.
+ ///
+ public async Task LaunchAndroidApp(string applicationId, int localPort = 9223)
+ {
+ await EnsureAndroidDeviceOnline();
+
+ // Force-stopped first so each session drives a freshly started app rather than whatever state a previous
+ // run or a human left behind on the device.
+ await RunAdb($"shell am force-stop {applicationId}");
+ await RunAdb($"shell monkey -p {applicationId} -c android.intent.category.LAUNCHER 1");
+
+ var deadline = DateTimeOffset.UtcNow + connectDeadline;
+ string pid;
+
+ while (true)
+ {
+ pid = (await RunAdb($"shell pidof {applicationId}", allowNonZeroExit: true)).Trim();
+
+ if (string.IsNullOrEmpty(pid) is false)
+ break;
+
+ if (DateTimeOffset.UtcNow >= deadline)
+ throw new TimeoutException($"'{applicationId}' did not start on the connected Android device/emulator within {connectDeadline}. Is it installed?");
+
+ await Task.Delay(TimeSpan.FromMilliseconds(500));
+ }
+
+ await RunAdb($"forward tcp:{localPort} localabstract:webview_devtools_remote_{pid}");
+
+ var browser = await ConnectWithRetry(playwright, $"http://localhost:{localPort}");
+
+ return new HybridAppSession(browser, stopApp: async () =>
+ {
+ await RunAdb($"forward --remove tcp:{localPort}", allowNonZeroExit: true);
+ await RunAdb($"shell am force-stop {applicationId}");
+ });
+ }
+ }
+
+ private static void StopWindowsApps()
+ {
+ foreach (var process in Process.GetProcesses().Where(p => p.ProcessName.EndsWith(".Client.Windows", StringComparison.Ordinal)))
+ {
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit();
+ }
+ }
+
+ ///
+ /// The CDP endpoint appears some time after the app process does (and its page later still), so a single connect
+ /// attempt right after launch would routinely lose the race - this keeps trying until .
+ ///
+ private static async Task ConnectWithRetry(IPlaywright playwright, string cdpUrl)
+ {
+ var deadline = DateTimeOffset.UtcNow + connectDeadline;
+
+ while (true)
+ {
+ try
+ {
+ var browser = await playwright.Chromium.ConnectOverCDPAsync(cdpUrl);
+
+ if (browser.Contexts.SelectMany(c => c.Pages).Any())
+ {
+ foreach (var context in browser.Contexts)
+ context.SetDefaultTimeout((float)TimeSpan.FromMinutes(1).TotalMilliseconds);
+
+ return browser;
+ }
+
+ // Connected before the WebView opened its page; disconnect and try again.
+ await browser.CloseAsync();
+ }
+ catch (PlaywrightException) when (DateTimeOffset.UtcNow < deadline)
+ {
+ }
+
+ if (DateTimeOffset.UtcNow >= deadline)
+ throw new TimeoutException($"No CDP endpoint with a page appeared at {cdpUrl} within {connectDeadline}.");
+
+ await Task.Delay(TimeSpan.FromMilliseconds(500));
+ }
+ }
+
+ ///
+ /// A connected device is the assumption, but when adb sees none, the first locally available AVD is booted
+ /// instead - and deliberately left running afterwards: booting costs minutes and the next session reuses it.
+ ///
+ private static async Task EnsureAndroidDeviceOnline()
+ {
+ if (await IsAnyAndroidDeviceOnline())
+ return;
+
+ var emulator = FindEmulatorExecutable();
+
+ var avdName = (await RunProcess(emulator, "-list-avds"))
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ // Newer emulators mix INFO/WARNING lines into -list-avds; actual AVD names never contain spaces.
+ .FirstOrDefault(line => line.Contains(' ') is false)
+ ?? throw new InvalidOperationException($"No Android device/emulator is connected and '{emulator}' lists no AVD to start. Create one (with the apps installed) or connect a device.");
+
+ var process = Process.Start(new ProcessStartInfo(emulator, $"-avd {avdName}")
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ })!;
+
+ // Drained but discarded: the emulator logs plenty, and an unread redirected pipe would eventually block it.
+ process.OutputDataReceived += delegate { };
+ process.ErrorDataReceived += delegate { };
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ var deadline = DateTimeOffset.UtcNow + emulatorBootDeadline;
+
+ while (true)
+ {
+ if (await IsAnyAndroidDeviceOnline()
+ && (await RunAdb("shell getprop sys.boot_completed", allowNonZeroExit: true)).Trim() is "1")
+ {
+ return;
+ }
+
+ if (process.HasExited)
+ throw new InvalidOperationException($"The '{avdName}' emulator exited with code {process.ExitCode} before finishing its boot.");
+
+ if (DateTimeOffset.UtcNow >= deadline)
+ throw new TimeoutException($"The '{avdName}' emulator did not finish booting within {emulatorBootDeadline}.");
+
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ }
+ }
+
+ private static async Task IsAnyAndroidDeviceOnline()
+ {
+ var output = await RunAdb("devices", allowNonZeroExit: true);
+
+ // Skips the "List of devices attached" header; a usable row reads "\tdevice" ("offline" and
+ // "unauthorized" rows are not usable).
+ return output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Skip(1)
+ .Any(line => line.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries) is [_, "device", ..]);
+ }
+
+ ///
+ /// The emulator does not have to be on PATH the way adb is, so this looks in the usual sdk roots - the ANDROID_HOME /
+ /// ANDROID_SDK_ROOT variables, the default install location, and the sdk adb itself runs from (its platform-tools' parent).
+ ///
+ private static string FindEmulatorExecutable()
+ {
+ string?[] sdkRoots =
+ [
+ Environment.GetEnvironmentVariable("ANDROID_HOME"),
+ Environment.GetEnvironmentVariable("ANDROID_SDK_ROOT"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Android", "Sdk"),
+ FindSdkRootFromAdbOnPath(),
+ ];
+
+ var candidates = sdkRoots.OfType()
+ .SelectMany(root => new[] { Path.Combine(root, "emulator", "emulator.exe"), Path.Combine(root, "emulator", "emulator") });
+
+ return candidates.FirstOrDefault(File.Exists)
+ ?? throw new InvalidOperationException("No Android device/emulator is connected and no emulator executable was found next to any known sdk root. Connect a device or install the Android emulator.");
+ }
+
+ private static string? FindSdkRootFromAdbOnPath()
+ {
+ foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
+ {
+ try
+ {
+ if (File.Exists(Path.Combine(directory, "adb.exe")) || File.Exists(Path.Combine(directory, "adb")))
+ return Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)));
+ }
+ catch (Exception)
+ {
+ // A malformed PATH entry is not this method's problem; the next entry may still hold adb.
+ }
+ }
+
+ return null;
+ }
+
+ private static readonly TimeSpan emulatorBootDeadline = TimeSpan.FromMinutes(5);
+
+ private static async Task RunAdb(string arguments, bool allowNonZeroExit = false)
+ {
+ return await RunProcess("adb", arguments, allowNonZeroExit,
+ startFailureHint: "Is the Android SDK's platform-tools directory on PATH?");
+ }
+
+ private static async Task RunProcess(string fileName, string arguments, bool allowNonZeroExit = false, string? startFailureHint = null)
+ {
+ var process = Process.Start(new ProcessStartInfo(fileName, arguments)
+ {
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ }) ?? throw new InvalidOperationException($"Could not start {fileName}. {startFailureHint}");
+
+ var output = await process.StandardOutput.ReadToEndAsync();
+ var error = await process.StandardError.ReadToEndAsync();
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode is not 0 && allowNonZeroExit is false)
+ throw new InvalidOperationException($"{fileName} {arguments} failed ({process.ExitCode}): {error}");
+
+ return output;
+ }
+}
+
+///
+/// A running hybrid app with Playwright attached to its WebView. Disposing disconnects and stops the app, so the next
+/// session starts from a fresh app.
+///
+public sealed class HybridAppSession(IBrowser browser, Func stopApp) : IAsyncDisposable
+{
+ public IBrowser Browser => browser;
+
+ ///
+ /// The page the app is showing. A hybrid app has exactly one - drive this instead of creating a new page.
+ ///
+ public IPage Page => browser.Contexts.SelectMany(c => c.Pages).FirstOrDefault()
+ ?? throw new InvalidOperationException("The attached app exposes no page (anymore).");
+
+ public async ValueTask DisposeAsync()
+ {
+ await browser.CloseAsync();
+ await stopApp();
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/TestCategories.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/TestCategories.cs
new file mode 100644
index 00000000000..e0f925a7544
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/TestCategories.cs
@@ -0,0 +1,22 @@
+namespace Boilerplate.Tests.E2E.Infrastructure;
+
+///
+/// The platform a test drives, so a run can target one of them, e.g. dotnet test --filter "TestCategory=Web".
+///
+public static class TestCategories
+{
+ ///
+ /// Tests against the deployed web apps listed in .
+ ///
+ public const string Web = "Web";
+
+ ///
+ /// Tests driving the installed Android apps' WebView over CDP. See .
+ ///
+ public const string Android = "Android";
+
+ ///
+ /// Tests driving the installed Windows apps' WebView2 over CDP. See .
+ ///
+ public const string Windows = "Windows";
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/TestConfiguration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/TestConfiguration.cs
new file mode 100644
index 00000000000..5150ce6fa6d
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/Infrastructure/TestConfiguration.cs
@@ -0,0 +1,21 @@
+namespace Boilerplate.Tests.E2E.Infrastructure;
+
+///
+/// Configuration for tests that reach the deployed apps' backing services - the PostgreSQL database and the Azure Blob
+/// Storage account. Locally the values live in this project's user secrets (dotnet user-secrets set
+/// "ConnectionStrings:postgresdb" "..." in this project's directory); on CI, environment variables
+/// (ConnectionStrings__postgresdb) override them.
+///
+public static class TestConfiguration
+{
+ public static IConfigurationRoot Current { get; } = new ConfigurationBuilder()
+ .AddUserSecrets(typeof(TestConfiguration).Assembly)
+ .AddEnvironmentVariables()
+ .Build();
+
+ public static string GetRequiredConnectionString(string name)
+ {
+ return Current.GetConnectionString(name)
+ ?? throw new InvalidOperationException($"Connection string '{name}' was found in neither this project's user secrets nor the environment variables.");
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/MSTestSettings.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/MSTestSettings.cs
new file mode 100644
index 00000000000..49f09988833
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/MSTestSettings.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 2)]
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/RunTests.bat b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/RunTests.bat
new file mode 100644
index 00000000000..196fd6f881a
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Internal/Boilerplate.Tests.E2E/RunTests.bat
@@ -0,0 +1,33 @@
+@echo off
+setlocal
+cd /d "%~dp0"
+
+set EXIT_CODE=0
+
+echo ================================ Web tests on chromium ================================
+dotnet test --filter "TestCategory=Web"
+if errorlevel 1 set EXIT_CODE=1
+
+echo ================================ Web tests on firefox =================================
+set BROWSER=firefox
+dotnet test --filter "TestCategory=Web" --no-build
+if errorlevel 1 set EXIT_CODE=1
+set BROWSER=
+
+echo ==================== Web tests on webkit (Safari) on the remote mac ===================
+set BROWSER=webkit
+set PLAYWRIGHT_SERVER_ENDPOINT=ws://192.168.178.24:4444/
+dotnet test --filter "TestCategory=Web" --no-build
+if errorlevel 1 set EXIT_CODE=1
+set BROWSER=
+set PLAYWRIGHT_SERVER_ENDPOINT=
+
+echo ================================== Windows app tests ==================================
+dotnet test --filter "TestCategory=Windows" --no-build
+if errorlevel 1 set EXIT_CODE=1
+
+echo ================================== Android app tests ==================================
+dotnet test --filter "TestCategory=Android" --no-build
+if errorlevel 1 set EXIT_CODE=1
+
+exit /b %EXIT_CODE%
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/.runsettings b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/.runsettings
index 26a8880a231..5d1dc1daae0 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/.runsettings
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/.runsettings
@@ -11,6 +11,11 @@
+
+
+ npx playwright@1.62.0 install; npx playwright@1.62.0 run-server --port 4444 --host 0.0.0.0
+
false
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/AppPageTest.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/AppPageTest.cs
index fb473f2e24e..cb3b967dcfa 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/AppPageTest.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Infrastructure/AppPageTest.cs
@@ -48,6 +48,19 @@ protected async Task SetBlazorWebAssemblyServerAddress(Uri serverAddress, IBrows
await context.AddInitScriptAsync($"window.startupParams = function() {{ return [ 'ServerAddress={serverAddress}' ]; }};");
}
+ ///
+ /// Runs the selected BROWSER on another computer when PLAYWRIGHT_SERVER_ENDPOINT (see .runsettings)
+ /// points at a Playwright server there - most usefully a mac, where webkit runs against the actual Apple frameworks
+ /// and is as close to Safari as automation gets, but chromium and firefox connect the same way.
+ ///
+ public override async Task<(string, BrowserTypeConnectOptions?)?> ConnectOptionsAsync()
+ {
+ if (Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVER_ENDPOINT") is { Length: > 0 } playwrightServerEndpoint)
+ return (playwrightServerEndpoint, new());
+
+ return await base.ConnectOptionsAsync();
+ }
+
///
/// Runs the headless tests on chromium-headless-shell - the small headless-only build that plain
/// Headless = true used to mean before Playwright 1.49 made the chromium channel launch the real Chrome