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