diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..984bfffbf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +**/.dockerignore +**/.env +**/.git +**/.github +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +bin +obj +**/wwwroot/js/*.js +**/secrets.dev.yaml +**/values.dev.yaml +test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/wwwroot/appsettings.json +LICENSE +README.md \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6b4f1b431 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Set default behavior to automatically normalize line endings +* text=auto + +# Force LF line endings for shell scripts (required for Docker/Linux execution) +*.sh text eol=lf + +# Force LF for other common script/config files used in containers +Dockerfile text eol=lf +*.dockerfile text eol=lf diff --git a/.github/workflows/dev-pr-build.yml b/.github/workflows/dev-pr-build.yml index 4ad5e2288..78e171fa5 100644 --- a/.github/workflows/dev-pr-build.yml +++ b/.github/workflows/dev-pr-build.yml @@ -8,10 +8,11 @@ on: branches: [ "develop" ] push: branches: [ "develop" ] - + workflow_dispatch: + concurrency: group: dev-pr-build - cancel-in-progress: true + cancel-in-progress: ${{ !contains(github.actor, '[bot]') }} jobs: actor-check: @@ -25,10 +26,44 @@ jobs: run: | echo "was-bot=true" >> "$GITHUB_OUTPUT" echo "Skipping build for bot commit" - build: - needs: actor-check + + test: + runs-on: [ self-hosted, Windows, X64 ] + needs: [actor-check] + timeout-minutes: 90 if: needs.actor-check.outputs.was-bot != 'true' + steps: + - name: Generate Github App token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.SUBMODULE_APP_ID }} + private-key: ${{ secrets.SUBMODULE_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: 'GeoBlazor' + + # Checkout the repository to the GitHub Actions runner + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.ref || github.ref }} + + - name: Run Tests + shell: pwsh + env: + USE_CONTAINER: true + ARCGIS_API_KEY: ${{ secrets.ARCGISAPIKEY }} + GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} + run: | + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 + + build: runs-on: ubuntu-latest + needs: [actor-check] + if: needs.actor-check.outputs.was-bot != 'true' + timeout-minutes: 30 steps: - name: Generate Github App token uses: actions/create-github-app-token@v2 @@ -38,6 +73,7 @@ jobs: private-key: ${{ secrets.SUBMODULE_PRIVATE_KEY }} owner: ${{ github.repository_owner }} repositories: 'GeoBlazor' + # Checkout the repository to the GitHub Actions runner - name: Checkout uses: actions/checkout@v4 @@ -61,7 +97,7 @@ jobs: - name: Build GeoBlazor shell: pwsh run: | - ./GeoBlazorBuild.ps1 -pkg -docs -c "Release" + ./GeoBlazorBuild.ps1 -xml -pkg -docs -c "Release" # Copies the nuget package to the artifacts directory - name: Upload nuget artifact @@ -69,7 +105,21 @@ jobs: with: name: .core-nuget retention-days: 4 - path: ./src/dymaptic.GeoBlazor.Core/bin/Release/dymaptic.GeoBlazor.Core.*.nupkg + path: ./dymaptic.GeoBlazor.Core.*.nupkg + + # xmllint is a dependency of the copy steps below + - name: Install xmllint + shell: bash + run: | + sudo apt update + sudo apt install -y libxml2-utils + + # This step will copy the version number from the Directory.Build.props file to an environment variable + - name: Copy Build Version + id: copy-version + run: | + CORE_VERSION=$(xmllint --xpath "//PropertyGroup/CoreVersion/text()" ./Directory.Build.props) + echo "CORE_VERSION=$CORE_VERSION" >> $GITHUB_ENV - name: Get GitHub App User ID if: github.event_name == 'pull_request' @@ -78,11 +128,12 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} - - name: Add & Commit + # This step will commit the updated version number back to the develop branch + - name: Add Changes to Git if: github.event_name == 'pull_request' run: | git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]' git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com' git add . - git commit -m "Pipeline Build Commit of Version Bump" + git commit -m "Pipeline Build Commit of Version and Docs" git push \ No newline at end of file diff --git a/.github/workflows/main-release-build.yml b/.github/workflows/main-release-build.yml index d01d36215..638f432be 100644 --- a/.github/workflows/main-release-build.yml +++ b/.github/workflows/main-release-build.yml @@ -6,10 +6,17 @@ name: Main Branch Release Build on: push: branches: [ "main" ] + workflow_dispatch: jobs: build: - runs-on: ubuntu-latest + runs-on: [self-hosted, Windows, X64] + timeout-minutes: 90 + outputs: + token: ${{ steps.app-token.outputs.token }} + app-slug: ${{ steps.app-token.outputs.app-slug }} + user-id: ${{ steps.get-user-id.outputs.user-id }} + version: ${{ env.CORE_VERSION }} steps: - name: Generate Github App token uses: actions/create-github-app-token@v2 @@ -27,24 +34,22 @@ jobs: token: ${{ steps.app-token.outputs.token }} repository: ${{ github.repository }} ref: ${{ github.ref }} - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 10.x - - - name: Update NPM - uses: actions/setup-node@v4 - with: - node-version: '>=22.11.0' - check-latest: 'true' + + - name: Run Tests + shell: pwsh + env: + USE_CONTAINER: true + ARCGIS_API_KEY: ${{ secrets.ARCGISAPIKEY }} + GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} + run: | + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 # This runs the main GeoBlazor build script - name: Build GeoBlazor shell: pwsh run: | - ./GeoBlazorBuild.ps1 -pkg -pub -c "Release" - + ./GeoBlazorBuild.ps1 -xml -pkg -pub -c "Release" + # xmllint is a dependency of the copy steps below - name: Install xmllint shell: bash @@ -58,6 +63,13 @@ jobs: CORE_VERSION=$(xmllint --xpath "//PropertyGroup/CoreVersion/text()" ./Directory.Build.props) echo "CORE_VERSION=$CORE_VERSION" >> $GITHUB_ENV + # Copies the nuget package to the artifacts directory + - name: Upload nuget artifact + uses: actions/upload-artifact@v4.6.0 + with: + name: .core-nuget + path: ./dymaptic.GeoBlazor.Core.*.nupkg + # This step will copy the PR description to an environment variable - name: Copy PR Release Notes run: | @@ -69,13 +81,6 @@ jobs: echo "$DESC_PLUS_EOF" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV - # Copies the nuget package to the artifacts directory - - name: Upload nuget artifact - uses: actions/upload-artifact@v4.6.0 - with: - name: .core-nuget - path: ./src/dymaptic.GeoBlazor.Core/bin/Release/dymaptic.GeoBlazor.Core.*.nupkg - # Creates a GitHub Release based on the Version and the PR description - name: Create Release uses: softprops/action-gh-release@v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..e4f73e530 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: Run Tests + +on: + push: + branches: [ "test" ] + workflow_dispatch: + +concurrency: + group: test + cancel-in-progress: true + +jobs: + test: + runs-on: [self-hosted, Windows, X64] + outputs: + app-token: ${{ steps.app-token.outputs.token }} + timeout-minutes: 90 + steps: + - name: Generate Github App token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.SUBMODULE_APP_ID }} + private-key: ${{ secrets.SUBMODULE_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: 'GeoBlazor' + + # Checkout the repository to the GitHub Actions runner + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event.pull_request.head.ref || github.ref }} + + - name: Run Tests + shell: pwsh + env: + USE_CONTAINER: true + ARCGIS_API_KEY: ${{ secrets.ARCGISAPIKEY }} + GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} + run: | + dotnet test --project ./test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj -c Release --filter CORE_ --max-parallel-test-modules 2 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 54feddcb5..88ce23af4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,12 +10,16 @@ *.userosscache *.sln.docstates .DS_Store -appsettings.json esBuild.*.lock esBuild.log .esbuild-record.json CustomerTests.razor .claude/ +.env +test/dymaptic.GeoBlazor.Core.Test.Automation/test.txt +test/dymaptic.GeoBlazor.Core.Test.Automation/test-run.log +test/dymaptic.GeoBlazor.Core.Test.Automation/coverage* +test/dymaptic.GeoBlazor.Core.Test.Automation/Summary.txt # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -381,7 +385,8 @@ package-lock.json **/wwwroot/appsettings.Development.json DefaultDocsLinks .esbuild-bundled-assets-record.json - +**/*.Maui/appsettings.json +**/wwwroot/appsettings.json !/samples/dymaptic.GeoBlazor.Core.Sample.OAuth/dymaptic.GeoBlazor.Core.Sample.OAuth.Client/wwwroot/appsettings.json !/samples/dymaptic.GeoBlazor.Core.Sample.OAuth/dymaptic.GeoBlazor.Core.Sample.OAuth/appsettings.json /src/dymaptic.GeoBlazor.Core/.esbuild-timestamp.json diff --git a/CLAUDE.md b/CLAUDE.md index 453f641ee..a2b9a02dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,23 @@ -# CLAUDE.md +# CLAUDE.md - GeoBlazor Core This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +> **IMPORTANT:** This repository is a git submodule of the GeoBlazor CodeGen repository. +> For complete context including environment notes, available agents, and cross-repo coordination, +> see the parent CLAUDE.md at: `../../CLAUDE.md` (`dymaptic.GeoBlazor.CodeGen/Claude.md`) + ## Project Overview GeoBlazor is a Blazor component library that brings ArcGIS Maps SDK for JavaScript capabilities to .NET applications. It enables developers to create interactive maps using pure C# code without writing JavaScript. +## Repository Context + +| Repository | Path | Purpose | +|------------------------|-------------------------------------------------------|---------------------------------------| +| **This Repo (Core)** | `dymaptic.GeoBlazor.CodeGen/GeoBlazor.Pro/GeoBlazor` | Open-source Blazor mapping library | +| Parent (Pro) | `dymaptic.GeoBlazor.CodeGen/GeoBlazor.Pro` | Commercial extension with 3D support | +| Root (CodeGen) | `dymaptic.GeoBlazor.CodeGen` | Code generator from ArcGIS TypeScript | + ## Architecture ### Core Structure @@ -25,6 +37,11 @@ GeoBlazor is a Blazor component library that brings ArcGIS Maps SDK for JavaScri ### Build ```bash +# Clean build of the Core project +pwsh GeoBlazorBuild.ps1 + +# GeoBlazorBuild.ps1 includes lots of options, use -h to see options + # Build entire solution dotnet build src/dymaptic.GeoBlazor.Core.sln @@ -35,24 +52,18 @@ dotnet build src/dymaptic.GeoBlazor.Core.sln -c Debug # Build TypeScript/JavaScript (from src/dymaptic.GeoBlazor.Core/) pwsh esBuild.ps1 -c Debug pwsh esBuild.ps1 -c Release - -# NPM scripts for TypeScript compilation -npm run debugBuild -npm run releaseBuild -npm run watchBuild ``` ### Test ```bash -# Run all tests -dotnet test src/dymaptic.GeoBlazor.Core.sln +# Run all tests automatically in the GeoBlazor browser test runner +dotnet run test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj /p:RunOnStart=true /p:RenderMode=WebAssembly -# Run specific test project +# Run non-browser unit tests dotnet test test/dymaptic.GeoBlazor.Core.Test.Unit/dymaptic.GeoBlazor.Core.Test.Unit.csproj -dotnet test test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/dymaptic.GeoBlazor.Core.Test.Blazor.Shared.csproj -# Run with specific verbosity -dotnet test --verbosity normal +# Run source-generation tests +dotnet test test/dymaptic.GeoBlazor.Core.SourceGenerator.Tests/dymaptic.GeoBlazor.Core.SourceGenerator.Tests.csproj ``` ### Version Management @@ -72,22 +83,22 @@ pwsh esBuildClearLocks.ps1 npm run watchBuild # Install npm dependencies -npm install +npm install (from src/dymaptic.GeoBlazor.Core/) ``` ## Test Projects - **dymaptic.GeoBlazor.Core.Test.Unit**: Unit tests -- **dymaptic.GeoBlazor.Core.Test.Blazor.Shared**: Blazor component tests -- **dymaptic.GeoBlazor.Core.Test.WebApp**: WebApp integration tests +- **dymaptic.GeoBlazor.Core.Test.Blazor.Shared**: GeoBlazor component tests and test runner logic +- **dymaptic.GeoBlazor.Core.Test.WebApp**: Test running application for the GeoBlazor component tests (`Core.Test.Blazor.Shared`) - **dymaptic.GeoBlazor.Core.SourceGenerator.Tests**: Source generator tests ## Sample Projects -- **Sample.Wasm**: WebAssembly sample -- **Sample.WebApp**: Server-side Blazor sample -- **Sample.Maui**: MAUI hybrid sample +- **Sample.Wasm**: Standalone WebAssembly sample runner +- **Sample.WebApp**: Blazor Web App sample runner with render mode selector +- **Sample.Maui**: MAUI hybrid sample runner - **Sample.OAuth**: OAuth authentication sample - **Sample.TokenRefresh**: Token refresh sample -- **Sample.Shared**: Shared components and pages for samples +- **Sample.Shared**: Shared components and pages for samples (used by Wasm, WebApp, and Maui runners) ## Important Notes @@ -95,10 +106,10 @@ npm install Known issue: ESBuild compilation conflicts with MSBuild static file analysis may cause intermittent build errors when building projects with project references to Core. This is tracked with Microsoft. ### Development Workflow -1. Changes to TypeScript require running ESBuild (automatic via source generator or manual via `esBuild.ps1`) +1. Changes to TypeScript require running ESBuild (automatic via source generator or manual via `esBuild.ps1`). You should see a popup dialog when this is happening automatically from the source generator. 2. Browser cache should be disabled when testing JavaScript changes -3. Generated code (`.gb.*` files) should never be edited directly -4. When adding new components, contact the GeoBlazor team for code generation setup +3. Generated code (`.gb.*` files) should never be edited directly. Instead, move code into the matching hand-editable file to "override" the generated code. +4. When adding new components, use the Code Generator in the parent CodeGen repository ### Component Development - Components must have `[ActivatorUtilitiesConstructor]` on parameterless constructor @@ -115,5 +126,13 @@ Known issue: ESBuild compilation conflicts with MSBuild static file analysis may ## Dependencies - .NET 8.0+ SDK - Node.js (for TypeScript compilation) -- ArcGIS Maps SDK for JavaScript (v4.33.10) -- ESBuild for TypeScript compilation \ No newline at end of file +- ArcGIS Maps SDK for JavaScript (v4.33) +- ESBuild for TypeScript compilation + +## Environment Notes + +**See parent CLAUDE.md for full environment details.** Key points: +- **Platform:** When on Windows, use the Windows version (not WSL) +- **Shell:** Bash (Git Bash/MSYS2) - Use Unix-style commands +- **CRITICAL:** NEVER use 'nul' in Bash commands - use `/dev/null` instead +- **Commands:** Use Unix/Bash commands (`ls`, `cat`, `grep`), NOT Windows commands (`dir`, `type`, `findstr`) diff --git a/Directory.Build.props b/Directory.Build.props index 0f892a600..e026d4fbf 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,10 @@ + + enable enable - 4.4.4 + 5.0.0.3 true $(MSBuildThisFileDirectory)src\dymaptic.GeoBlazor.Core @@ -13,4 +15,4 @@ - + \ No newline at end of file diff --git a/Directory.Build.targets b/Directory.Build.targets index e87e93a45..5a086a0b9 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,5 +1,5 @@ - + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..d959ef75f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,118 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG ARCGIS_API_KEY +ARG GEOBLAZOR_LICENSE_KEY +ARG WFS_SERVERS +ARG HTTP_PORT +ARG HTTPS_PORT +ENV ARCGIS_API_KEY=${ARCGIS_API_KEY} +ENV GEOBLAZOR_LICENSE_KEY=${GEOBLAZOR_LICENSE_KEY} +ENV WFS_SERVERS=${WFS_SERVERS} + +RUN apt-get update \ + && apt-get install -y ca-certificates curl gnupg \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs + +WORKDIR /work +WORKDIR /work/src/dymaptic.GeoBlazor.Core +COPY ./src/dymaptic.GeoBlazor.Core/package.json ./package.json +RUN npm install + +WORKDIR /work +COPY ./src/ ./src/ +COPY ./*.ps1 ./ +COPY ./Directory.Build.* ./ +COPY ./.gitignore ./.gitignore +COPY ./nuget.config ./nuget.config + +RUN pwsh -Command "./GeoBlazorBuild.ps1" + +COPY ./test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/dymaptic.GeoBlazor.Core.Test.Blazor.Shared.csproj ./test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared.csproj +COPY ./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj ./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj +COPY ./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/dymaptic.GeoBlazor.Core.Test.WebApp.Client.csproj ./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/dymaptic.GeoBlazor.Core.Test.WebApp.Client.csproj + +# Use UsePackageReference=false to build from source (enables code coverage with PDB symbols) +RUN dotnet restore ./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj /p:UsePackageReference=false + +COPY ./test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared ./test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared +COPY ./test/dymaptic.GeoBlazor.Core.Test.WebApp ./test/dymaptic.GeoBlazor.Core.Test.WebApp + +RUN pwsh -Command './buildAppSettings.ps1 \ + -ArcGISApiKey $env:ARCGIS_API_KEY \ + -LicenseKey $env:GEOBLAZOR_LICENSE_KEY \ + -OutputPaths @( \ + "./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/wwwroot/appsettings.json", \ + "./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/wwwroot/appsettings.Production.json", \ + "./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/appsettings.json", \ + "./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/appsettings.Production.json") \ + -WfsServers $env:WFS_SERVERS' + +# Build from source with debug symbols for code coverage +# UsePackageReference=false builds GeoBlazor from source instead of NuGet +# DebugSymbols=true and DebugType=portable ensure PDB files are generated +RUN dotnet publish ./test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj \ + -c Release \ + /p:UsePackageReference=false \ + /p:PipelineBuild=true \ + /p:DebugSymbols=true \ + /p:DebugType=portable \ + -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + +# Re-declare ARGs for this stage (ARGs don't persist across stages) +ARG HTTP_PORT=8080 +ARG HTTPS_PORT=9443 + +# Generate a self-signed certificate for HTTPS and install bash for entrypoint script +# Also install libxml2 which is required for dotnet-coverage profiler +RUN apt-get update && apt-get install -y --no-install-recommends openssl bash libxml2 \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /https /coverage \ + && openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /https/aspnetapp.key \ + -out /https/aspnetapp.crt \ + -subj "/CN=test-app" \ + -addext "subjectAltName=DNS:test-app,DNS:localhost" \ + && openssl pkcs12 -export -out /https/aspnetapp.pfx \ + -inkey /https/aspnetapp.key \ + -in /https/aspnetapp.crt \ + -password pass:password \ + && chmod 644 /https/aspnetapp.pfx + +# Install .NET SDK for dotnet-coverage tool (in runtime image) +COPY --from=build /usr/share/dotnet /usr/share/dotnet +ENV PATH="/usr/share/dotnet:/tools:$PATH" +ENV DOTNET_ROOT=/usr/share/dotnet + +# Install dotnet-coverage tool to a shared location accessible by all users +RUN mkdir -p /tools && \ + /usr/share/dotnet/dotnet tool install --tool-path /tools dotnet-coverage && \ + chmod -R 755 /tools + +# Create user and set working directory +RUN groupadd -r info && useradd -r -g info info \ + && chown -R info:info /coverage +WORKDIR /app +COPY --from=build /app/publish . + +# Configure Kestrel for HTTPS +ENV ASPNETCORE_URLS="https://+:${HTTPS_PORT};http://+:${HTTP_PORT}" +ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx +ENV ASPNETCORE_Kestrel__Certificates__Default__Password=password + +# Coverage configuration (can be overridden via environment) +ENV COVERAGE_ENABLED=false +ENV COVERAGE_OUTPUT=/coverage/coverage.xml + +# Copy entrypoint script +COPY ./test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +USER info +EXPOSE ${HTTP_PORT} ${HTTPS_PORT} +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["dotnet", "dymaptic.GeoBlazor.Core.Test.WebApp.dll"] diff --git a/GeoBlazorBuild.ps1 b/GeoBlazorBuild.ps1 index 9764a2745..ffaafb513 100644 --- a/GeoBlazorBuild.ps1 +++ b/GeoBlazorBuild.ps1 @@ -4,6 +4,7 @@ param( [switch][Alias("pub")]$PublishVersion, [switch][Alias("obf")]$Obfuscate, [switch][Alias("docs")]$GenerateDocs, + [switch][Alias("xml")]$GenerateXmlComments, [switch][Alias("pkg")]$Package, [switch][Alias("bl")]$Binlog, [switch][Alias("h")]$Help, @@ -21,6 +22,7 @@ if ($Help) { Write-Host " -PublishVersion (-pub) Truncate the build version to 3 digits for NuGet (default is false)" Write-Host " -Obfuscate (-obf) Obfuscate the Pro license validation logic (default is false)" Write-Host " -GenerateDocs (-docs) Generate documentation files for the docs site (default is false)" + Write-Host " -GenerateXmlComments (-xml) Generate the XML comments that provide intellisense when using the library in an IDE" Write-Host " -Package (-pkg) Create NuGet packages (default is false)" Write-Host " -Binlog (-bl) Generate MSBuild binary log files (default is false)" Write-Host " -Version (-v) Specify a custom version number (default is to auto-increment the current build version)" @@ -32,11 +34,16 @@ if ($Help) { exit 0 } +if ($GenerateDocs) { + $GenerateXmlComments = $true +} + Write-Host "Starting GeoBlazor Build Script" Write-Host "Pro Build: $Pro" Write-Host "Set Nuget Publish Version Build: $PublishVersion" Write-Host "Obfuscate Pro Build: $Obfuscate" -Write-Host "Generate XML Documentation: $GenerateDocs" +Write-Host "Generate Documentation Files: $GenerateDocs" +Write-Host "Generate XML Documentation: $GenerateXmlComments" Write-Host "Build Package: $($Package -eq $true)" Write-Host "Version: $Version" Write-Host "Configuration: $Configuration" @@ -279,8 +286,12 @@ try { Write-Host "" # double-escape line breaks - $CoreBuild = "dotnet build dymaptic.GeoBlazor.Core.csproj --no-restore /p:PipelineBuild=true `` - /p:GenerateDocs=$($GenerateDocs.ToString().ToLower()) /p:CoreVersion=$Version -c $Configuration `` + $CoreBuild = "dotnet build dymaptic.GeoBlazor.Core.csproj --no-restore `` + -c $Configuration `` + /p:PipelineBuild=true `` + /p:GenerateDocs=$($GenerateDocs.ToString().ToLower()) `` + /p:GenerateXmlComments=$($GenerateXmlComments.ToString().ToLower()) `` + /p:CoreVersion=$Version `` /p:GeneratePackage=$($Package.ToString().ToLower()) $BinlogFlag 2>&1" Write-Host "Executing '$CoreBuild'" @@ -325,10 +336,6 @@ try { if ($CoreNupkg) { Copy-Item -Path $CoreNupkg.FullName -Destination $CoreRepoRoot -Force Write-Host "Copied $($CoreNupkg.Name) to $CoreRepoRoot" - if ($Pro -eq $true) { - Copy-Item -Path $CoreNupkg.FullName -Destination $ProRepoRoot -Force - Write-Host "Copied $($CoreNupkg.Name) to $ProRepoRoot" - } } } @@ -457,9 +464,14 @@ try { # double-escape line breaks $ProBuild = "dotnet build dymaptic.GeoBlazor.Pro.csproj --no-restore `` - /p:GenerateDocs=$($GenerateDocs.ToString().ToLower()) /p:PipelineBuild=true /p:CoreVersion=$Version `` - /p:ProVersion=$Version /p:OptOutFromObfuscation=$($OptOutFromObfuscation.ToString().ToLower()) -c `` - $Configuration /p:GeneratePackage=$($Package.ToString().ToLower()) $BinlogFlag 2>&1" + -c $Configuration `` + /p:PipelineBuild=true `` + /p:GenerateDocs=$($GenerateDocs.ToString().ToLower()) `` + /p:GenerateXmlComments=$($GenerateXmlComments.ToString().ToLower()) `` + /p:CoreVersion=$Version `` + /p:ProVersion=$Version `` + /p:OptOutFromObfuscation=$($OptOutFromObfuscation.ToString().ToLower()) `` + /p:GeneratePackage=$($Package.ToString().ToLower()) $BinlogFlag 2>&1" Write-Host "Executing '$ProBuild'" # sometimes the build fails due to a Microsoft bug, retry a few times @@ -501,8 +513,8 @@ try { # Copy generated NuGet package to script root $ProNupkg = Get-ChildItem -Path "bin/$Configuration" -Filter "*.nupkg" -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($ProNupkg) { - Copy-Item -Path $ProNupkg.FullName -Destination $ProRepoRoot -Force - Write-Host "Copied $($ProNupkg.Name) to $ProRepoRoot" + Copy-Item -Path $ProNupkg.FullName -Destination $CoreRepoRoot -Force + Write-Host "Copied $($ProNupkg.Name) to $CoreRepoRoot" } } diff --git a/ReadMe.md b/ReadMe.md index de12d7ed7..99b707036 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -17,6 +17,9 @@ GeoBlazor brings the power of the ArcGIS Maps SDK for JavaScript into your Blazo [![Build](https://img.shields.io/github/actions/workflow/status/dymaptic/GeoBlazor/main-release-build.yml?logo=github)](https://github.com/dymaptic/GeoBlazor/actions/workflows/main-release-build.yml) [![Issues](https://img.shields.io/github/issues/dymaptic/GeoBlazor?logo=github)](https://github.com/dymaptic/GeoBlazor/issues) [![Pull Requests](https://img.shields.io/github/issues-pr/dymaptic/GeoBlazor?logo=github&color=)](https://github.com/dymaptic/GeoBlazor/pulls) +[![Line Code Coverage](badge_linecoverage.svg)] +[![Method Coverage](badge_methodcoverage.svg)] +[![Full Method Coverage](badge_fullmethodcoverage.svg)] **CORE** diff --git a/buildAppSettings.ps1 b/buildAppSettings.ps1 new file mode 100644 index 000000000..e49d22acc --- /dev/null +++ b/buildAppSettings.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS + Generates appsettings.json files for test applications. + +.DESCRIPTION + Creates appsettings.json files at the specified paths with the provided configuration values. + +.PARAMETER ArcGISApiKey + The ArcGIS API key for map services. + +.PARAMETER LicenseKey + The GeoBlazor license key. + +.PARAMETER OutputPaths + Array of file paths where appsettings.json should be written. + +.PARAMETER DocsUrl + The documentation URL. Defaults to "https://docs.geoblazor.com". + +.PARAMETER ByPassApiKey + The API bypass key for samples. + +.PARAMETER WfsServers + Additional WFS server configuration (JSON fragment without outer braces). + +.EXAMPLE + ./buildAppSettings.ps1 -ArcGISApiKey "your-key" -LicenseKey "your-license" -OutputPaths @("./appsettings.json") + +.EXAMPLE + ./buildAppSettings.ps1 -ArcGISApiKey "key" -LicenseKey "license" -OutputPaths @("./app1/appsettings.json", "./app2/appsettings.json") +#> + +param( + [Parameter(Mandatory = $true)] + [string]$ArcGISApiKey, + + [Parameter(Mandatory = $true)] + [string]$LicenseKey, + + [Parameter(Mandatory = $true)] + [string[]]$OutputPaths, + + [Parameter(Mandatory = $false)] + [string]$DocsUrl = "https://docs.geoblazor.com", + + [Parameter(Mandatory = $false)] + [string]$ByPassApiKey = "", + + [Parameter(Mandatory = $false)] + [string]$WfsServers = "" +) + +# Build the appsettings JSON content +$appSettingsContent = @" +{ + "ArcGISApiKey": "$ArcGISApiKey", + "GeoBlazor": { + "LicenseKey": "$LicenseKey" + }, + "DocsUrl": "$DocsUrl", + "ByPassApiKey": "$ByPassApiKey" +"@ + +# Add WFS servers if provided +if ($WfsServers -ne "") { + $appSettingsContent += ",`n $WfsServers" +} + +$appSettingsContent += "`n}" + +# Write to each target path +foreach ($path in $OutputPaths) { + $directory = Split-Path -Parent $path + if ($directory -and !(Test-Path $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + if (!(Test-Path $path)) { + New-Item -ItemType File -Path $path -Force | Out-Null + } + $appSettingsContent | Out-File -FilePath $path -Encoding utf8 + Write-Host "Created: $path" +} + +Write-Host "AppSettings files generated successfully." diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 4f1ddd007..f28c635a7 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -189,4 +189,50 @@ that normal Blazor components do not have. - If the widget has methods that we want to support, create a `wrapper` class for it. See `The JavaScript Wrapper Pattern` above. - Create a new Widget samples page in `dymaptic.GeoBlazor.Core.Samples.Shared/Pages`. Also add to the `NavMenu.razor`. - Alternatively, for simple widgets, you can add them to the `Widgets.razor` sample. -- Create a new unit test in `dymaptic.GeoBlazor.Core.Tests.Blazor.Shared/Components/WidgetTests.razor`. \ No newline at end of file +- Create a new unit test in `dymaptic.GeoBlazor.Core.Tests.Blazor.Shared/Components/WidgetTests.razor`. +## Automated Browser Testing + +GeoBlazor includes a comprehensive automated testing framework using Playwright and MSTest. For detailed documentation, see the [Test Automation README](../test/dymaptic.GeoBlazor.Core.Test.Automation/README.md). + +### Quick Start + +```bash +# Run all automated tests +dotnet test test/dymaptic.GeoBlazor.Core.Test.Automation + +# Run with specific test filter +dotnet test --filter "FullyQualifiedName~FeatureLayerTests" + +# Run in container mode for CI +dotnet test -e USE_CONTAINER=true +``` + +### Key Features + +- **Auto-generated tests**: A source generator scans test components in `dymaptic.GeoBlazor.Core.Test.Blazor.Shared` and generates MSTest classes +- **Browser pooling**: Limits concurrent browser instances to prevent resource exhaustion in CI environments +- **Docker support**: Can run test applications in Docker containers for consistent CI/CD environments +- **Parallel execution**: Tests run in parallel at the method level with browser pool management + +### Writing Tests + +Create test components in `dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/`: + +```razor +@inherits TestRunnerBase + +[TestMethod] +public async Task MyNewTest() +{ + // Test implementation using GeoBlazor components + await PassTest(); +} +``` + +### Configuration + +Set environment variables for test configuration: +- `ARCGIS_API_KEY`: Required ArcGIS API key +- `GEOBLAZOR_CORE_LICENSE_KEY`: Core license key +- `USE_CONTAINER`: Set to `true` for container mode +- `BROWSER_POOL_SIZE`: Maximum concurrent browsers (default: 2 in CI, 4 locally) diff --git a/global.json b/global.json new file mode 100644 index 000000000..7ce73a9e8 --- /dev/null +++ b/global.json @@ -0,0 +1,10 @@ +{ + "sdk": { + "version": "10.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 000000000..6ed51ff86 --- /dev/null +++ b/nuget.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/showDialog.ps1 b/showDialog.ps1 index d0377d21c..4fb49eb31 100644 --- a/showDialog.ps1 +++ b/showDialog.ps1 @@ -24,6 +24,11 @@ .PARAMETER DefaultButtonIndex Zero-based index of the default button. +.PARAMETER ListenForInput + When specified, the dialog will listen for standard input and append each line received to the message. + This allows external processes to update the dialog message dynamically while it's open. + (Windows only) + .EXAMPLE .\showDialog.ps1 -Message "Operation completed successfully" -Title "Success" -Type success @@ -39,6 +44,11 @@ $job = Start-Job { .\showDialog.ps1 -Message "Processing..." -Title "Please Wait" -Buttons None -Type information } # ... do work ... Stop-Job $job; Remove-Job $job + +.EXAMPLE + # Use -ListenForInput to dynamically update the dialog message from stdin + # Pipe output to the dialog to update its message in real-time + & { Write-Output "Step 1 complete"; Start-Sleep 1; Write-Output "Step 2 complete" } | .\showDialog.ps1 -Message "Starting..." -Title "Progress" -Buttons None -ListenForInput #> param( @@ -60,7 +70,9 @@ param( [int]$Duration = 0, - [switch]$Async + [switch]$Async, + + [switch]$ListenForInput ) $buttonMap = @{ @@ -82,14 +94,23 @@ function Show-WindowsDialog { [int]$DefaultIndex, [int]$CancelIndex, [int]$Duration, - [bool]$Async + [bool]$Async, + [bool]$ListenForInput ) + # Create synchronized hashtable for cross-runspace communication + $syncHash = [hashtable]::Synchronized(@{ + Message = $Message + DialogClosed = $false + Result = $null + }) + $runspace = [runspacefactory]::CreateRunspace() $runspace.Open() + $runspace.SessionStateProxy.SetVariable('syncHash', $syncHash) $PowerShell = [PowerShell]::Create().AddScript({ - param ($message, $title, $type, $buttonList, $defaultButtonIndex, $cancelButtonIndex, $duration) + param ($message, $title, $type, $buttonList, $defaultButtonIndex, $cancelButtonIndex, $duration, $syncHash) Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing @@ -153,7 +174,9 @@ function Show-WindowsDialog { $form.Text = $title $form.ForeColor = [System.Drawing.ColorTranslator]::FromHtml($fore) $form.BackColor = [System.Drawing.ColorTranslator]::FromHtml($back) - $form.ControlBox = $false + $form.ControlBox = $true + $form.MinimizeBox = $false + $form.MaximizeBox = $false $form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedSingle # Calculate dimensions @@ -162,35 +185,155 @@ function Show-WindowsDialog { $hasButtons = $buttonList.Count -gt 0 $totalButtonHeight = if ($hasButtons) { $buttonHeight + ($buttonMargin * 2) } else { 0 } $formWidth = 400 - $formHeight = 180 + $totalButtonHeight + $formHeight = 480 + $totalButtonHeight $form.Size = New-Object System.Drawing.Size($formWidth, $formHeight) - # Center on primary screen + # Center on primary screen, with offset for other dialog instances $monitor = [System.Windows.Forms.Screen]::PrimaryScreen $monitorWidth = $monitor.WorkingArea.Width $monitorHeight = $monitor.WorkingArea.Height + + # Calculate base center position + $baseCenterX = [int](($monitorWidth / 2) - ($form.Width / 2)) + $baseCenterY = [int](($monitorHeight / 2) - ($form.Height / 2)) + + # Find other PowerShell-hosted forms by checking for windows at similar positions + # Use a simple offset based on existing windows at the center position + $offset = 0 + $offsetStep = 30 + + # Get all visible top-level windows and check for overlaps + Add-Type @" + using System; + using System.Collections.Generic; + using System.Runtime.InteropServices; + using System.Text; + + public class WindowFinder { + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll", CharSet = CharSet.Auto)] + private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [StructLayout(LayoutKind.Sequential)] + public struct RECT { + public int Left, Top, Right, Bottom; + } + + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + public static List GetVisibleWindowRects() { + List rects = new List(); + EnumWindows((hWnd, lParam) => { + if (IsWindowVisible(hWnd)) { + RECT rect; + if (GetWindowRect(hWnd, out rect)) { + // Only include reasonably sized windows (not tiny or huge) + int width = rect.Right - rect.Left; + int height = rect.Bottom - rect.Top; + if (width > 100 && width < 800 && height > 100 && height < 800) { + rects.Add(rect); + } + } + } + return true; + }, IntPtr.Zero); + return rects; + } + } +"@ + + # Check for windows near the center position and calculate offset + $existingRects = [WindowFinder]::GetVisibleWindowRects() + $tolerance = 50 + + foreach ($rect in $existingRects) { + $windowX = $rect.Left + $windowY = $rect.Top + + # Check if this window is near our intended position (with current offset) + $targetX = $baseCenterX + $offset + $targetY = $baseCenterY + $offset + + if ([Math]::Abs($windowX - $targetX) -lt $tolerance -and [Math]::Abs($windowY - $targetY) -lt $tolerance) { + $offset += $offsetStep + } + } + + # Apply offset (cascade down and right) + $finalX = $baseCenterX + $offset + $finalY = $baseCenterY + $offset + + # Make sure we stay on screen + $finalX = [Math]::Min($finalX, $monitorWidth - $form.Width - 10) + $finalY = [Math]::Min($finalY, $monitorHeight - $form.Height - 10) + $finalX = [Math]::Max($finalX, 10) + $finalY = [Math]::Max($finalY, 10) + $form.StartPosition = "Manual" - $form.Location = New-Object System.Drawing.Point( - (($monitorWidth / 2) - ($form.Width / 2)), - (($monitorHeight / 2) - ($form.Height / 2)) - ) + $form.Location = New-Object System.Drawing.Point($finalX, $finalY) - # Add message label + # Add message control - use TextBox for scrolling when listening for input $marginX = 30 $marginY = 30 $labelWidth = $formWidth - ($marginX * 2) - 16 # Account for form border $labelHeight = $formHeight - ($marginY * 2) - $totalButtonHeight - 40 - $label = New-Object System.Windows.Forms.Label - $label.Location = New-Object System.Drawing.Size($marginX, $marginY) - $label.Size = New-Object System.Drawing.Size($labelWidth, $labelHeight) - $label.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Regular) - $label.Text = $message - $label.ForeColor = [System.Drawing.ColorTranslator]::FromHtml($fore) - $label.BackColor = [System.Drawing.ColorTranslator]::FromHtml($back) - $label.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter - $form.Controls.Add($label) + # Use a TextBox with scrolling capability + $textBox = New-Object System.Windows.Forms.TextBox + $textBox.Location = New-Object System.Drawing.Size($marginX, $marginY) + $textBox.Size = New-Object System.Drawing.Size($labelWidth, $labelHeight) + $textBox.Font = New-Object System.Drawing.Font("Segoe UI", 11, [System.Drawing.FontStyle]::Regular) + $textBox.Text = $message + $textBox.ForeColor = [System.Drawing.ColorTranslator]::FromHtml($fore) + $textBox.BackColor = [System.Drawing.ColorTranslator]::FromHtml($back) + $textBox.Multiline = $true + $textBox.ReadOnly = $true + $textBox.ScrollBars = [System.Windows.Forms.ScrollBars]::Vertical + $textBox.BorderStyle = [System.Windows.Forms.BorderStyle]::None + $textBox.TabStop = $false + $form.Controls.Add($textBox) + + # Timer to check for message updates from syncHash + $MessageTimer = New-Object System.Windows.Forms.Timer + $MessageTimer.Interval = 100 + $MessageTimer.Add_Tick({ + if ($null -ne $syncHash -and $syncHash.Message -ne $textBox.Text) { + $textBox.Text = $syncHash.Message + # Auto-scroll to the bottom + $textBox.SelectionStart = $textBox.Text.Length + $textBox.ScrollToCaret() + } + }.GetNewClosure()) + $MessageTimer.Start() + + # Handle form closing via X button + $form.Add_FormClosing({ + $MessageTimer.Stop() + $MessageTimer.Dispose() + $Timer.Stop() + $Timer.Dispose() + if ($null -ne $syncHash) { + # Set result to Cancel or first button if closed via X + $script:result = if ($null -ne $cancelButtonIndex -and $cancelButtonIndex -lt $buttonList.Count) { + $buttonList[$cancelButtonIndex] + } elseif ($buttonList.Count -gt 0) { + $buttonList[0] + } else { + $null + } + $syncHash.Result = $script:result + $syncHash.DialogClosed = $true + } + }.GetNewClosure()) # Create buttons (only if there are any) if ($hasButtons) { @@ -228,6 +371,12 @@ function Show-WindowsDialog { $script:result = $this.Text $Timer.Stop() $Timer.Dispose() + $MessageTimer.Stop() + $MessageTimer.Dispose() + if ($null -ne $syncHash) { + $syncHash.Result = $script:result + $syncHash.DialogClosed = $true + } $form.Close() }.GetNewClosure()) @@ -246,6 +395,12 @@ function Show-WindowsDialog { } $Timer.Stop() $Timer.Dispose() + $MessageTimer.Stop() + $MessageTimer.Dispose() + if ($null -ne $syncHash) { + $syncHash.Result = $script:result + $syncHash.DialogClosed = $true + } $form.Close() } }) @@ -288,6 +443,7 @@ function Show-WindowsDialog { }) $form.ShowDialog() | Out-Null + return $script:result }).AddArgument($Message). @@ -296,11 +452,21 @@ function Show-WindowsDialog { AddArgument($ButtonList). AddArgument($DefaultIndex). AddArgument($CancelIndex). - AddArgument($Duration) + AddArgument($Duration). + AddArgument($syncHash) $PowerShell.Runspace = $runspace - if ($Async) { + if ($ListenForInput) { + # Start dialog asynchronously and return syncHash for stdin listening + $handle = $PowerShell.BeginInvoke() + return @{ + SyncHash = $syncHash + PowerShell = $PowerShell + Handle = $handle + } + } + elseif ($Async) { $handle = $PowerShell.BeginInvoke() $null = Register-ObjectEvent -InputObject $PowerShell -MessageData $handle -EventName InvocationStateChanged -Action { @@ -521,7 +687,74 @@ elseif ($IsMacOS) { } else { # Windows - $result = Show-WindowsDialog -Message $Message -Title $Title -Type $Type -ButtonList $buttonList -DefaultIndex $defaultIndex -CancelIndex $cancelIndex -Duration $Duration -Async $Async + if ($ListenForInput) { + # Start dialog and listen for stdin input to append to message + $dialogInfo = Show-WindowsDialog -Message $Message -Title $Title -Type $Type -ButtonList $buttonList -DefaultIndex $defaultIndex -CancelIndex $cancelIndex -Duration $Duration -Async $false -ListenForInput $true + + $syncHash = $dialogInfo.SyncHash + $ps = $dialogInfo.PowerShell + $handle = $dialogInfo.Handle + + try { + # Read from stdin and append to message until dialog closes or EOF + # Use a background runspace to read stdin without blocking the main thread + $stdinRunspace = [runspacefactory]::CreateRunspace() + $stdinRunspace.Open() + $stdinRunspace.SessionStateProxy.SetVariable('syncHash', $syncHash) + + $stdinPS = [PowerShell]::Create().AddScript({ + param($syncHash) + $stdinStream = [System.Console]::OpenStandardInput() + $reader = New-Object System.IO.StreamReader($stdinStream) + + try { + while (-not $syncHash.DialogClosed) { + $line = $reader.ReadLine() + if ($null -eq $line) { + # EOF reached + break + } + # Append line to message (use CRLF for Windows TextBox) + $syncHash.Message = $syncHash.Message + "`r`n" + $line + } + } + finally { + $reader.Dispose() + $stdinStream.Dispose() + } + }).AddArgument($syncHash) + + $stdinPS.Runspace = $stdinRunspace + $stdinHandle = $stdinPS.BeginInvoke() + + # Wait for dialog to close + while (-not $syncHash.DialogClosed) { + Start-Sleep -Milliseconds 100 + } + + # Clean up stdin reader + if (-not $stdinHandle.IsCompleted) { + $stdinPS.Stop() + } + $stdinRunspace.Close() + $stdinRunspace.Dispose() + $stdinPS.Dispose() + } + finally { + # Wait for dialog to complete if still running + if (-not $handle.IsCompleted) { + $null = $ps.EndInvoke($handle) + } + $ps.Runspace.Close() + $ps.Runspace.Dispose() + $ps.Dispose() + } + + $result = $syncHash.Result + } + else { + $result = Show-WindowsDialog -Message $Message -Title $Title -Type $Type -ButtonList $buttonList -DefaultIndex $defaultIndex -CancelIndex $cancelIndex -Duration $Duration -Async $Async -ListenForInput $false + } } return $result diff --git a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs index db90f4d58..e111f8c3f 100644 --- a/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs +++ b/src/dymaptic.GeoBlazor.Core.SourceGenerator/ESBuildLauncher.cs @@ -109,7 +109,6 @@ private void SetProjectDirectoryAndConfiguration((string? projectDirectory, stri private void LaunchESBuild(SourceProductionContext context) { context.CancellationToken.ThrowIfCancellationRequested(); - ShowMessageBox("Starting GeoBlazor Core ESBuild process..."); Notification?.Invoke(this, "Starting Core ESBuild process..."); StringBuilder logBuilder = new StringBuilder(DateTime.Now.ToLongTimeString()); @@ -128,7 +127,6 @@ private void LaunchESBuild(SourceProductionContext context) if (_proPath is not null) { - ShowMessageBox("Starting GeoBlazor Pro ESBuild process..."); Notification?.Invoke(this, "Starting Pro ESBuild process..."); logBuilder.AppendLine("Starting Pro ESBuild process..."); @@ -233,10 +231,6 @@ internal class ESBuildRecord throw new Exception( $"An error occurred while running ESBuild: {ex.Message}\n\n{logBuilder}\n\n{ex.StackTrace}", ex); } - finally - { - CloseMessageBox(); - } } private void Log(string content, bool isError = false) @@ -271,7 +265,7 @@ private async Task RunPowerShellScript(string processName, string powershe RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, - CreateNoWindow = false + CreateNoWindow = true }; using var process = Process.Start(processStartInfo); @@ -326,36 +320,8 @@ private async Task ReadStreamAsync(StreamReader reader, string prefix, StringBui } } - private void ShowMessageBox(string message) - { - string path = Path.Combine(_corePath!, "..", ".."); - - ProcessStartInfo processStartInfo = new() - { - WorkingDirectory = path, - FileName = "pwsh", - Arguments = - $"-NoProfile -ExecutionPolicy ByPass -File showDialog.ps1 -Message \"{message}\" -Title \"GeoBlazor ESBuild\" -Buttons None", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - _popupProcesses.Add(Process.Start(processStartInfo)); - } - - private void CloseMessageBox() - { - foreach (Process process in _popupProcesses) - { - process.Kill(); - } - } - private static string? _corePath; private static string? _proPath; private static string? _configuration; private static bool _logESBuildOutput; - private List _popupProcesses = []; } \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core.sln b/src/dymaptic.GeoBlazor.Core.sln index 94fea22b8..f476ec49f 100644 --- a/src/dymaptic.GeoBlazor.Core.sln +++ b/src/dymaptic.GeoBlazor.Core.sln @@ -42,6 +42,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dymaptic.GeoBlazor.Core.Sam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dymaptic.GeoBlazor.Core.Analyzers", "dymaptic.GeoBlazor.Core.Analyzers\dymaptic.GeoBlazor.Core.Analyzers.csproj", "{468F9CE4-A24F-4EE0-9C5B-2AF88A369C30}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dymaptic.GeoBlazor.Core.Test.Automation", "..\test\dymaptic.GeoBlazor.Core.Test.Automation\dymaptic.GeoBlazor.Core.Test.Automation.csproj", "{679E2D83-C4D8-4350-83DC-9780364A0815}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration", "..\test\dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration\dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration.csproj", "{B70AE99D-782B-48E7-8713-DFAEB57809FF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -258,6 +262,30 @@ Global {468F9CE4-A24F-4EE0-9C5B-2AF88A369C30}.Release|x64.Build.0 = Release|Any CPU {468F9CE4-A24F-4EE0-9C5B-2AF88A369C30}.Release|x86.ActiveCfg = Release|Any CPU {468F9CE4-A24F-4EE0-9C5B-2AF88A369C30}.Release|x86.Build.0 = Release|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Debug|Any CPU.Build.0 = Debug|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Debug|x64.ActiveCfg = Debug|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Debug|x64.Build.0 = Debug|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Debug|x86.ActiveCfg = Debug|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Debug|x86.Build.0 = Debug|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Release|Any CPU.ActiveCfg = Release|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Release|Any CPU.Build.0 = Release|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Release|x64.ActiveCfg = Release|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Release|x64.Build.0 = Release|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Release|x86.ActiveCfg = Release|Any CPU + {679E2D83-C4D8-4350-83DC-9780364A0815}.Release|x86.Build.0 = Release|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Debug|x64.ActiveCfg = Debug|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Debug|x64.Build.0 = Debug|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Debug|x86.Build.0 = Debug|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Release|Any CPU.Build.0 = Release|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Release|x64.ActiveCfg = Release|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Release|x64.Build.0 = Release|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Release|x86.ActiveCfg = Release|Any CPU + {B70AE99D-782B-48E7-8713-DFAEB57809FF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/dymaptic.GeoBlazor.Core/Components/Portal.cs b/src/dymaptic.GeoBlazor.Core/Components/Portal.cs index 2fdfe2446..923a383c1 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Portal.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Portal.cs @@ -3,7 +3,7 @@ namespace dymaptic.GeoBlazor.Core.Components; public partial class Portal : MapComponent { /// - /// The URL to the portal instance. + /// The URL to the portal instance. Typically ends with "/portal". /// [Parameter] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/dymaptic.GeoBlazor.Core/Components/Widgets/BasemapToggleWidget.cs b/src/dymaptic.GeoBlazor.Core/Components/Widgets/BasemapToggleWidget.cs index 6251d02a0..dcf0a7b21 100644 --- a/src/dymaptic.GeoBlazor.Core/Components/Widgets/BasemapToggleWidget.cs +++ b/src/dymaptic.GeoBlazor.Core/Components/Widgets/BasemapToggleWidget.cs @@ -4,17 +4,6 @@ public partial class BasemapToggleWidget : Widget { /// public override WidgetType Type => WidgetType.BasemapToggle; - - /// - /// The name of the next basemap for toggling. - /// - /// - /// Set either or - /// - [Parameter] - [Obsolete("Use NextBasemapStyle instead")] - [CodeGenerationIgnore] - public string? NextBasemapName { get; set; } /// /// The next for toggling. @@ -76,9 +65,9 @@ public override async Task UnregisterChildComponent(MapComponent child) public override void ValidateRequiredChildren() { #pragma warning disable CS0618 // Type or member is obsolete - if (NextBasemap is null && NextBasemapName is null && NextBasemapStyle is null) + if (NextBasemap is null && NextBasemapStyle is null) { - throw new MissingRequiredOptionsChildElementException(nameof(BasemapToggleWidget), [nameof(NextBasemap), nameof(NextBasemapName), nameof(NextBasemapStyle)]); + throw new MissingRequiredOptionsChildElementException(nameof(BasemapToggleWidget), [nameof(NextBasemap), nameof(NextBasemapStyle)]); } #pragma warning restore CS0618 // Type or member is obsolete diff --git a/src/dymaptic.GeoBlazor.Core/Model/AuthenticationManager.cs b/src/dymaptic.GeoBlazor.Core/Model/AuthenticationManager.cs index 900ee0166..65c444d5e 100644 --- a/src/dymaptic.GeoBlazor.Core/Model/AuthenticationManager.cs +++ b/src/dymaptic.GeoBlazor.Core/Model/AuthenticationManager.cs @@ -1,7 +1,4 @@ -using Environment = System.Environment; - - -namespace dymaptic.GeoBlazor.Core.Model; +namespace dymaptic.GeoBlazor.Core.Model; /// /// Manager for all authentication-related tasks, tokens, and keys @@ -52,6 +49,9 @@ public string? AppId /// /// The ArcGIS Enterprise Portal URL, only required if using Enterprise authentication. /// + /// + /// Typically ends with "/portal". + /// public string? PortalUrl { get @@ -212,9 +212,6 @@ public async Task Logout() /// public async Task IsLoggedIn() { - // TODO: In V5, we should remove this line and always throw the exception below, but that would be a breaking change. It is safe to throw below this because the JavaScript is throwing an exception anyways without the AppId being set. - if (!string.IsNullOrWhiteSpace(ApiKey)) return true; - if (string.IsNullOrWhiteSpace(AppId)) { // If no AppId is provided, we cannot check if the user is logged in using Esri's logic. diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts b/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts index 7bde6b9a8..378ed16da 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts @@ -1694,51 +1694,45 @@ async function resetCenterToSpatialReference(center: Point, spatialReference: Sp function waitForRender(viewId: string, theme: string | null | undefined, dotNetRef: any, abortSignal: AbortSignal): void { const view = arcGisObjectRefs[viewId] as View; - try { - view.when().then(_ => { - if (hasValue(theme)) { - setViewTheme(theme, viewId); + view.when().then(_ => { + if (hasValue(theme)) { + setViewTheme(theme, viewId); + } + let isRendered = false; + let rendering = false; + const interval = setInterval(async () => { + if (view === undefined || view === null || abortSignal.aborted) { + clearInterval(interval); + return; } - let isRendered = false; - let rendering = false; - const interval = setInterval(async () => { - if (view === undefined || view === null || abortSignal.aborted) { - clearInterval(interval); - return; - } - if (!view.updating && !isRendered && !rendering) { - notifyExtentChanged = true; - // listen for click on zoom widget - if (!widgetListenerAdded) { - let widgetQuery = '[title="Zoom in"], [title="Zoom out"], [title="Find my location"], [class="esri-bookmarks__list"], [title="Default map view"], [title="Reset map orientation"]'; - let widgetButtons = document.querySelectorAll(widgetQuery); - for (let i = 0; i < widgetButtons.length; i++) { - widgetButtons[i].removeEventListener('click', setUserChangedViewExtent); - widgetButtons[i].addEventListener('click', setUserChangedViewExtent); - } - widgetListenerAdded = true; + if (!view.updating && !isRendered && !rendering) { + notifyExtentChanged = true; + // listen for click on zoom widget + if (!widgetListenerAdded) { + let widgetQuery = '[title="Zoom in"], [title="Zoom out"], [title="Find my location"], [class="esri-bookmarks__list"], [title="Default map view"], [title="Reset map orientation"]'; + let widgetButtons = document.querySelectorAll(widgetQuery); + for (let i = 0; i < widgetButtons.length; i++) { + widgetButtons[i].removeEventListener('click', setUserChangedViewExtent); + widgetButtons[i].addEventListener('click', setUserChangedViewExtent); } + widgetListenerAdded = true; + } - try { - rendering = true; - requestAnimationFrame(async () => { - await dotNetRef.invokeMethodAsync('OnJsViewRendered') - }); - } catch { - // we must be disconnected - } - rendering = false; - isRendered = true; - } else if (isRendered && view.updating) { - isRendered = false; + try { + rendering = true; + requestAnimationFrame(async () => { + await dotNetRef.invokeMethodAsync('OnJsViewRendered') + }); + } catch { + // we must be disconnected } - }, 100); - }).catch((error) => !promiseUtils.isAbortError(error) && console.error(error)); - } catch (error: any) { - if (!promiseUtils.isAbortError(error) && !abortSignal.aborted) { - console.error(error); - } - } + rendering = false; + isRendered = true; + } else if (isRendered && view.updating) { + isRendered = false; + } + }, 100); + }); } let widgetListenerAdded = false; diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/geoBlazorCore.ts b/src/dymaptic.GeoBlazor.Core/Scripts/geoBlazorCore.ts index c811d29c0..4e676be17 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/geoBlazorCore.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/geoBlazorCore.ts @@ -24,6 +24,14 @@ export const dotNetRefs: Record = {}; const observers: Record = {}; export let Pro: any; + +// Polyfill for crypto.randomUUID +if (typeof crypto !== 'undefined' && !crypto.randomUUID) { + crypto.randomUUID = () => '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c => + (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16) + ) as any; +} + export function setPro(pro: any): void { Pro = pro; } diff --git a/src/dymaptic.GeoBlazor.Core/Scripts/layerView.ts b/src/dymaptic.GeoBlazor.Core/Scripts/layerView.ts index ac166812c..622fd3491 100644 --- a/src/dymaptic.GeoBlazor.Core/Scripts/layerView.ts +++ b/src/dymaptic.GeoBlazor.Core/Scripts/layerView.ts @@ -1,7 +1,8 @@ import Layer from "@arcgis/core/layers/Layer"; -import {arcGisObjectRefs, dotNetRefs, hasValue, jsObjectRefs, lookupGeoBlazorId, sanitize} from './geoBlazorCore'; +import {arcGisObjectRefs, dotNetRefs, hasValue, jsObjectRefs, lookupGeoBlazorId, Pro} from './geoBlazorCore'; import MapView from "@arcgis/core/views/MapView"; import SceneView from "@arcgis/core/views/SceneView"; +import {DotNetLayerView} from "./definitions"; export async function buildJsLayerView(dotNetObject: any, layerId: string | null, viewId: string | null): Promise { if (!hasValue(dotNetObject?.layer)) { @@ -69,6 +70,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null dnLayerView = await buildDotNetWFSLayerView(jsObject, layerId, viewId); break; // case 'building-scene': + // if (!Pro) { + // return await buildDefaultLayerView(jsObject, layerId, viewId); + // } // try { // // @ts-ignore GeoBlazor Pro only // let {buildDotNetBuildingSceneLayerView} = await import('./buildingSceneLayerView'); @@ -78,6 +82,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null // } // break; case 'ogc-feature': + if (!Pro) { + return await buildDefaultLayerView(jsObject, layerId, viewId); + } try { // @ts-ignore GeoBlazor Pro Only let {buildDotNetOGCFeatureLayerView} = await import('./oGCFeatureLayerView'); @@ -87,6 +94,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null } break; case 'catalog': + if (!Pro) { + return await buildDefaultLayerView(jsObject, layerId, viewId); + } try { // @ts-ignore GeoBlazor Pro Only let {buildDotNetCatalogLayerView} = await import('./catalogLayerView'); @@ -96,6 +106,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null } break; case 'catalog-footprint': + if (!Pro) { + return await buildDefaultLayerView(jsObject, layerId, viewId); + } try { // @ts-ignore GeoBlazor Pro Only let {buildDotNetCatalogFootprintLayerView} = await import('./catalogFootprintLayerView'); @@ -105,6 +118,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null } break; case 'catalog-dynamic-group': + if (!Pro) { + return await buildDefaultLayerView(jsObject, layerId, viewId); + } try { // @ts-ignore GeoBlazor Pro Only let {buildDotNetCatalogDynamicGroupLayerView} = await import('./catalogDynamicGroupLayerView'); @@ -114,6 +130,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null } break; // case 'point-cloud': + // if (!Pro) { + // return await buildDefaultLayerView(jsObject, layerId, viewId); + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {buildDotNetPointCloudLayerView} = await import('./pointCloudLayerView'); @@ -123,6 +142,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null // } // break; // case 'scene': + // if (!Pro) { + // return await buildDefaultLayerView(jsObject, layerId, viewId); + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {buildDotNetSceneLayerView} = await import('./sceneLayerView'); @@ -132,6 +154,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null // } // break; // case 'stream': + // if (!Pro) { + // return await buildDefaultLayerView(jsObject, layerId, viewId); + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {buildDotNetStreamLayerView} = await import('./streamLayerView'); @@ -141,6 +166,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null // } // break; // case 'media': + // if (!Pro) { + // return await buildDefaultLayerView(jsObject, layerId, viewId); + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {buildDotNetMediaLayerView} = await import('./mediaLayerView'); @@ -150,6 +178,9 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null // } // break; case 'vector-tile': + if (!Pro) { + return await buildDefaultLayerView(jsObject, layerId, viewId); + } try { let {buildDotNetVectorTileLayerView} = await import('./vectorTileLayerView'); dnLayerView = await buildDotNetVectorTileLayerView(jsObject, layerId, viewId); @@ -158,33 +189,40 @@ export async function buildDotNetLayerView(jsObject: any, layerId: string | null } break; default: - dnLayerView = {}; - if (hasValue(jsObject.spatialReferenceSupported)) { - dnLayerView.spatialReferenceSupported = jsObject.spatialReferenceSupported; - } - if (hasValue(jsObject.suspended)) { - dnLayerView.suspended = jsObject.suspended; - } - if (hasValue(jsObject.updating)) { - dnLayerView.updating = jsObject.updating; - } - if (hasValue(jsObject.visibleAtCurrentScale)) { - dnLayerView.visibleAtCurrentScale = jsObject.visibleAtCurrentScale; - } - if (hasValue(jsObject.visibleAtCurrentTimeExtent)) { - dnLayerView.visibleAtCurrentTimeExtent = jsObject.visibleAtCurrentTimeExtent; - } + return await buildDefaultLayerView(jsObject, layerId, viewId); + } - if (!hasValue(layerId) && hasValue(viewId)) { - let dotNetRef = dotNetRefs[viewId!]; - layerId = await dotNetRef.invokeMethodAsync('GetId'); - } + dnLayerView.type = jsObject.layer.type; - dnLayerView.layerId = layerId; + return dnLayerView; +} + +async function buildDefaultLayerView(jsObject: any, layerId: string | null, viewId: string | null): Promise { + let dnLayerView: any = {}; + if (hasValue(jsObject.spatialReferenceSupported)) { + dnLayerView.spatialReferenceSupported = jsObject.spatialReferenceSupported; + } + if (hasValue(jsObject.suspended)) { + dnLayerView.suspended = jsObject.suspended; + } + if (hasValue(jsObject.updating)) { + dnLayerView.updating = jsObject.updating; + } + if (hasValue(jsObject.visibleAtCurrentScale)) { + dnLayerView.visibleAtCurrentScale = jsObject.visibleAtCurrentScale; + } + if (hasValue(jsObject.visibleAtCurrentTimeExtent)) { + dnLayerView.visibleAtCurrentTimeExtent = jsObject.visibleAtCurrentTimeExtent; } - dnLayerView.type = jsObject.layer.type; + if (!hasValue(layerId) && hasValue(viewId)) { + let dotNetRef = dotNetRefs[viewId!]; + layerId = await dotNetRef.invokeMethodAsync('GetId'); + } + dnLayerView.layerId = layerId; + dnLayerView.type = jsObject.layer.type; + return dnLayerView; } @@ -237,6 +275,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { return new WFSLayerViewWrapper(jsLayerView); } case 'ogc-feature': { + if (!Pro) { + return jsLayerView; + } try { // @ts-ignore GeoBlazor Pro Only let {default: OGCFeatureLayerViewWrapper} = await import('./oGCFeatureLayerView'); @@ -246,6 +287,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { } } case 'catalog': { + if (!Pro) { + return jsLayerView; + } try { // @ts-ignore GeoBlazor Pro Only let {default: CatalogLayerViewWrapper} = await import('./catalogLayerView'); @@ -255,6 +299,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { } } case 'catalog-footprint': { + if (!Pro) { + return jsLayerView; + } try { // @ts-ignore GeoBlazor Pro Only let {default: CatalogFootprintLayerViewWrapper} = await import('./catalogFootprintLayerView'); @@ -264,6 +311,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { } } case 'catalog-dynamic-group': { + if (!Pro) { + return jsLayerView; + } try { // @ts-ignore GeoBlazor Pro Only let {default: CatalogDynamicGroupLayerViewWrapper} = await import('./catalogDynamicGroupLayerView'); @@ -273,6 +323,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { } } case 'group': { + if (!Pro) { + return jsLayerView; + } try { // @ts-ignore GeoBlazor Pro Only let {default: GroupLayerViewWrapper} = await import('./groupLayerView'); @@ -282,6 +335,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { } } // case 'point-cloud': { + // if (!Pro) { + // return jsLayerView; + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {default: PointCloudLayerViewWrapper} = await import('./pointCloudLayerView'); @@ -291,6 +347,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { // } // } // case 'scene': { + // if (!Pro) { + // return jsLayerView; + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {default: SceneLayerViewWrapper} = await import('./sceneLayerView'); @@ -300,6 +359,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { // } // } // case 'stream': { + // if (!Pro) { + // return jsLayerView; + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {default: StreamLayerViewWrapper} = await import('./streamLayerView'); @@ -309,6 +371,9 @@ export async function buildJsLayerViewWrapper(jsLayerView: any): Promise { // } // } // case 'media': { + // if (!Pro) { + // return jsLayerView; + // } // try { // // @ts-ignore GeoBlazor Pro Only // let {default: MediaLayerViewWrapper} = await import('./mediaLayerView'); diff --git a/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_linecoverage.svg new file mode 100644 index 000000000..e69de29bb diff --git a/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg b/src/dymaptic.GeoBlazor.Core/badge_methodcoverage.svg new file mode 100644 index 000000000..e69de29bb diff --git a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj index 1920cbc9a..3b56a3245 100644 --- a/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj +++ b/src/dymaptic.GeoBlazor.Core/dymaptic.GeoBlazor.Core.csproj @@ -63,7 +63,7 @@ - + true true Documentation @@ -77,6 +77,10 @@ + + + + diff --git a/src/dymaptic.GeoBlazor.Core/esBuild.js b/src/dymaptic.GeoBlazor.Core/esBuild.js new file mode 100644 index 000000000..7628beb29 --- /dev/null +++ b/src/dymaptic.GeoBlazor.Core/esBuild.js @@ -0,0 +1,63 @@ +import esbuild from 'esbuild'; +import eslint from 'esbuild-plugin-eslint'; +import { cleanPlugin } from 'esbuild-clean-plugin'; +import fs from 'fs'; +import path from 'path'; +import process from 'process'; +import { execSync } from 'child_process'; + +const args = process.argv.slice(2); +const isRelease = args.includes('--release'); + +const RECORD_FILE = path.resolve('../../.esbuild-record.json'); +const OUTPUT_DIR = path.resolve('./wwwroot/js'); + +function getCurrentGitBranch() { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); + return branch; + } catch (error) { + console.warn('Failed to get git branch name:', error.message); + return 'unknown'; + } +} + +function saveBuildRecord() { + fs.writeFileSync(RECORD_FILE, JSON.stringify({ + timestamp: Date.now(), + branch: getCurrentGitBranch() + }), 'utf-8'); +} + +let options = { + entryPoints: ['./Scripts/geoBlazorCore.ts'], + chunkNames: 'core_[name]_[hash]', + bundle: true, + sourcemap: true, + format: 'esm', + outdir: OUTPUT_DIR, + splitting: true, + loader: { + ".woff2": "file" + }, + metafile: true, + minify: isRelease, + plugins: [eslint({ + throwOnError: true + }), + cleanPlugin()] +} + +// check if output directory exists +if (!fs.existsSync(OUTPUT_DIR)) { + console.log('Output directory does not exist. Creating it.'); + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); +} + +try { + await esbuild.build(options); + saveBuildRecord(); +} catch (err) { + console.error(`ESBuild Failed: ${err}`); + process.exit(1); +} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/esBuild.ps1 b/src/dymaptic.GeoBlazor.Core/esBuild.ps1 index 7a9f805dd..03d5d366a 100644 --- a/src/dymaptic.GeoBlazor.Core/esBuild.ps1 +++ b/src/dymaptic.GeoBlazor.Core/esBuild.ps1 @@ -1,4 +1,4 @@ -param([string][Alias("c")]$Configuration = "Debug", +param([string][Alias("c")]$Configuration = "Debug", [switch][Alias("f")]$Force, [switch][Alias("h")]$Help) @@ -19,6 +19,101 @@ $DebugLockFilePath = Join-Path -Path $PSScriptRoot "esBuild.Debug.lock" $ReleaseLockFilePath = Join-Path -Path $PSScriptRoot "esBuild.Release.lock" $LockFilePath = if ($Configuration.ToLowerInvariant() -eq "release") { $ReleaseLockFilePath } else { $DebugLockFilePath } +# Check for changes before starting the dialog +$RecordFilePath = Join-Path -Path $PSScriptRoot ".." ".." ".esbuild-record.json" +$ScriptsDir = Join-Path -Path $PSScriptRoot "Scripts" +$OutputDir = Join-Path -Path $PSScriptRoot "wwwroot" "js" + +# Handle --force flag: delete record file +if ($Force) { + if (Test-Path $RecordFilePath) { + Write-Host "Force rebuild: Deleting existing record file." + Remove-Item -Path $RecordFilePath -Force + } +} + +function Get-CurrentGitBranch { + try { + $branch = git rev-parse --abbrev-ref HEAD 2>$null + if ($LASTEXITCODE -eq 0) { + return $branch.Trim() + } + return "unknown" + } catch { + return "unknown" + } +} + +function Get-LastBuildRecord { + if (-not (Test-Path $RecordFilePath)) { + return @{ timestamp = 0; branch = "unknown" } + } + try { + $data = Get-Content -Path $RecordFilePath -Raw | ConvertFrom-Json + return @{ + timestamp = if ($data.timestamp) { $data.timestamp } else { 0 } + branch = if ($data.branch) { $data.branch } else { "unknown" } + } + } catch { + return @{ timestamp = 0; branch = "unknown" } + } +} + +function Get-ScriptsModifiedSince { + param([long]$LastTimestamp) + + # Convert JavaScript timestamp (milliseconds) to DateTime + $lastBuildTime = [DateTimeOffset]::FromUnixTimeMilliseconds($LastTimestamp).DateTime + + $files = Get-ChildItem -Path $ScriptsDir -Recurse -File + foreach ($file in $files) { + if ($file.LastWriteTime -gt $lastBuildTime) { + return $true + } + } + return $false +} + +# Check if build is needed +$lastBuild = Get-LastBuildRecord +$currentBranch = Get-CurrentGitBranch +$branchChanged = $currentBranch -ne $lastBuild.branch + +$needsBuild = $false +if ($branchChanged) { + Write-Host "Git branch changed from `"$($lastBuild.branch)`" to `"$currentBranch`". Rebuilding..." + $needsBuild = $true +} elseif (-not (Get-ScriptsModifiedSince -LastTimestamp $lastBuild.timestamp)) { + Write-Host "No changes in Scripts folder since last build." + + # Check output directory for existing files + if ((Test-Path $OutputDir) -and ((Get-ChildItem -Path $OutputDir -File).Count -gt 0)) { + Write-Host "Output directory is not empty. Skipping build." + exit 0 + } else { + Write-Host "Output directory is empty. Proceeding with build." + $needsBuild = $true + } +} else { + Write-Host "Changes detected in Scripts folder. Proceeding with build." + $needsBuild = $true +} + +if (-not $needsBuild) { + exit 0 +} + +# Start dialog process only if we're actually going to build +$ShowDialogPath = Join-Path -Path $PSScriptRoot ".." ".." "showDialog.ps1" +$DialogArgs = "-Message `"Starting GeoBlazor Core ESBuild process...`" -Title `"GeoBlazor Core ESBuild`" -Buttons None -ListenForInput" +$DialogStartInfo = New-Object System.Diagnostics.ProcessStartInfo +$DialogStartInfo.FileName = "pwsh" +$DialogStartInfo.Arguments = "-NoProfile -ExecutionPolicy ByPass -File `"$ShowDialogPath`" $DialogArgs" +$DialogStartInfo.RedirectStandardInput = $true +$DialogStartInfo.UseShellExecute = $false +$DialogStartInfo.CreateNoWindow = $true +$DialogProcess = [System.Diagnostics.Process]::Start($DialogStartInfo) + # Check if the process is locked for the current configuration $Locked = (($Configuration.ToLowerInvariant() -eq "debug") -and ($null -ne (Get-Item -Path $DebugLockFilePath -EA 0))) ` -or (($Configuration.ToLowerInvariant() -eq "release") -and ($null -ne (Get-Item -Path $ReleaseLockFilePath -EA 0))) @@ -39,6 +134,7 @@ if ($Locked) Write-Host "Cleared esBuild lock files" } else { Write-Output "Another instance of the script is already running. Exiting." + $DialogProcess.Kill() Exit 1 } } @@ -65,12 +161,18 @@ try $Install = npm install 2>&1 Write-Output $Install + foreach ($line in $Install) + { + $DialogProcess.StandardInput.WriteLine($line) + } $HasError = ($Install -like "*Error*") $HasWarning = ($Install -like "*Warning*") Write-Output "-----" + $DialogProcess.StandardInput.WriteLine("-----") if ($HasError -ne $null -or $HasWarning -ne $null) { Write-Output "NPM Install failed" + $DialogProcess.StandardInput.WriteLine("NPM Install failed") exit 1 } @@ -78,9 +180,14 @@ try { $Build = npm run releaseBuild 2>&1 Write-Output $Build + foreach ($line in $Build) + { + $DialogProcess.StandardInput.WriteLine($line) + } $HasError = ($Build -like "*Error*") $HasWarning = ($Build -like "*Warning*") Write-Output "-----" + $DialogProcess.StandardInput.WriteLine("-----") if ($HasError -ne $null -or $HasWarning -ne $null) { exit 1 @@ -90,20 +197,31 @@ try { $Build = npm run debugBuild 2>&1 Write-Output $Build + foreach ($line in $Build) + { + $DialogProcess.StandardInput.WriteLine($line) + } $HasError = ($Build -like "*Error*") $HasWarning = ($Build -like "*Warning*") Write-Output "-----" + $DialogProcess.StandardInput.WriteLine("-----") if ($HasError -ne $null -or $HasWarning -ne $null) { exit 1 } } Write-Output "NPM Build Complete" + $DialogProcess.StandardInput.WriteLine("NPM Build Complete") + Start-Sleep -Seconds 4 + $DialogProcess.Kill() exit 0 } catch { + Write-Output "An error occurred in esBuild.ps1" + $DialogProcess.StandardInput.WriteLine("An error occurred in esBuild.ps1") Write-Output $_ + $DialogProcess.StandardInput.WriteLine($_) exit 1 } finally diff --git a/src/dymaptic.GeoBlazor.Core/esBuildLogger.ps1 b/src/dymaptic.GeoBlazor.Core/esBuildLogger.ps1 index 5050e66bb..28723b709 100644 --- a/src/dymaptic.GeoBlazor.Core/esBuildLogger.ps1 +++ b/src/dymaptic.GeoBlazor.Core/esBuildLogger.ps1 @@ -1,352 +1,27 @@ param([string][Alias("c")]$Content, [bool]$isError=$false) +# ESBuild logger - writes build output to a rolling 2-day log file +# Usage: ./esBuildLogger.ps1 -Content "Build message" [-isError $true] -# We have some generic implementations of message boxes borrowed here, and then adapted. -# So there is some code that isn't being used. - -#usage -#Alkane-Popup [message] [title] [type] [buttons] [position] [duration] [asynchronous] -#Alkane-Popup "This is a message." "My Title" "success" "OKCancel" "center" 0 $false -#[message] a string of text -#[title] a string for the window title bar -#[type] options are "success" "warning" "error" "information". A blank string will be default black text on a white background. -#[buttons] options are "OK" "OKCancel" "AbortRetryIgnore" "YesNoCancel" "YesNo" "RetryCancel" -#[position] options are "topLeft" "topRight" "topCenter" "center" "centerLeft" "centerRight" "bottomLeft" "bottomCenter" "bottomRight" -#[duration] 0 will keep the popup open until clicked. Any other integer will close after that period in seconds. -#[asynchronous] $true or $false. $true will pop the message up and continue script execution (asynchronous). $false will pop the message up and wait for it to timeout or be manually closed on click. - - -# https://www.alkanesolutions.co.uk/2023/03/23/powershell-gui-message-box-popup/ -function Alkane-Popup() { - - param( - [string]$message, - [string]$title, - [string]$type, - [ValidateSet('OK', 'OKClear', 'OKShowLogsClear', 'OKCancel', 'AbortRetryIgnore', 'YesNoCancel', 'YesNo', 'RetryCancel')] - [string]$buttons = 'OK', - [string]$position, - [int]$duration, - [bool]$async, - [string]$logFile = (Join-Path $PSScriptRoot "esbuild.log") - ) - - $buttonMap = @{ - 'OK' = @{ buttonList = @('OK'); defaultButtonIndex = 0 } - 'OKClear' = @{ buttonList = @('OK', 'Clear'); defaultButtonIndex = 0; cancelButtonIndex = 1 } - 'OKShowLogsClear' = @{ buttonList = @('OK', 'Show Logs', 'Clear'); defaultButtonIndex = 0; cancelButtonIndex = 2 } - 'OKCancel' = @{ buttonList = @('OK', 'Cancel'); defaultButtonIndex = 0; cancelButtonIndex = 1 } - 'AbortRetryIgnore' = @{ buttonList = @('Abort', 'Retry', 'Ignore'); defaultButtonIndex = 2; cancelButtonIndex = 0 } - 'YesNoCancel' = @{ buttonList = @('Yes', 'No', 'Cancel'); defaultButtonIndex = 2; cancelButtonIndex = 2 } - 'YesNo' = @{ buttonList = @('Yes', 'No'); defaultButtonIndex = 0; cancelButtonIndex = 1 } - 'RetryCancel' = @{ buttonList = @('Retry', 'Cancel'); defaultButtonIndex = 0; cancelButtonIndex = 1 } - } - - $runspace = [runspacefactory]::CreateRunspace() - $runspace.Open() - $PowerShell = [PowerShell]::Create().AddScript({ - param ($message, $title, $type, $position, $duration, $buttonList, $defaultButtonIndex, $cancelButtonIndex, $logFile) - Add-Type -AssemblyName System.Windows.Forms - - $Timer = New-Object System.Windows.Forms.Timer - $Timer.Interval = 1000 - $back = "#FFFFFF" - $fore = "#000000" - $script:result = $null - - switch ($type) { - "success" { $back = "#60A917"; $fore = "#FFFFFF"; break; } - "warning" { $back = "#FA6800"; $fore = "#FFFFFF"; break; } - "information" { $back = "#1BA1E2"; $fore = "#FFFFFF"; break; } - "error" { $back = "#CE352C"; $fore = "#FFFFFF"; break; } - } - - #Build Form - $objForm = New-Object System.Windows.Forms.Form - $objForm.ShowInTaskbar = $false - $objForm.TopMost = $true - $objForm.Text = $title - $objForm.ForeColor = [System.Drawing.ColorTranslator]::FromHtml($fore); - $objForm.BackColor = [System.Drawing.ColorTranslator]::FromHtml($back); - $objForm.ControlBox = $false - $objForm.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::FixedSingle - - # Calculate button area height - $buttonHeight = 35 - $buttonMargin = 10 - $totalButtonHeight = $buttonHeight + ($buttonMargin * 2) - - $objForm.Size = New-Object System.Drawing.Size(400, 200 + $totalButtonHeight) - $marginx = 30 - $marginy = 30 - $tbWidth = ($objForm.Width) - ($marginx*2) - $tbHeight = ($objForm.Height) - ($marginy*2) - $totalButtonHeight - - #Add Rich text box - $objTB = New-Object System.Windows.Forms.Label - $objTB.Location = New-Object System.Drawing.Size($marginx,$marginy) - - #get primary screen width/height - $monitor = [System.Windows.Forms.Screen]::PrimaryScreen - $monitorWidth = $monitor.WorkingArea.Width - $monitorHeight = $monitor.WorkingArea.Height - $objForm.StartPosition = "Manual" - - #default center - $objForm.Location = New-Object System.Drawing.Point((($monitorWidth/2) - ($objForm.Width/2)), (($monitorHeight/2) - ($objForm.Height/2))); - - switch ($position) { - "topLeft" { $objForm.Location = New-Object System.Drawing.Point(0,0); break; } - "topRight" { $objForm.Location = New-Object System.Drawing.Point(($monitorWidth - $objForm.Width),0); break; } - "topCenter" { $objForm.Location = New-Object System.Drawing.Point((($monitorWidth/2) - ($objForm.Width/2)), 0); break; } - "center" { $objForm.Location = New-Object System.Drawing.Point((($monitorWidth/2) - ($objForm.Width/2)), (($monitorHeight/2) - ($objForm.Height/2))); break; } - "centerLeft" { $objForm.Location = New-Object System.Drawing.Point(0, (($monitorHeight/2) - ($objForm.Height/2))); break; } - "centerRight" { $objForm.Location = New-Object System.Drawing.Point(($monitorWidth - $objForm.Width), (($monitorHeight/2) - ($objForm.Height/2))); break; } - "bottomLeft" { $objForm.Location = New-Object System.Drawing.Point(0, ($monitorHeight - $objForm.Height)); break; } - "bottomCenter" { $objForm.Location = New-Object System.Drawing.Point((($monitorWidth/2) - ($objForm.Width/2)), ($monitorHeight - $objForm.Height)); break; } - "bottomRight" { $objForm.Location = New-Object System.Drawing.Point(($monitorWidth - $objForm.Width), ($monitorHeight - $objForm.Height)); break; } - } - - $objTB.Size = New-Object System.Drawing.Size($tbWidth,$tbHeight) - $objTB.Font = "Arial,14px,style=Regular" - $objTB.Text = $message - $objTB.ForeColor = [System.Drawing.ColorTranslator]::FromHtml($fore); - $objTB.BackColor = [System.Drawing.ColorTranslator]::FromHtml($back); - $objTB.BorderStyle = 'None' - $objTB.DetectUrls = $false - $objTB.SelectAll() - $objTB.SelectionAlignment = 'Center' - $objForm.Controls.Add($objTB) - #deselect text after centralising it - $objTB.Select(0, 0) - - #add some padding near scrollbar if visible - $scrollCalc = ($objTB.Width - $objTB.ClientSize.Width) #if 0 no scrollbar - if ($scrollCalc -ne 0) { - $objTB.RightMargin = ($objTB.Width-35) - } - - # Create buttons - $buttonWidth = 80 - $buttonSpacing = 10 - $totalButtonsWidth = ($buttonList.Count * $buttonWidth) + (($buttonList.Count - 1) * $buttonSpacing) - $startX = ($objForm.Width - $totalButtonsWidth) / 2 - $buttonY = $objForm.Height - $buttonHeight - $buttonMargin - 30 - - for ($i = 0; $i -lt $buttonList.Count; $i++) { - $button = New-Object System.Windows.Forms.Button - $button.Text = $buttonList[$i] - $button.Size = New-Object System.Drawing.Size($buttonWidth, $buttonHeight) - $button.Location = New-Object System.Drawing.Point(($startX + ($i * ($buttonWidth + $buttonSpacing))), $buttonY) - $button.BackColor = [System.Drawing.ColorTranslator]::FromHtml($back) - $button.ForeColor = [System.Drawing.ColorTranslator]::FromHtml($fore) - $button.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat - $button.FlatAppearance.BorderSize = 1 - $button.FlatAppearance.BorderColor = [System.Drawing.ColorTranslator]::FromHtml($fore) - - # Set as default button if specified - if ($i -eq $defaultButtonIndex) { - $objForm.AcceptButton = $button - $button.FlatAppearance.BorderSize = 2 - } - - # Add click event - $buttonText = $buttonList[$i] - $button.Add_Click({ - # Special handling for Clear button - delete lock files - if ($this.Text -eq "Clear") { - try { - # Get current directory (where esBuild*.lock files would be) - $currentDir = Get-Location - - # Delete esBuild*.lock files from current directory - $esBuildLocks = Get-ChildItem -Path $currentDir -Name "esBuild*.lock" -ErrorAction SilentlyContinue - foreach ($lockFile in $esBuildLocks) { - $fullPath = Join-Path $currentDir $lockFile - Remove-Item -Path $fullPath -Force -ErrorAction SilentlyContinue - Write-Host "Deleted: $fullPath" - } - - # Find Pro project directory - navigate up to find GeoBlazor.Pro folder - $proDir = $currentDir - while ($proDir -and -not (Test-Path (Join-Path $proDir "GeoBlazor.Pro"))) { - $proDir = Split-Path $proDir -Parent - } - - if ($proDir) { - $proProjectDir = Join-Path $proDir "GeoBlazor.Pro" - - # Delete esProBuild*.lock files from Pro project directory - $esProBuildLocks = Get-ChildItem -Path $proProjectDir -Name "esProBuild*.lock" -ErrorAction SilentlyContinue - foreach ($lockFile in $esProBuildLocks) { - $fullPath = Join-Path $proProjectDir $lockFile - Remove-Item -Path $fullPath -Force -ErrorAction SilentlyContinue - Write-Host "Deleted: $fullPath" - } - } - } - catch { - Write-Warning "Error deleting lock files: $($_.Exception.Message)" - } - } elseif ($this.Text -eq "Show Logs") { - # Open log file in default text editor - if (Test-Path $logFile) { - Start-Process -FilePath $logFile - } else { - [System.Windows.Forms.MessageBox]::Show("Log file not found: $logFile", "Error", [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Error) | Out-Null - } - } - - $script:result = $this.Text - $Timer.Dispose() - $objForm.Dispose() - }.GetNewClosure()) - - $objForm.Controls.Add($button) - } - - # Remove click handlers from textbox and form since we have buttons now - $script:countdown = $duration - - $Timer.Add_Tick({ - --$script:countdown - if ($script:countdown -lt 0) - { - $script:result = if ($null -ne $cancelButtonIndex) { $buttonList[$cancelButtonIndex] } else { $buttonList[0] } - $Timer.Dispose(); - $objForm.Dispose(); - } - }) - - if ($duration -gt 0) { - $Timer.Start() - } - - #bring form to front when shown - $objForm.Add_Shown({ - $this.focus() - $this.Activate(); - $this.BringToFront(); - }) - - $objForm.ShowDialog() | Out-Null - return $script:result - - }).AddArgument($message).` - AddArgument($title).` - AddArgument($type).` - AddArgument($position).` - AddArgument($duration).` - AddArgument($buttonMap[$buttons].buttonList).` - AddArgument($buttonMap[$buttons].defaultButtonIndex).` - AddArgument($buttonMap[$buttons].cancelButtonIndex).` - AddArgument($logFile) - - $state = @{ - Instance = $PowerShell - Handle = if ($async) { $PowerShell.BeginInvoke() } else { $PowerShell.Invoke() } - } - - $null = Register-ObjectEvent -InputObject $state.Instance -MessageData $state.Handle -EventName InvocationStateChanged -Action { - param([System.Management.Automation.PowerShell] $ps) - if($ps.InvocationStateInfo.State -in 'Completed', 'Failed', 'Stopped') { - $ps.Runspace.Close() - $ps.Runspace.Dispose() - $ps.EndInvoke($Event.MessageData) - $ps.Dispose() - [GC]::Collect() - } - } -} - -# https://stackoverflow.com/questions/58718191/is-there-a-way-to-display-a-pop-up-message-box-in-powershell-that-is-compatible -function Show-MessageBox { - [CmdletBinding(PositionalBinding=$false)] - param( - [Parameter(Mandatory, Position=0)] - [string] $Message, - [Parameter(Position=1)] - [string] $Title, - [Parameter(Position=2)] - [ValidateSet('OK', 'OKClear', 'OKShowLogsClear', 'OKCancel', 'AbortRetryIgnore', 'YesNoCancel', 'YesNo', 'RetryCancel')] - [string] $Buttons = 'OK', - [ValidateSet('information', 'warning', 'error', 'success')] - [string] $Type = 'information', - [ValidateSet(0, 1, 2)] - [int] $DefaultButtonIndex - ) - - # So that the $IsLinux and $IsMacOS PS Core-only - # variables can safely be accessed in WinPS. - Set-StrictMode -Off - - $buttonMap = @{ - 'OK' = @{ buttonList = 'OK'; defaultButtonIndex = 0 } - 'OKClear' = @{ buttonList = 'OK', 'Clear'; defaultButtonIndex = 0; cancelButtonIndex = 1 } - 'OKShowLogsClear' = @{ buttonList = 'OK', 'Show Logs', 'Clear'; defaultButtonIndex = 0; cancelButtonIndex = 2 } - 'OKCancel' = @{ buttonList = 'OK', 'Cancel'; defaultButtonIndex = 0; cancelButtonIndex = 1 } - 'AbortRetryIgnore' = @{ buttonList = 'Abort', 'Retry', 'Ignore'; defaultButtonIndex = 2; ; cancelButtonIndex = 0 }; - 'YesNoCancel' = @{ buttonList = 'Yes', 'No', 'Cancel'; defaultButtonIndex = 2; cancelButtonIndex = 2 }; - 'YesNo' = @{ buttonList = 'Yes', 'No'; defaultButtonIndex = 0; cancelButtonIndex = 1 } - 'RetryCancel' = @{ buttonList = 'Retry', 'Cancel'; defaultButtonIndex = 0; cancelButtonIndex = 1 } - } - - $numButtons = $buttonMap[$Buttons].buttonList.Count - $defaultIndex = [math]::Min($numButtons - 1, ($buttonMap[$Buttons].defaultButtonIndex, $DefaultButtonIndex)[$PSBoundParameters.ContainsKey('DefaultButtonIndex')]) - $cancelIndex = $buttonMap[$Buttons].cancelButtonIndex - - if ($IsLinux) { - Throw "Not supported on Linux." - } - elseif ($IsMacOS) { - - $iconClause = if ($Type -ne 'information') { 'as ' + $Type -replace 'error', 'critical' } - $buttonClause = "buttons { $($buttonMap[$Buttons].buttonList -replace '^', '"' -replace '$', '"' -join ',') }" - - $defaultButtonClause = 'default button ' + (1 + $defaultIndex) - if ($null -ne $cancelIndex -and $cancelIndex -ne $defaultIndex) { - $cancelButtonClause = 'cancel button ' + (1 + $cancelIndex) - } - - $appleScript = "display alert `"$Title`" message `"$Message`" $iconClause $buttonClause $defaultButtonClause $cancelButtonClause" #" - - Write-Verbose "AppleScript command: $appleScript" - - # Show the dialog. - # Note that if a cancel button is assigned, pressing Esc results in an - # error message indicating that the user canceled. - $result = $appleScript | osascript 2>$null - - # Output the name of the button chosen (string): - # The name of the cancel button, if the dialog was canceled with ESC, or the - # name of the clicked button, which is reported as "button:" - if (-not $result) { $buttonMap[$Buttons].buttonList[$buttonMap[$Buttons].cancelButtonIndex] } else { $result -replace '.+:' } - } - else { # Windows - Alkane-Popup -message $Message -title $Title -type $Type -buttons $Buttons -position 'center' -duration 0 -async $false - } -} - -# save the content to a log file for reference $logFile = Join-Path $PSScriptRoot "esbuild.log" +# Load existing log content and trim entries older than 2 days $logContent = Get-Content -Path $logFile -ErrorAction SilentlyContinue -$newLogContent = $logContent -$startIndex = 0 -$twoDaysAgo = (Get-Date).AddDays(-2); -if ($logContent) +$newLogContent = @() +$twoDaysAgo = (Get-Date).AddDays(-2) + +if ($logContent) { + $startIndex = 0 for ($i = 0; $i -lt $logContent.Count; $i++) { $line = $logContent[$i] - # check the timestamp starting the line - if ($line -match '^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]') + if ($line -match '^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]') { $timestamp = [datetime]$matches[1] - # if the timestamp is older than 2 days, remove the line - if ($timestamp -lt $twoDaysAgo) + if ($timestamp -lt $twoDaysAgo) { - $startIndex = $i + 1; + $startIndex = $i + 1 } else { @@ -354,23 +29,13 @@ if ($logContent) } } } - - $newLogContent = $logContent[$startIndex..$logContent.Count - 1] + $newLogContent = $logContent[$startIndex..($logContent.Count - 1)] } +# Add new entry with timestamp $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" -$logEntry = "`n[$timestamp] $Content" +$prefix = if ($isError) { "[ERROR]" } else { "" } +$logEntry = "[$timestamp]$prefix $Content" $newLogContent += $logEntry Set-Content -Path $logFile -Value $newLogContent -Force - -# if there is content in the $logFile older than 2 days, delete it - - -if ($isError) -{ - Show-MessageBox -Message "An error occurred during the esBuild step. Please check the log file for details." ` - -Title "esBuild Step Failed" ` - -Buttons "OKShowLogsClear" ` - -Type error -} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/esbuild.js b/src/dymaptic.GeoBlazor.Core/esbuild.js deleted file mode 100644 index 30ab6943f..000000000 --- a/src/dymaptic.GeoBlazor.Core/esbuild.js +++ /dev/null @@ -1,135 +0,0 @@ -import esbuild from 'esbuild'; -import eslint from 'esbuild-plugin-eslint'; -import { cleanPlugin } from 'esbuild-clean-plugin'; -import fs from 'fs'; -import path from 'path'; -import process from 'process'; -import { execSync } from 'child_process'; - -const args = process.argv.slice(2); -const isRelease = args.includes('--release'); -const force = args.includes('--force'); - -const RECORD_FILE = path.resolve('../../.esbuild-record.json'); -const SCRIPTS_DIR = path.resolve('./Scripts'); -const OUTPUT_DIR = path.resolve('./wwwroot/js'); - -if (force) { - // delete the record file if --force is specified - if (fs.existsSync(RECORD_FILE)) { - console.log('Force rebuild: Deleting existing record file.'); - fs.unlinkSync(RECORD_FILE); - } -} - -function getAllScriptFiles(dir) { - let results = []; - const list = fs.readdirSync(dir); - list.forEach(function(file) { - file = path.resolve(dir, file); - const stat = fs.statSync(file); - if (stat && stat.isDirectory()) { - results = results.concat(getAllScriptFiles(file)); - } else { - results.push(file); - } - }); - return results; -} - -function getCurrentGitBranch() { - try { - // Execute git command to get current branch name - const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); - return branch; - } catch (error) { - console.warn('Failed to get git branch name:', error.message); - return 'unknown'; - } -} - -function getLastBuildRecord() { - if (!fs.existsSync(RECORD_FILE)) return { timestamp: 0, branch: 'unknown' }; - try { - const data = fs.readFileSync(RECORD_FILE, 'utf-8'); - const parsed = JSON.parse(data); - return { - timestamp: parsed.timestamp || 0, - branch: parsed.branch || 'unknown' - }; - } catch { - return { timestamp: 0, branch: 'unknown' }; - } -} - -function saveBuildRecord() { - fs.writeFileSync(RECORD_FILE, JSON.stringify({ - timestamp: Date.now(), - branch: getCurrentGitBranch() - }), 'utf-8'); -} - -function scriptsModifiedSince(lastTimestamp) { - const files = getAllScriptFiles(SCRIPTS_DIR); - for (const file of files) { - const stat = fs.statSync(file); - if (stat.mtimeMs > lastTimestamp) { - return true; - } - } - return false; -} - -let options = { - entryPoints: ['./Scripts/geoBlazorCore.ts'], - chunkNames: 'core_[name]_[hash]', - bundle: true, - sourcemap: true, - format: 'esm', - outdir: OUTPUT_DIR, - splitting: true, - loader: { - ".woff2": "file" - }, - metafile: true, - minify: isRelease, - plugins: [eslint({ - throwOnError: true - }), - cleanPlugin()] -} - -// check if output directory exists -if (!fs.existsSync(OUTPUT_DIR)) { - console.log('Output directory does not exist. Creating it.'); - fs.mkdirSync(OUTPUT_DIR, { recursive: true }); -} - -const lastBuild = getLastBuildRecord(); -const currentBranch = getCurrentGitBranch(); -const branchChanged = currentBranch !== lastBuild.branch; - -if (branchChanged) { - console.log(`Git branch changed from "${lastBuild.branch}" to "${currentBranch}". Rebuilding...`); -} else if (!scriptsModifiedSince(lastBuild.timestamp)) { - console.log('No changes in Scripts folder since last build.'); - - // check output directory for existing files - const outputFiles = fs.readdirSync(OUTPUT_DIR); - if (outputFiles.length > 0) { - console.log('Output directory is not empty. Skipping build.'); - process.exit(0); - } else { - console.log('Output directory is empty. Proceeding with build.'); - } -} else { - console.log('Changes detected in Scripts folder. Proceeding with build.'); -} - -try { - await esbuild.build(options); - saveBuildRecord(); -} catch (err) { - console.error(`ESBuild Failed: ${err}`); - process.exit(1); -} \ No newline at end of file diff --git a/src/dymaptic.GeoBlazor.Core/package.json b/src/dymaptic.GeoBlazor.Core/package.json index 8b7db75c8..b9082b119 100644 --- a/src/dymaptic.GeoBlazor.Core/package.json +++ b/src/dymaptic.GeoBlazor.Core/package.json @@ -4,9 +4,9 @@ "main": "geoBlazorCore.js", "type": "module", "scripts": { - "debugBuild": "node ./esbuild.js --debug", - "watchBuild": "node ./esbuild.js --watch", - "releaseBuild": "node ./esbuild.js --release" + "debugBuild": "node ./esBuild.js --debug", + "watchBuild": "node ./esBuild.js --watch", + "releaseBuild": "node ./esBuild.js --release" }, "keywords": [], "author": "dymaptic", diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/GenerateTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/GenerateTests.cs new file mode 100644 index 000000000..98fb67251 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/GenerateTests.cs @@ -0,0 +1,176 @@ +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using System.Text; +using System.Text.RegularExpressions; + + +namespace dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration; + +[Generator] +public class GenerateTests : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValueProvider> testProvider = + context.AdditionalTextsProvider.Collect(); + context.RegisterSourceOutput(testProvider, Generate); + } + + private void Generate(SourceProductionContext context, ImmutableArray testClasses) + { + foreach (AdditionalText testClass in testClasses) + { + string testClassName = testClass.Path.Split('/', '\\').Last().Split('.').First(); + bool isPro = testClass.Path.Contains("dymaptic.GeoBlazor.Pro.Test.Blazor.Shared"); + string className = isPro ? $"PRO_{testClassName}" : $"CORE_{testClassName}"; + + List additionalAttributes = []; + List classAttributes = []; + Dictionary> testMethods = []; + + bool attributeFound = false; + var inMethod = false; + var openingBracketFound = false; + int lineNumber = 0; + var methodBracketCount = 0; + + foreach (var line in testClass.GetText()!.Lines.Select(l => l.ToString().Trim())) + { + lineNumber++; + + if (inMethod) + { + if (line.Contains("}")) + { + methodBracketCount++; + } + else if (line.Contains("{")) + { + openingBracketFound = true; + methodBracketCount--; + } + + if (openingBracketFound && (methodBracketCount == 0)) + { + inMethod = false; + } + + continue; + } + + if (attributeFound) + { + if (testMethodRegex.Match(line) is { Success: true } match) + { + inMethod = true; + + if (line.Contains("{")) + { + openingBracketFound = true; + } + + string methodName = match.Groups["testName"].Value; + testMethods.Add(methodName, additionalAttributes); + attributeFound = false; + additionalAttributes = []; + + continue; + } + + if (line.StartsWith("//")) + { + // commented out test + attributeFound = false; + + continue; + } + + throw new FormatException($"Line after [TestMethod] should be a method signature: Line {lineNumber + } in test class {testClassName}"); + } + + if (line.Contains("[TestMethod]") && !line.StartsWith("//")) + { + attributeFound = true; + } + else if (attributesToIgnore.Any(attribute => line.Contains($"[{attribute}"))) + { + // ignore these attributes + } + else if (attributeRegex.Match(line) is { Success: true }) + { + additionalAttributes.Add(line); + } + else if (razorAttributeRegex.Match(line) is { Success: true } razorAttribute) + { + var attributeContent = razorAttribute.Groups["attributeContent"].Value; + + // razor attributes are on the whole class + classAttributes.Add($"[{attributeContent}]"); + } + else if (classDeclarationRegex.Match(line) is { Success: true }) + { + classAttributes = additionalAttributes; + additionalAttributes = []; + } + } + + if (testMethods.Count == 0) + { + continue; + } + + StringBuilder sourceBuilder = new($$""" + namespace dymaptic.GeoBlazor.Core.Test.Automation; + + [TestClass]{{ + (classAttributes.Count > 0 + ? $"\n{string.Join("\n", classAttributes)}" + : "")}} + public class {{className}}: GeoBlazorTestClass + { + + """); + + foreach (KeyValuePair> testMethod in testMethods) + { + var methodName = testMethod.Key.Split('.').Last(); + var methodAttributes = testMethod.Value; + + sourceBuilder.AppendLine($$""" + [TestMethod]{{ + (methodAttributes.Count > 0 + ? $"\n {string.Join("\n ", methodAttributes)}" + : "")}} + public Task {{methodName}}() + { + return RunTestImplementation($"{{testClassName}}.{nameof({{methodName + }})}"); + } + + """); + } + + sourceBuilder.AppendLine("}"); + + context.AddSource($"{className}.g.cs", sourceBuilder.ToString()); + } + } + + private static readonly string[] attributesToIgnore = + [ + "TestClass", + "Inject", + "Parameter", + "CascadingParameter", + "IsolatedTest", + "SuppressMessage" + ]; + private static readonly Regex testMethodRegex = + new(@"^\s*public (?:async Task)?(?:void)? (?[A-Za-z0-9_]*)\(.*?$", RegexOptions.Compiled); + private static readonly Regex attributeRegex = new(@"^\[.+\]$", RegexOptions.Compiled); + private static readonly Regex razorAttributeRegex = + new("^@attribute (?[A-Za-z0-9_]*.*?)$", RegexOptions.Compiled); + private static readonly Regex classDeclarationRegex = + new(@"^public class (?[A-Za-z0-9_]+)\s*?:?.*?$", RegexOptions.Compiled); +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/Properties/launchSettings.json b/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/Properties/launchSettings.json new file mode 100644 index 000000000..fcd144398 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/Properties/launchSettings.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "DebugRoslynSourceGenerator": { + "commandName": "DebugRoslynComponent", + "targetProject": "../dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration.Sample/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration.Sample.csproj" + } + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration.csproj b/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration.csproj new file mode 100644 index 000000000..248b15335 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + false + enable + latest + + true + true + + dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration + dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration + + + + + + + + diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs new file mode 100644 index 000000000..9136183d0 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserPool.cs @@ -0,0 +1,329 @@ +using Microsoft.Playwright; +using System.Collections.Concurrent; +using System.Diagnostics; + + +namespace dymaptic.GeoBlazor.Core.Test.Automation; + +/// +/// Thread-safe pool of browser instances for parallel test execution. +/// Limits concurrent browser processes to prevent resource exhaustion on CI runners. +/// +public sealed class BrowserPool : IAsyncDisposable +{ + private static BrowserPool? _instance; + private static readonly Lock _instanceLock = new(); + + private readonly ConcurrentQueue _availableBrowsers = new(); + private readonly ConcurrentDictionary _checkedOutBrowsers = new(); + private readonly SemaphoreSlim _poolSemaphore; + private readonly SemaphoreSlim _creationLock = new(1, 1); + private readonly BrowserTypeLaunchOptions _launchOptions; + private readonly IBrowserType _browserType; + private readonly int _maxPoolSize; + private int _currentPoolSize; + private bool _disposed; + + /// + /// Maximum time to wait for a browser from the pool (5 minutes) + /// + private static readonly TimeSpan CheckoutTimeout = TimeSpan.FromMinutes(5); + + private BrowserPool(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions, int maxPoolSize) + { + _browserType = browserType; + _launchOptions = launchOptions; + _maxPoolSize = maxPoolSize; + _poolSemaphore = new SemaphoreSlim(maxPoolSize, maxPoolSize); + } + + /// + /// Gets or creates the singleton browser pool instance. + /// + public static BrowserPool GetInstance(IBrowserType browserType, BrowserTypeLaunchOptions launchOptions, int maxPoolSize = 2) + { + if (_instance is null) + { + lock (_instanceLock) + { + _instance ??= new BrowserPool(browserType, launchOptions, maxPoolSize); + } + } + + return _instance; + } + + /// + /// Tries to get the existing pool instance without creating one. + /// Used for cleanup scenarios. + /// + public static bool TryGetInstance(out BrowserPool? pool) + { + pool = _instance; + + return pool is not null; + } + + /// + /// Checks out a browser from the pool. Creates a new one if pool isn't full. + /// Waits if pool is exhausted until a browser becomes available. + /// + public async Task CheckoutAsync(CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // Wait for a slot in the pool + bool acquired = await _poolSemaphore.WaitAsync(CheckoutTimeout, cancellationToken) + .ConfigureAwait(false); + + if (!acquired) + { + throw new TimeoutException( + $"Timed out waiting for browser from pool after {CheckoutTimeout.TotalSeconds} seconds. " + + $"Pool size: {_maxPoolSize}, All browsers checked out."); + } + + try + { + // Try to get an existing healthy browser from the queue + while (_availableBrowsers.TryDequeue(out var pooledBrowser)) + { + if (await pooledBrowser.IsHealthyAsync().ConfigureAwait(false)) + { + pooledBrowser.MarkCheckedOut(); + _checkedOutBrowsers[pooledBrowser.Id] = pooledBrowser; + Trace.WriteLine($"Checked out existing browser {pooledBrowser.Id} from pool", "BROWSER_POOL"); + + return pooledBrowser; + } + + // Browser is unhealthy, dispose it and decrement pool size + Trace.WriteLine($"Disposing unhealthy browser {pooledBrowser.Id}", "BROWSER_POOL"); + await pooledBrowser.DisposeAsync().ConfigureAwait(false); + Interlocked.Decrement(ref _currentPoolSize); + } + + // No available browsers, create a new one + await _creationLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var browser = await _browserType.LaunchAsync(_launchOptions).ConfigureAwait(false); + var newPooledBrowser = new PooledBrowser(browser, this); + newPooledBrowser.MarkCheckedOut(); + _checkedOutBrowsers[newPooledBrowser.Id] = newPooledBrowser; + Interlocked.Increment(ref _currentPoolSize); + Trace.WriteLine( + $"Created new browser {newPooledBrowser.Id}, pool size: {_currentPoolSize}/{_maxPoolSize}", + "BROWSER_POOL"); + + return newPooledBrowser; + } + finally + { + _creationLock.Release(); + } + } + catch + { + // If we fail to get/create a browser, release the semaphore slot + _poolSemaphore.Release(); + + throw; + } + } + + /// + /// Returns a browser to the pool for reuse by other tests. + /// + public async Task ReturnAsync(PooledBrowser pooledBrowser) + { + if (_disposed) + { + await pooledBrowser.DisposeAsync().ConfigureAwait(false); + + return; + } + + if (!_checkedOutBrowsers.TryRemove(pooledBrowser.Id, out _)) + { + // Browser wasn't tracked as checked out - may be a duplicate return + Trace.WriteLine($"Warning: Browser {pooledBrowser.Id} returned but wasn't tracked as checked out", + "BROWSER_POOL"); + + return; + } + + // Close all contexts to reset state for next test + await pooledBrowser.CloseAllContextsAsync().ConfigureAwait(false); + + if (await pooledBrowser.IsHealthyAsync().ConfigureAwait(false)) + { + pooledBrowser.MarkReturned(); + _availableBrowsers.Enqueue(pooledBrowser); + Trace.WriteLine($"Returned browser {pooledBrowser.Id} to pool", "BROWSER_POOL"); + } + else + { + // Browser is unhealthy, dispose it + Trace.WriteLine($"Disposing unhealthy browser {pooledBrowser.Id} on return", "BROWSER_POOL"); + await pooledBrowser.DisposeAsync().ConfigureAwait(false); + Interlocked.Decrement(ref _currentPoolSize); + } + + // Release the semaphore slot + _poolSemaphore.Release(); + } + + /// + /// Reports a browser as crashed/failed. Removes from tracking and releases slot. + /// + public async Task ReportFailedAsync(PooledBrowser pooledBrowser) + { + _checkedOutBrowsers.TryRemove(pooledBrowser.Id, out _); + + try + { + await pooledBrowser.DisposeAsync().ConfigureAwait(false); + } + catch + { + // Ignore disposal errors for already-failed browsers + } + + Interlocked.Decrement(ref _currentPoolSize); + _poolSemaphore.Release(); + Trace.WriteLine($"Removed failed browser {pooledBrowser.Id} from pool", "BROWSER_POOL"); + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + + _disposed = true; + + // Dispose all available browsers + while (_availableBrowsers.TryDequeue(out var browser)) + { + await browser.DisposeAsync().ConfigureAwait(false); + } + + // Dispose all checked out browsers + foreach (var browser in _checkedOutBrowsers.Values) + { + await browser.DisposeAsync().ConfigureAwait(false); + } + + _checkedOutBrowsers.Clear(); + + _poolSemaphore.Dispose(); + _creationLock.Dispose(); + + _instance = null; + Trace.WriteLine("Browser pool disposed", "BROWSER_POOL"); + } + + /// + /// Gets pool statistics for diagnostics + /// + public (int Available, int CheckedOut, int TotalCreated) GetStats() => + (_availableBrowsers.Count, _checkedOutBrowsers.Count, _currentPoolSize); +} + +/// +/// Wrapper around IBrowser that tracks pool state and provides health checking. +/// +public sealed class PooledBrowser : IAsyncDisposable +{ + private readonly BrowserPool _pool; + private bool _disposed; + + public Guid Id { get; } = Guid.NewGuid(); + public IBrowser Browser { get; } + public DateTime CreatedAt { get; } = DateTime.UtcNow; + public DateTime? CheckedOutAt { get; private set; } + public DateTime? ReturnedAt { get; private set; } + public int UseCount { get; private set; } + + internal PooledBrowser(IBrowser browser, BrowserPool pool) + { + Browser = browser; + _pool = pool; + + // Subscribe to disconnect event for crash detection + browser.Disconnected += OnBrowserDisconnected; + } + + private async void OnBrowserDisconnected(object? sender, IBrowser browser) + { + Trace.WriteLine($"Browser {Id} disconnected unexpectedly", "BROWSER_POOL"); + await _pool.ReportFailedAsync(this).ConfigureAwait(false); + } + + internal void MarkCheckedOut() + { + CheckedOutAt = DateTime.UtcNow; + UseCount++; + } + + internal void MarkReturned() + { + ReturnedAt = DateTime.UtcNow; + } + + /// + /// Checks if the browser is still connected and responsive. + /// + public Task IsHealthyAsync() + { + if (_disposed) return Task.FromResult(false); + + try + { + // Check if browser is still connected + return Task.FromResult(Browser.IsConnected); + } + catch + { + return Task.FromResult(false); + } + } + + /// + /// Closes all browser contexts to reset state between tests. + /// + public async Task CloseAllContextsAsync() + { + try + { + var contexts = Browser.Contexts.ToList(); + + foreach (var context in contexts) + { + await context.CloseAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Error closing contexts for browser {Id}: {ex.Message}", "BROWSER_POOL"); + } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) return; + + _disposed = true; + + Browser.Disconnected -= OnBrowserDisconnected; + + try + { + await Browser.CloseAsync().ConfigureAwait(false); + } + catch + { + // Ignore errors during browser close + } + } +} diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserService.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserService.cs new file mode 100644 index 000000000..47523a066 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/BrowserService.cs @@ -0,0 +1,98 @@ +using Microsoft.Playwright; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Playwright.TestAdapter; + + +namespace dymaptic.GeoBlazor.Core.Test.Automation; + +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +internal class BrowserService : IWorkerService +{ + public IBrowser Browser { get; private set; } + + private BrowserService(IBrowser browser) + { + Browser = browser; + } + + public static Task Register(WorkerAwareTest test, IBrowserType browserType, (string, BrowserTypeConnectOptions?)? connectOptions) + { + return test.RegisterService("Browser", async () => new BrowserService(await CreateBrowser(browserType, connectOptions).ConfigureAwait(false))); + } + + private static async Task CreateBrowser(IBrowserType browserType, (string WSEndpoint, BrowserTypeConnectOptions? Options)? connectOptions) + { + if (connectOptions.HasValue) + { + var options = new BrowserTypeConnectOptions(connectOptions.Value.Options ?? new()); + var headers = options.Headers?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value) ?? []; + headers.Add("x-playwright-launch-options", JsonSerializer.Serialize(PlaywrightSettingsProvider.LaunchOptions, new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull })); + options.Headers = headers; + return await browserType.ConnectAsync(connectOptions.Value.WSEndpoint, options).ConfigureAwait(false); + } + + var legacyBrowser = await ConnectBasedOnEnv(browserType); + if (legacyBrowser != null) + { + return legacyBrowser; + } + return await browserType.LaunchAsync(PlaywrightSettingsProvider.LaunchOptions).ConfigureAwait(false); + } + + // TODO: Remove at some point + private static async Task ConnectBasedOnEnv(IBrowserType browserType) + { + var accessToken = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_ACCESS_TOKEN"); + var serviceUrl = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_URL"); + + if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(serviceUrl)) + { + return null; + } + + var exposeNetwork = Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_EXPOSE_NETWORK") ?? ""; + var os = Uri.EscapeDataString(Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_OS") ?? "linux"); + var runId = Uri.EscapeDataString(Environment.GetEnvironmentVariable("PLAYWRIGHT_SERVICE_RUN_ID") ?? DateTime.Now.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture)); + var apiVersion = "2023-10-01-preview"; + var wsEndpoint = $"{serviceUrl}?os={os}&runId={runId}&api-version={apiVersion}"; + + return await browserType.ConnectAsync(wsEndpoint, new BrowserTypeConnectOptions + { + Timeout = 3 * 60 * 1000, + ExposeNetwork = exposeNetwork, + Headers = new Dictionary + { + ["Authorization"] = $"Bearer {accessToken}", + ["x-playwright-launch-options"] = JsonSerializer.Serialize(PlaywrightSettingsProvider.LaunchOptions, new JsonSerializerOptions() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }) + } + }).ConfigureAwait(false); + } + + public Task ResetAsync() => Task.CompletedTask; + public Task DisposeAsync() => Browser.CloseAsync(); +} diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/CoverageSessionId.txt b/test/dymaptic.GeoBlazor.Core.Test.Automation/CoverageSessionId.txt new file mode 100644 index 000000000..78c44d991 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/CoverageSessionId.txt @@ -0,0 +1 @@ +403a93a9-0033-4a46-b431-1eb9d92b54e4 \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/DotEnvFileSource.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/DotEnvFileSource.cs new file mode 100644 index 000000000..01b5c6343 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/DotEnvFileSource.cs @@ -0,0 +1,183 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.FileProviders.Physical; +using Microsoft.Extensions.Primitives; +using System.Runtime.CompilerServices; +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.Test.Automation; + +public static class DotEnvFileSourceExtensions +{ + public static IConfigurationBuilder AddDotEnvFile(this IConfigurationBuilder builder, + bool optional, bool reloadOnChange, [CallerFilePath] string callerFilePath = "") + { + var directory = Path.GetDirectoryName(callerFilePath)!; + + DotEnvFileSource fileSource = new() + { + Path = Path.Combine(directory, ".env"), + Optional = optional, + ReloadOnChange = reloadOnChange, + FileProvider = new DotEnvFileProvider(directory) + }; + + return builder.Add(fileSource); + } +} + +public class DotEnvFileProvider(string directory) : IFileProvider +{ + public IFileInfo GetFileInfo(string subpath) + { + if (string.IsNullOrEmpty(subpath)) + { + return new NotFoundFileInfo(subpath); + } + + var fullPath = Path.Combine(directory, subpath); + + var fileInfo = new FileInfo(fullPath); + + return new PhysicalFileInfo(fileInfo); + } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + return _fileProvider.GetDirectoryContents(subpath); + } + + public IChangeToken Watch(string filter) + { + return _fileProvider.Watch(filter); + } + + private readonly PhysicalFileProvider _fileProvider = new(directory); +} + +public class DotEnvFileSource : FileConfigurationSource +{ + public override IConfigurationProvider Build(IConfigurationBuilder builder) + { + EnsureDefaults(builder); + + return new DotEnvConfigurationProvider(this); + } +} + +public class DotEnvConfigurationProvider(FileConfigurationSource source) : FileConfigurationProvider(source) +{ + public override void Load(Stream stream) + { + Data = DotEnvStreamConfigurationProvider.Read(stream); + } +} + +public class DotEnvStreamConfigurationProvider(StreamConfigurationSource source) : StreamConfigurationProvider(source) +{ + public static IDictionary Read(Stream stream) + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + using var reader = new StreamReader(stream); + var lineNumber = 0; + var multiline = false; + StringBuilder? multilineValueBuilder = null; + var multilineKey = string.Empty; + + while (reader.Peek() != -1) + { + var rawLine = reader.ReadLine()!; // Since Peak didn't return -1, stream hasn't ended. + var line = rawLine.Trim(); + lineNumber++; + + string key; + string value; + + if (multiline) + { + if (!line.EndsWith('"')) + { + multilineValueBuilder!.AppendLine(line); + + continue; + } + + // end of multi-line value + line = line[..^1]; + multilineValueBuilder!.AppendLine(line); + key = multilineKey!; + value = multilineValueBuilder.ToString(); + multilineKey = string.Empty; + multilineValueBuilder = null; + multiline = false; + } + else + { + // Ignore blank lines + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // Ignore comments + if (line[0] is ';' or '#' or '/') + { + continue; + } + + // key = value OR "value" + var separator = line.IndexOf('='); + + if (separator < 0) + { + throw new FormatException($"Line {lineNumber} is missing an '=' character in the .env file"); + } + + key = line[..separator].Trim(); + value = line[(separator + 1)..].Trim(); + + // Remove single quotes + if ((value.Length > 1) && (value[0] == '\'') && (value[^1] == '\'')) + { + value = value[1..^1]; + } + + // Remove double quotes + if ((value.Length > 1) && (value[0] == '"') && (value[^1] == '"')) + { + value = value[1..^1]; + } + + // start of a multi-line value + if ((value.Length > 1) && (value[0] == '"')) + { + multiline = true; + multilineValueBuilder = new StringBuilder(value); + multilineKey = key; + + // don't add yet, get the rest of the lines + continue; + } + } + + if (!data.TryAdd(key, value)) + { + throw new FormatException($"A duplicate key '{key}' was found in the .env file on line {lineNumber}"); + } + } + + if (multiline) + { + throw new FormatException( + "The .env file contains an unterminated multi-line value. Ensure that multiline values start and end with double quotes."); + } + + return data; + } + + public override void Load(Stream stream) + { + Data = Read(stream); + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs new file mode 100644 index 000000000..8a4c4face --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/GeoBlazorTestClass.cs @@ -0,0 +1,259 @@ +using Microsoft.Playwright; +using System.Diagnostics; +using System.Web; + + +namespace dymaptic.GeoBlazor.Core.Test.Automation; + +public abstract class GeoBlazorTestClass : PlaywrightTest +{ + private IBrowserContext Context { get; set; } = null!; + + [TestInitialize] + public Task TestSetup() + { + return Setup(0); + } + + [TestCleanup] + public async Task BrowserTearDown() + { + if (TestOK()) + { + foreach (var context in _contexts) + { + await context.CloseAsync().ConfigureAwait(false); + } + } + + _contexts.Clear(); + + // Return browser to pool instead of abandoning it + if (_pooledBrowser is not null) + { + try + { + await BrowserPool.GetInstance(BrowserType, _launchOptions!, TestConfig.BrowserPoolSize) + .ReturnAsync(_pooledBrowser) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Trace.WriteLine($"Error returning browser to pool: {ex.Message}", "TEST"); + } + finally + { + _pooledBrowser = null; + } + } + } + + protected virtual Task<(string, BrowserTypeConnectOptions?)?> ConnectOptionsAsync() + { + return Task.FromResult<(string, BrowserTypeConnectOptions?)?>(null); + } + + protected async Task RunTestImplementation(string testName, int retries = 0) + { + var page = await Context + .NewPageAsync() + .ConfigureAwait(false); + page.Console += HandleConsoleMessage; + page.PageError += HandlePageError; + string testMethodName = testName.Split('.').Last(); + + try + { + string testUrl = BuildTestUrl(testName); + + Trace.WriteLine($"Navigating to {testUrl}", "TEST"); + + await page.GotoAsync(testUrl, + _pageGotoOptions); + Trace.WriteLine($"Page loaded for {testName}", "TEST"); + ILocator sectionToggle = page.GetByTestId("section-toggle"); + await sectionToggle.ClickAsync(_clickOptions); + ILocator testBtn = page.GetByText("Run Test"); + await testBtn.ClickAsync(_clickOptions); + ILocator passedSpan = page.GetByTestId("passed"); + ILocator inconclusiveSpan = page.GetByTestId("inconclusive"); + + if (await inconclusiveSpan.IsVisibleAsync()) + { + // Inconclusive we treat as passing for our automation purposes + Trace.WriteLine($"{testName} Inconclusive", "TEST"); + } + else + { + await Expect(passedSpan).ToBeVisibleAsync(_visibleOptions); + await Expect(passedSpan).ToHaveTextAsync("Passed: 1"); + Trace.WriteLine($"{testName} Passed", "TEST"); + } + + if (_consoleMessages.TryGetValue(testName, out List? consoleMessages)) + { + foreach (string message in consoleMessages) + { + Trace.WriteLine(message, "TEST"); + } + } + } + catch (Exception ex) + { + if (_errorMessages.TryGetValue(testMethodName, out List? testErrors)) + { + foreach (string error in testErrors.Distinct()) + { + Trace.WriteLine(error, "ERROR"); + } + } + else + { + Trace.WriteLine($"{ex.Message}{Environment.NewLine}{ex.StackTrace}", "ERROR"); + } + + if (retries > 2) + { + Assert.Fail($"{testName} Failed"); + } + + await RunTestImplementation(testName, retries + 1); + } + finally + { + page.Console -= HandleConsoleMessage; + page.PageError -= HandlePageError; + } + } + + private string BuildTestUrl(string testName) + { + return $"{TestConfig.TestAppUrl}?testFilter={testName}&renderMode={ + TestConfig.RenderMode}{(TestConfig.ProOnly ? "&proOnly" : "")}{(TestConfig.CoreOnly ? "&coreOnly" : "")}"; + } + + private async Task Setup(int retries) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan(retries, 2); + + try + { + // Get pool instance and checkout a browser + var pool = BrowserPool.GetInstance(BrowserType, + _launchOptions!, + TestConfig.BrowserPoolSize); + + _pooledBrowser = await pool.CheckoutAsync().ConfigureAwait(false); + + // Create context on the pooled browser + Context = await NewContextAsync(ContextOptions()).ConfigureAwait(false); + } + catch (Exception e) + { + // transient error on setup found, seems to be very rare, so we will just retry + Trace.WriteLine($"{e.Message}{Environment.NewLine}{e.StackTrace}", "ERROR"); + + // If browser failed during setup, report it to the pool + if (_pooledBrowser is not null) + { + await BrowserPool.GetInstance(BrowserType, _launchOptions!, TestConfig.BrowserPoolSize) + .ReportFailedAsync(_pooledBrowser) + .ConfigureAwait(false); + _pooledBrowser = null; + } + + await Setup(retries + 1); + } + } + + private async Task NewContextAsync(BrowserNewContextOptions? options) + { + var context = await _pooledBrowser!.Browser.NewContextAsync(options).ConfigureAwait(false); + _contexts.Add(context); + + return context; + } + + private BrowserNewContextOptions ContextOptions() + { + return new BrowserNewContextOptions + { + BaseURL = TestConfig.TestAppUrl, + Locale = "en-US", + ColorScheme = ColorScheme.Light, + IgnoreHTTPSErrors = true + }; + } + + // Set up console message logging + private void HandleConsoleMessage(object? pageObject, IConsoleMessage message) + { + IPage page = (IPage)pageObject!; + Uri uri = new(page.Url); + string testName = HttpUtility.ParseQueryString(uri.Query)["testFilter"]!.Split('.').Last(); + + if (message.Type == "error" || message.Text.Contains("error")) + { + if (!_errorMessages.ContainsKey(testName)) + { + _errorMessages[testName] = []; + } + + _errorMessages[testName].Add(message.Text); + } + else + { + if (!_consoleMessages.ContainsKey(testName)) + { + _consoleMessages[testName] = []; + } + + _consoleMessages[testName].Add(message.Text); + } + } + + private void HandlePageError(object? pageObject, string message) + { + IPage page = (IPage)pageObject!; + Uri uri = new(page.Url); + string testName = HttpUtility.ParseQueryString(uri.Query)["testFilter"]!.Split('.').Last(); + + if (!_errorMessages.ContainsKey(testName)) + { + _errorMessages[testName] = []; + } + + _errorMessages[testName].Add(message); + } + + private readonly List _contexts = new(); + private readonly BrowserTypeLaunchOptions? _launchOptions = new() + { + Args = + [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--ignore-certificate-errors", + "--ignore-gpu-blocklist", + "--enable-webgl", + "--enable-webgl2-compute-context", + "--use-angle=default", + "--enable-gpu-rasterization", + "--enable-features=Vulkan", + "--enable-unsafe-webgpu" + ] + }; + + private readonly PageGotoOptions _pageGotoOptions = new() + { + WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 60_000 + }; + + private readonly LocatorClickOptions _clickOptions = new() { Timeout = 120_000 }; + + private readonly LocatorAssertionsToBeVisibleOptions _visibleOptions = new() { Timeout = 120_000 }; + + private readonly Dictionary> _consoleMessages = []; + private readonly Dictionary> _errorMessages = []; + private PooledBrowser? _pooledBrowser; +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/README.md b/test/dymaptic.GeoBlazor.Core.Test.Automation/README.md new file mode 100644 index 000000000..20b99e659 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/README.md @@ -0,0 +1,348 @@ +# GeoBlazor .NET Automation Tests + +.NET-based automated browser testing for GeoBlazor using MSTest and Playwright. Tests are auto-generated from test component files using a source generator. + +## Quick Start + +```bash +# Playwright browsers are installed automatically on first test run + +# Run all tests +dotnet test + +# Run with specific test filter +dotnet test --filter "FullyQualifiedName~FeatureLayerTests" + +# Run only Core tests +dotnet test -e CORE_ONLY=true + +# Run only Pro tests (requires Pro license) +dotnet test -e PRO_ONLY=true +``` + +## Configuration + +Configuration is loaded from multiple sources (in order of precedence): +1. Environment variables +2. `.env` file in the test project directory +3. `appsettings.json` / `appsettings.{Environment}.json` +4. User secrets + +### Required Environment Variables + +```env +# ArcGIS API credentials +ARCGIS_API_KEY=your_api_key + +# License keys (at least one required) +GEOBLAZOR_CORE_LICENSE_KEY=your_core_license_key +GEOBLAZOR_PRO_LICENSE_KEY=your_pro_license_key +``` + +### Optional Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `RENDER_MODE` | `WebAssembly` | Blazor render mode (`WebAssembly` or `Server`) | +| `CORE_ONLY` | `false` | Run only Core tests (auto-detected if Pro not available) | +| `PRO_ONLY` | `false` | Run only Pro tests | +| `USE_CONTAINER` | `false` | Run test app in Docker container instead of locally | +| `FORCE_BUILD` | `false` | Force rebuild of Docker container | +| `HTTPS_PORT` | `9443` | HTTPS port for test app | +| `HTTP_PORT` | `8080` | HTTP port for test app | +| `TEST_APP_URL` | `https://localhost:9443` | Test app URL | +| `BROWSER_POOL_SIZE` | `2` (CI) / `4` (local) | Maximum concurrent browser instances | + +## How It Works + +### Architecture + +``` ++----------------------------------------------------------+ +| MSTest + Source Generator | +| - Auto-generates test classes from Blazor components | +| - Discovers tests from Core.Test.Blazor.Shared | +| - Discovers tests from Pro.Test.Blazor.Shared (if Pro) | ++----------------------------+-----------------------------+ + | + v ++----------------------------------------------------------+ +| Browser Pool | +| - Manages pool of reusable Chromium instances | +| - Limits concurrent browsers to prevent resource | +| exhaustion (configurable via BROWSER_POOL_SIZE) | +| - Health checks and automatic browser recycling | ++----------------------------+-----------------------------+ + | + v ++----------------------------------------------------------+ +| GeoBlazorTestClass (Playwright) | +| - Checks out browser from pool | +| - Launches Chromium with GPU/WebGL2 support | +| - Navigates to test pages | +| - Clicks "Run Test" button | +| - Waits for pass/fail result | +| - Returns browser to pool | ++----------------------------+-----------------------------+ + | + v ++----------------------------------------------------------+ +| Test App (local dotnet run or Docker) | +| - Core: dymaptic.GeoBlazor.Core.Test.WebApp | +| - Pro: dymaptic.GeoBlazor.Pro.Test.WebApp | +| - Ports: 8080 (HTTP), 9443 (HTTPS) | ++----------------------------------------------------------+ +``` + +### Browser Pooling + +To prevent resource exhaustion when running many parallel tests, the framework uses a browser pool: + +- **Pool Size**: Configurable via `BROWSER_POOL_SIZE` (default: 2 in CI, 4 locally) +- **Checkout/Return**: Tests check out a browser, use it, then return it to the pool +- **Health Checks**: Browsers are validated before reuse; unhealthy browsers are replaced +- **Automatic Cleanup**: Failed browsers are disposed and replaced with fresh instances +- **Semaphore-based**: Uses `SemaphoreSlim` to limit concurrent browser creation + +This prevents the "Your computer has run out of resources" errors that can occur when many browsers are launched simultaneously. + +### Test Discovery (Source Generator) + +Tests are automatically discovered and generated from Blazor component files: + +1. The source generator scans `dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/` for test components +2. If Pro is available, it also scans `dymaptic.GeoBlazor.Pro.Test.Blazor.Shared/Components/` +3. For each test class, it generates an MSTest class with `[DynamicData]` test methods +4. Generated test classes are prefixed: `CORE_` for Core tests, `PRO_` for Pro tests + +### Test Execution Flow + +1. **Assembly Initialize**: + - Installs Playwright browsers if needed (via `Microsoft.Playwright.Program.Main`) + - Starts test app (locally via `dotnet run` or in Docker) + - Waits up to 8 minutes for app to be ready +2. **Per Test**: + - Checks out browser from pool (up to 3 minute wait) + - Creates new browser context with GPU-enabled Chromium + - Navigates to `{TestAppUrl}?testFilter={TestName}&renderMode={RenderMode}` + - Clicks section toggle and "Run Test" button + - Waits for "Passed: 1" indicator (up to 120 seconds) + - Retries up to 3 times on failure + - Returns browser to pool +3. **Assembly Cleanup**: + - Disposes browser pool + - Stops test app/container + - Kills orphaned processes + +### WebGL2 Requirements + +The ArcGIS Maps SDK for JavaScript requires WebGL2. The test framework: + +- Launches Chromium locally with GPU-enabling flags +- Uses flags: `--ignore-gpu-blocklist`, `--enable-webgl`, `--enable-webgl2-compute-context` +- Local GPU provides WebGL2 acceleration + +## Running Modes + +### Local Mode (Default) + +Tests start the appropriate test web application directly: + +```bash +dotnet test +``` + +The test framework will: +1. Start `dotnet run` on the Core or Pro test web app +2. Wait for HTTP response on the configured port (up to 8 minutes) +3. Run tests against the local app +4. Stop the app after tests complete + +### Container Mode + +Run the test app in Docker for CI/CD environments: + +```bash +dotnet test -e USE_CONTAINER=true +``` + +This uses Docker Compose with: +- `docker-compose-core.yml` - Core tests +- `docker-compose-pro.yml` - Pro tests (requires Pro availability) + +## Parallel Execution + +Tests run in parallel at the method level (configured via `[Parallelize(Scope = ExecutionScope.MethodLevel)]`). The browser pool ensures that only a limited number of browsers run concurrently, preventing resource exhaustion while maintaining parallelism. + +## Project Structure + +``` +dymaptic.GeoBlazor.Core.Test.Automation/ +├── GeoBlazorTestClass.cs # Base test class with Playwright integration +├── TestConfig.cs # Configuration and test app lifecycle +├── BrowserPool.cs # Thread-safe browser instance pooling +├── BrowserService.cs # Browser instance management +├── DotEnvFileSource.cs # .env file configuration provider +├── SourceGeneratorInputs.targets # MSBuild targets for source gen inputs +├── docker-compose-core.yml # Docker config for Core tests +├── docker-compose-pro.yml # Docker config for Pro tests +└── .env # Local configuration (not in git) + +dymaptic.GeoBlazor.Core.Test.Automation.SourceGeneration/ +└── GenerateTests.cs # Source generator for test classes +``` + +## Troubleshooting + +### Playwright browsers not installed + +Browsers are installed automatically during `AssemblyInitialize`. If issues occur: + +```bash +# Manual installation via PowerShell +pwsh bin/Debug/net10.0/playwright.ps1 install chromium +# or after Release build: +pwsh bin/Release/net10.0/playwright.ps1 install chromium +``` + +### Port already in use + +The test framework automatically kills processes on the configured HTTPS port before starting. If issues persist: + +```bash +# Windows (PowerShell) +Get-NetTCPConnection -LocalPort 9443 -State Listen | + Select-Object -ExpandProperty OwningProcess | + ForEach-Object { Stop-Process -Id $_ -Force } + +# Linux/macOS +lsof -i:9443 | awk '{if(NR>1)print $2}' | xargs -r kill -9 +``` + +### Container startup issues + +```bash +# Check container status +docker compose -f docker-compose-core.yml ps + +# View container logs +docker compose -f docker-compose-core.yml logs test-app + +# Rebuild and restart +docker compose -f docker-compose-core.yml down +docker compose -f docker-compose-core.yml up -d --build +``` + +### Resource exhaustion / "Out of resources" errors + +If you see errors about resources being exhausted: + +1. **Reduce pool size**: Set `BROWSER_POOL_SIZE=1` to run one browser at a time +2. **Check system resources**: Ensure adequate RAM and CPU available +3. **Close other applications**: Browsers are memory-intensive + +### Test timeouts + +Tests have the following timeouts: +- App startup wait: 8 minutes (240 attempts x 2 seconds) +- Browser checkout from pool: 3 minutes +- Page navigation: 60 seconds +- Button clicks: 120 seconds +- Pass/fail visibility: 120 seconds + +If tests consistently timeout, check: +- Test app startup in container logs or console +- WebGL availability (browser console for errors) +- Network connectivity to test endpoints +- Browser pool availability (may be waiting for a browser) + +### Debugging test failures + +Console and error messages from the browser are captured and logged: +- Console messages appear in test output on success +- Error messages appear in test output on failure + +To see browser activity, you can modify `_launchOptions` in `GeoBlazorTestClass.cs` to add `Headless = false`. + +## Writing New Tests + +1. Create a new Blazor component in `dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/` +2. Add test methods with `[TestMethod]` attribute +3. The source generator will automatically create corresponding MSTest methods +4. Run `dotnet build` to regenerate test classes + +Example test component structure: +```razor +@inherits TestRunnerBase + +[TestMethod] +public async Task MyNewTest() +{ + // Test implementation + await PassTest(); +} +``` + +## GitHub Actions Integration + +The test framework is integrated with GitHub Actions workflows for both Core and Pro repositories. + +### Core Repository Workflows + +Located in `.github/workflows/`: + +- **tests.yml**: Dedicated test workflow + - Runs on self-hosted Windows runner with GPU + - Uses container mode (`USE_CONTAINER=true`) + - Uploads TRX test results as artifacts + +- **dev-pr-build.yml**: PR validation + - Builds and tests on pull requests + - Uses self-hosted runner for Playwright tests + +### Pro Repository Workflows + +Located in `GeoBlazor.Pro/.github/workflows/`: + +- **tests.yml**: Pro test workflow + - Similar to Core but includes Pro license + - Tests Pro-specific features + +- **dev-pr-build.yml**: Pro PR validation + - Builds Pro components + - Runs Pro test suite + +### Self-Hosted Runner Requirements + +The GitHub Actions workflows use self-hosted Windows runners because: + +1. **GPU Required**: ArcGIS Maps SDK requires WebGL2/GPU acceleration +2. **Resource Intensive**: Browser tests need significant RAM +3. **License Keys**: Secure access to Pro license keys + +Runner setup requirements: +- Windows with GPU (for WebGL2) +- Docker Desktop installed +- .NET SDK installed +- Playwright browsers accessible + +### Example Workflow Configuration + +```yaml +# Example GitHub Actions step +- name: Run Tests + run: dotnet test --logger "trx;LogFileName=test-results.trx" + env: + ARCGIS_API_KEY: ${{ secrets.ARCGIS_API_KEY }} + GEOBLAZOR_CORE_LICENSE_KEY: ${{ secrets.GEOBLAZOR_CORE_LICENSE_KEY }} + GEOBLAZOR_PRO_LICENSE_KEY: ${{ secrets.GEOBLAZOR_PRO_LICENSE_KEY }} + USE_CONTAINER: true + BROWSER_POOL_SIZE: 2 + +- name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: "**/*.trx" +``` \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/SourceGeneratorInputs.targets b/test/dymaptic.GeoBlazor.Core.Test.Automation/SourceGeneratorInputs.targets new file mode 100644 index 000000000..9be1db2e7 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/SourceGeneratorInputs.targets @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/StringBuilderTraceListener.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/StringBuilderTraceListener.cs new file mode 100644 index 000000000..a7720fed1 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/StringBuilderTraceListener.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.Test.Automation; + +public class StringBuilderTraceListener(StringBuilder builder) : TraceListener +{ + public override void Write(string? message) + { + builder.Append(message); + } + + public override void WriteLine(string? message) + { + builder.AppendLine($"{DateTime.Now:u} {message}"); + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/StringExtensions.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/StringExtensions.cs new file mode 100644 index 000000000..2cb73a6a8 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/StringExtensions.cs @@ -0,0 +1,37 @@ +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.Test.Automation; + +public static class StringExtensions +{ + public static string ToKebabCase(this string val) + { + bool previousWasDigit = false; + StringBuilder sb = new(); + + for (var i = 0; i < val.Length; i++) + { + char c = val[i]; + + if (char.IsUpper(c) || char.IsDigit(c)) + { + if (!previousWasDigit && i > 0) + { + // only add a dash if the previous character was not a digit + sb.Append('-'); + } + + sb.Append(char.ToLower(c)); + } + else + { + sb.Append(c); + } + + previousWasDigit = char.IsDigit(c); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs new file mode 100644 index 000000000..d1222dc88 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/TestConfig.cs @@ -0,0 +1,692 @@ +using CliWrap; +using Microsoft.Extensions.Configuration; +using Microsoft.Playwright; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using System.Diagnostics; +using System.Net; +using System.Reflection; +using System.Text; + + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] + + +namespace dymaptic.GeoBlazor.Core.Test.Automation; + +[TestClass] +public class TestConfig +{ + public static string TestAppUrl { get; private set; } = ""; + public static BlazorMode RenderMode { get; private set; } + public static bool CoreOnly { get; private set; } + public static bool ProOnly { get; private set; } + + /// + /// Maximum number of concurrent browser instances in the pool. + /// Configurable via BROWSER_POOL_SIZE environment variable. + /// Default: 2 for CI environments, 4 for local development. + /// + public static int BrowserPoolSize { get; private set; } = 2; + + private static string ComposeFilePath => Path.Combine(_projectFolder, + _proAvailable && !CoreOnly ? "docker-compose-pro.yml" : "docker-compose-core.yml"); + private static string TestAppPath => _proAvailable + ? Path.GetFullPath(Path.Combine(_projectFolder, "..", "..", "..", "test", + "dymaptic.GeoBlazor.Pro.Test.WebApp", + "dymaptic.GeoBlazor.Pro.Test.WebApp", + "dymaptic.GeoBlazor.Pro.Test.WebApp.csproj")) + : Path.GetFullPath(Path.Combine(_projectFolder, "..", + "dymaptic.GeoBlazor.Core.Test.WebApp", + "dymaptic.GeoBlazor.Core.Test.WebApp", + "dymaptic.GeoBlazor.Core.Test.WebApp.csproj")); + private static string TestAppHttpUrl => $"http://localhost:{_httpPort}"; + private static string CoverageFolderPath => Path.Combine(_projectFolder, "coverage"); + private static string CoverageFilePath => + Path.Combine(CoverageFolderPath, $"coverage.{_coverageFileVersion}.{_coverageFormat}"); + private static string CoreRepoRoot => Path.GetFullPath(Path.Combine(_projectFolder, "..", "..")); + private static string ProRepoRoot => Path.GetFullPath(Path.Combine(_projectFolder, "..", "..", "..")); + private static string CoreProjectPath => + Path.GetFullPath(Path.Combine(CoreRepoRoot, "src", "dymaptic.GeoBlazor.Core")); + private static string ProProjectPath => + Path.GetFullPath(Path.Combine(ProRepoRoot, "src", "dymaptic.GeoBlazor.Pro")); + + [AssemblyInitialize] + public static async Task AssemblyInitialize(TestContext testContext) + { + Trace.Listeners.Add(new ConsoleTraceListener()); + Trace.Listeners.Add(new StringBuilderTraceListener(logBuilder)); + Trace.AutoFlush = true; + + // kill old running test apps and containers + await StopContainer(); + await StopTestApp(); + + SetupConfiguration(); + + if (_cover) + { + await InstallCodeCoverageTools(); + } + + await EnsurePlaywrightBrowsersAreInstalled(); + + if (_useContainer) + { + await StartContainer(); + } + else + { + await StartTestApp(); + } + } + + [AssemblyCleanup] + public static async Task AssemblyCleanup() + { + // Dispose browser pool first + if (BrowserPool.TryGetInstance(out var pool) && pool is not null) + { + Trace.WriteLine("Disposing browser pool...", "TEST_CLEANUP"); + await pool.DisposeAsync().ConfigureAwait(false); + Trace.WriteLine("Browser pool disposed", "TEST_CLEANUP"); + } + + cts.CancelAfter(5000); + + await gracefulCts.CancelAsync(); + + await Task.Delay(5000); + + if (_useContainer) + { + await StopContainer(); + } + else + { + await StopTestApp(); + } + + if (_cover) + { + await GenerateCoverageReport(); + } + + await File.WriteAllTextAsync(Path.Combine(_projectFolder, "test-run.log"), + logBuilder.ToString()); + } + + private static void SetupConfiguration() + { + _projectFolder = Assembly.GetAssembly(typeof(TestConfig))!.Location; + + if (_projectFolder.Contains("bin")) + { + var parts = _projectFolder.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + _runConfig = parts[^3]; + _targetFramework = parts[^2]; + } + + while (_projectFolder.Contains("bin")) + { + // get the test project folder + _projectFolder = Path.GetDirectoryName(_projectFolder)!; + } + + // assemblyLocation = GeoBlazor.Pro/GeoBlazor/test/dymaptic.GeoBlazor.Core.Test.Automation + // this pulls us up to GeoBlazor.Pro then finds the Dockerfile + var proDockerPath = Path.GetFullPath(Path.Combine(_projectFolder, "..", "..", "..", "Dockerfile")); + _proAvailable = File.Exists(proDockerPath); + + _configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", true) +#if DEBUG + .AddJsonFile("appsettings.Development.json", true) +#else + .AddJsonFile("appsettings.Production.json", true) +#endif + .AddUserSecrets() + .AddEnvironmentVariables() + .AddDotEnvFile(true, true) + .AddCommandLine(Environment.GetCommandLineArgs()) + .Build(); + + _httpsPort = _configuration.GetValue("HTTPS_PORT", 9443); + _httpPort = _configuration.GetValue("HTTP_PORT", 8080); + TestAppUrl = _configuration.GetValue("TEST_APP_URL", $"https://localhost:{_httpsPort}"); + + // Default to Server Mode for compatibility with Code Coverage Tools + var renderMode = _configuration.GetValue("RENDER_MODE", nameof(BlazorMode.Server)); + + if (Enum.TryParse(renderMode, true, out BlazorMode blazorMode)) + { + RenderMode = blazorMode; + } + + var envArgs = Environment.GetCommandLineArgs(); + + if (_proAvailable) + { + CoreOnly = _configuration.GetValue("CORE_ONLY", false) + || (envArgs.Contains("--filter") && (envArgs[envArgs.IndexOf("--filter") + 1] == "CORE_")); + + ProOnly = _configuration.GetValue("PRO_ONLY", false) + || (envArgs.Contains("--filter") && (envArgs[envArgs.IndexOf("--filter") + 1] == "PRO_")); + } + else + { + CoreOnly = true; + ProOnly = false; + } + + _useContainer = _configuration.GetValue("USE_CONTAINER", false); + + // Configure browser pool size - smaller for CI, larger for local development + _isCI = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CI")); + var defaultPoolSize = _isCI ? 2 : 4; + BrowserPoolSize = _configuration.GetValue("BROWSER_POOL_SIZE", defaultPoolSize); + Trace.WriteLine($"Browser pool size set to: {BrowserPoolSize} (CI: {_isCI})", "TEST_SETUP"); + + _cover = _configuration.GetValue("COVER", false) + + // only run coverage on a full test run or a full CORE or full PRO test + && (!envArgs.Contains("--filter") || (envArgs[envArgs.IndexOf("--filter") + 1] == "CORE_") + || (envArgs[envArgs.IndexOf("--filter") + 1] == "PRO_")); + + if (_cover) + { + _coverageFormat = _configuration.GetValue("COVERAGE_FORMAT", "xml"); + _coverageFileVersion = DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss"); + _reportGenLicenseKey = _configuration["REPORT_GEN_LICENSE_KEY"]; + } + + var config = _configuration["CONFIGURATION"]; + + if (!string.IsNullOrEmpty(config)) + { + _runConfig = config; + } + + if (_runConfig is null) + { +#if DEBUG + _runConfig = "Debug"; +#else + _runConfig = "Release"; +#endif + } + + _targetFramework ??= _configuration.GetValue("TARGET_FRAMEWORK", "net10.0"); + } + + private static async Task InstallCodeCoverageTools() + { + await Cli.Wrap("dotnet") + .WithArguments([ + "tool", + "install", + "--global", + "dotnet-coverage" + ]) + .WithStandardOutputPipe(PipeTarget.ToDelegate(output => + Trace.WriteLine(output, "CODE_COVERAGE_TOOL_INSTALLATION"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(output => + Trace.WriteLine(output, "CODE_COVERAGE_TOOL_INSTALLATION_ERROR"))) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + await Cli.Wrap("dotnet") + .WithArguments([ + "tool", + "install", + "--global", + "dotnet-reportgenerator-globaltool" + ]) + .WithStandardOutputPipe(PipeTarget.ToDelegate(output => + Trace.WriteLine(output, "CODE_COVERAGE_TOOL_INSTALLATION"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(output => + Trace.WriteLine(output, "CODE_COVERAGE_TOOL_INSTALLATION_ERROR"))) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + // ensure output directory exists + Directory.CreateDirectory(Path.Combine(_projectFolder, "coverage")); + } + + private static async Task EnsurePlaywrightBrowsersAreInstalled() + { + try + { + // Use Playwright's built-in installation via Program.Main + // This is more reliable cross-platform than calling pwsh + var exitCode = Program.Main(["install"]); + + if (exitCode != 0) + { + Trace.WriteLine($"Playwright browser installation returned exit code: {exitCode}", "TEST_SETUP"); + } + + await Task.CompletedTask; // Keep method async for consistency + } + catch (Exception ex) + { + Trace.WriteLine($"Playwright browser installation failed: {ex.Message}", "TEST_SETUP"); + } + } + + private static async Task StartContainer() + { + var cmdLineApp = "docker"; + + string[] args = + [ + "compose", "-f", ComposeFilePath, "up", "-d", "--build" + ]; + Trace.WriteLine($"Starting container with: docker {string.Join(" ", args)}", "TEST_SETUP"); + Trace.WriteLine($"Working directory: {_projectFolder}", "TEST_SETUP"); + + var sessionId = "geoblazor-cover"; + + if (_cover) + { + cmdLineApp = "dotnet-coverage"; + var dockerCommand = $"docker {string.Join(" ", args)}"; + + args = + [ + "collect", + "--session-id", sessionId, + "-o", CoverageFilePath, + "-f", _coverageFormat, + dockerCommand + ]; + } + + CommandTask commandTask = Cli.Wrap(cmdLineApp) + .WithArguments(args) + .WithEnvironmentVariables(new Dictionary + { + ["HTTP_PORT"] = _httpPort.ToString(), + ["HTTPS_PORT"] = _httpsPort.ToString(), + ["COVERAGE_ENABLED"] = _cover.ToString().ToLower(), + ["SESSION_ID"] = sessionId, + ["COVERAGE_FORMAT"] = _coverageFormat, + ["COVERAGE_FILE_VERSION"] = _coverageFileVersion + }) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_CONTAINER"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_CONTAINER_ERROR"))) + .WithWorkingDirectory(_projectFolder) + .ExecuteAsync(cts.Token, gracefulCts.Token); + + _testProcessId = commandTask.ProcessId; + + await WaitForHttpResponse(); + } + + private static async Task StartTestApp() + { + var cmdLineApp = "dotnet"; + + string[] args = + [ + "run", "--project", $"\"{TestAppPath}\"", + "--urls", $"{TestAppUrl};{TestAppHttpUrl}", + "--", "-c", "Release", + "/p:GenerateXmlComments=false", "/p:GeneratePackage=false", + "/p:DebugSymbols=true", "/p:DebugType=portable" + ]; + + if (_cover) + { + cmdLineApp = "dotnet-coverage"; + + // Join the dotnet run command into a single string for dotnet-coverage + var dotnetCommand = $"dotnet {string.Join(" ", args)}"; + + // Include GeoBlazor assemblies for coverage + args = + [ + "collect", + "-o", CoverageFilePath, + "-f", _coverageFormat, + dotnetCommand + ]; + } + + Trace.WriteLine($"Starting test app: {cmdLineApp} {string.Join(" ", args)}", "TEST_SETUP"); + + CommandTask commandTask = Cli.Wrap(cmdLineApp) + .WithArguments(args) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_APP"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Trace.WriteLine(line, "TEST_APP_ERROR"))) + .WithWorkingDirectory(_projectFolder) + .ExecuteAsync(cts.Token, gracefulCts.Token); + + _testProcessId = commandTask.ProcessId; + + await WaitForHttpResponse(); + } + + private static async Task StopContainer() + { + // If coverage is enabled, gracefully shutdown dotnet-coverage before stopping the container + if (_cover) + { + await ShutdownCoverageCollection(); + } + + try + { + Trace.WriteLine($"Stopping container with: docker compose -f {ComposeFilePath} down", "TEST_CLEANUP"); + + await Cli.Wrap("docker") + .WithArguments($"compose -f \"{ComposeFilePath}\" down") + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(cts.Token); + Trace.WriteLine("Container stopped successfully", "TEST_CLEANUP"); + } + catch + { + // ignore, these just clutter the output + } + + // If coverage was enabled, copy the coverage file from the volume mount directory + if (_cover) + { + var containerCoverageFile = Path.Combine(_projectFolder, "coverage", "coverage.xml"); + var targetCoverageFile = Path.Combine(_projectFolder, $"coverage.{_coverageFormat}"); + + if (File.Exists(containerCoverageFile)) + { + File.Copy(containerCoverageFile, targetCoverageFile, true); + Trace.WriteLine($"Coverage file copied from container: {targetCoverageFile}", "TEST_CLEANUP"); + } + else + { + Trace.WriteLine($"Container coverage file not found: {containerCoverageFile}", "TEST_CLEANUP"); + } + } + + await KillProcessesByTestPorts(); + } + + private static async Task StopTestApp() + { + await KillProcessById(_testProcessId); + await KillProcessesByTestPorts(); + } + + private static async Task ShutdownCoverageCollection() + { + try + { + // Get the container name from the compose file + string containerName = _proAvailable && !CoreOnly + ? "geoblazor-pro-tests-test-app-1" + : "geoblazor-core-tests-test-app-1"; + + Trace.WriteLine($"Shutting down coverage collection in container: {containerName}", "CODE_COVERAGE"); + + // Call dotnet-coverage shutdown inside the container to gracefully write coverage data + await Cli.Wrap("docker") + .WithArguments($"exec {containerName} /tools/dotnet-coverage shutdown geoblazor-coverage") + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => + Trace.WriteLine(line, "CODE_COVERAGE"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => + Trace.WriteLine(line, "CODE_COVERAGE_ERROR"))) + .ExecuteAsync(); + + // Give time for coverage file to be written + await Task.Delay(3000); + Trace.WriteLine("Coverage shutdown command completed", "CODE_COVERAGE"); + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to shutdown coverage collection: {ex.Message}", "CODE_COVERAGE_ERROR"); + } + } + + private static async Task WaitForHttpResponse() + { + // Configure HttpClient to ignore SSL certificate errors (for self-signed certs in Docker) + HttpClientHandler handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }; + using HttpClient httpClient = new(handler); + + // worst-case scenario for docker build is ~ 6 minutes + // set this to 60 seconds * 8 = 8 minutes + var maxAttempts = 60 * 8; + + for (var i = 1; i <= maxAttempts; i++) + { + try + { + HttpResponseMessage response = + await httpClient.GetAsync(TestAppHttpUrl, cts.Token); + + if (response.IsSuccessStatusCode || + response.StatusCode is >= (HttpStatusCode)300 and < (HttpStatusCode)400) + { + Trace.WriteLine($"Test Site is ready! Status: {response.StatusCode}", "TEST_SETUP"); + + return; + } + } + catch (Exception ex) + { + // Log the exception for debugging SSL/connection issues + if (i % 10 == 0) + { + Trace.WriteLine($"Connection attempt {i} failed: {ex.Message}", "TEST_SETUP"); + } + } + + if (i % 10 == 0) + { + Trace.WriteLine($"Waiting for Test Site at {TestAppHttpUrl}. Attempt {i} out of {maxAttempts}...", + "TEST_SETUP"); + } + + await Task.Delay(1000, cts.Token); + } + + throw new ProcessExitedException("Test page was not reachable within the allotted time frame"); + } + + private static async Task KillProcessById(int? processId) + { + if (processId.HasValue) + { + Process? process = null; + + try + { + process = Process.GetProcessById(processId.Value); + + if (_useContainer) + { + await process.StandardInput.WriteLineAsync("exit"); + await Task.Delay(5000); + } + } + catch + { + // ignore, these just clutter the output + } + + if (process is not null && !process.HasExited) + { + process.Kill(); + } + } + } + + private static async Task KillProcessesByTestPorts() + { + try + { + if (OperatingSystem.IsWindows()) + { + // Use PowerShell for more reliable Windows port killing + await Cli.Wrap("pwsh") + .WithArguments($"Get-NetTCPConnection -LocalPort {_httpsPort + } -State Listen | Select-Object -ExpandProperty OwningProcess | ForEach-Object {{ Stop-Process -Id $_ -Force }}") + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + } + else + { + await Cli.Wrap("/bin/bash") + .WithArguments($"lsof -i:{_httpsPort} | awk '{{if(NR>1)print $2}}' | xargs -t -r kill -9") + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + } + } + catch + { + // ignore, these just clutter the test output + } + } + + private static async Task GenerateCoverageReport() + { + var reportDir = Path.Combine(_projectFolder, "coverage-report"); + + if (!File.Exists(CoverageFilePath)) + { + Trace.WriteLine($"Coverage file not found: {CoverageFilePath}", "CODE_COVERAGE_ERROR"); + + return; + } + + try + { + Trace.WriteLine("Generating coverage report...", "CODE_COVERAGE"); + + List assemblyFilters = CoreOnly + ? ["+dymaptic.GeoBlazor.Core.dll"] + : ProOnly + ? ["+dymaptic.GeoBlazor.Pro.dll"] + : ["+dymaptic.GeoBlazor.Core.dll", "+dymaptic.GeoBlazor.Pro.dll"]; + + List sourceDirs = CoreOnly + ? [CoreProjectPath] + : ProOnly + ? [ProProjectPath] + : [CoreProjectPath, ProProjectPath]; + + List args = + [ + $"-reports:{CoverageFilePath}", + $"-targetdir:{reportDir}", + "-reporttypes:Html;HtmlSummary;TextSummary;Badges", + + // Include only GeoBlazor Core and Pro assemblies, exclude everything else + $"-assemblyfilters:{string.Join(";", assemblyFilters)}", + $"-sourcedirs:{string.Join(";", sourceDirs)}" + ]; + + if (!string.IsNullOrEmpty(_reportGenLicenseKey)) + { + args.Add($"-license:{_reportGenLicenseKey}"); + } + + await Cli.Wrap("reportgenerator") + .WithArguments(args) + .WithStandardOutputPipe(PipeTarget.ToDelegate(line => + Trace.WriteLine(line, "CODE_COVERAGE_REPORT"))) + .WithStandardErrorPipe(PipeTarget.ToDelegate(line => + Trace.WriteLine(line, "CODE_COVERAGE_REPORT_ERROR"))) + .WithValidation(CommandResultValidation.None) + .ExecuteAsync(); + + var indexPath = Path.Combine(reportDir, "index.html"); + + if (File.Exists(indexPath)) + { + Trace.WriteLine($"Coverage report generated: {indexPath}", "CODE_COVERAGE"); + + // Open report in browser for local development (not CI) + if (!_isCI) + { + try + { + OpenInBrowser(indexPath); + Trace.WriteLine("Coverage report opened in browser", "CODE_COVERAGE"); + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to open browser: {ex.Message}", "CODE_COVERAGE"); + } + } + } + else + { + Trace.WriteLine("Coverage report index.html was not generated", "CODE_COVERAGE_ERROR"); + } + + // copy the badge image to the repo root + var lineBadgePath = Path.Combine(reportDir, "badge_linecoverage.svg"); + var methodBadgePath = Path.Combine(reportDir, "badge_methodcoverage.svg"); + var fullMethodBadgePath = Path.Combine(_projectFolder, "badge_fullmethodcoverage.svg"); + + if (!ProOnly) + { + File.Copy(lineBadgePath, Path.Combine(CoreRepoRoot, "badge_linecoverage.svg"), true); + File.Copy(methodBadgePath, Path.Combine(CoreRepoRoot, "badge_methodcoverage.svg"), true); + File.Copy(fullMethodBadgePath, Path.Combine(CoreRepoRoot, "badge_fullmethodcoverage.svg"), true); + File.Copy(lineBadgePath, Path.Combine(CoreProjectPath, "badge_linecoverage.svg"), true); + File.Copy(methodBadgePath, Path.Combine(CoreProjectPath, "badge_methodcoverage.svg"), true); + File.Copy(fullMethodBadgePath, Path.Combine(CoreProjectPath, "badge_fullmethodcoverage.svg"), true); + } + + if (!CoreOnly) + { + File.Copy(lineBadgePath, Path.Combine(ProRepoRoot, "badge_linecoverage.svg"), true); + File.Copy(methodBadgePath, Path.Combine(ProRepoRoot, "badge_methodcoverage.svg"), true); + File.Copy(fullMethodBadgePath, Path.Combine(ProRepoRoot, "badge_fullmethodcoverage.svg"), true); + File.Copy(lineBadgePath, Path.Combine(ProProjectPath, "badge_linecoverage.svg"), true); + File.Copy(methodBadgePath, Path.Combine(ProProjectPath, "badge_methodcoverage.svg"), true); + File.Copy(fullMethodBadgePath, Path.Combine(ProProjectPath, "badge_fullmethodcoverage.svg"), true); + } + } + catch (Exception ex) + { + Trace.WriteLine($"Failed to generate coverage report: {ex.Message}", "CODE_COVERAGE_ERROR"); + } + } + + private static void OpenInBrowser(string path) + { + var cmdLineApp = OperatingSystem.IsWindows() + ? "start" + : OperatingSystem.IsMacOS() + ? "open" + : "xdg-open"; + + Cli.Wrap(cmdLineApp) + .WithArguments(path) + .ExecuteAsync(); + } + + private static readonly CancellationTokenSource cts = new(); + private static readonly CancellationTokenSource gracefulCts = new(); + private static readonly StringBuilder logBuilder = new(); + + private static IConfiguration? _configuration; + private static string? _runConfig; + private static string? _targetFramework; + private static bool _proAvailable; + private static int _httpsPort; + private static int _httpPort; + private static string _projectFolder = string.Empty; + private static int? _testProcessId; + private static bool _useContainer; + private static bool _cover; + private static string _coverageFormat = string.Empty; + private static string _coverageFileVersion = string.Empty; + private static string? _reportGenLicenseKey; + private static bool _isCI; +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/appsettings.json b/test/dymaptic.GeoBlazor.Core.Test.Automation/appsettings.json new file mode 100644 index 000000000..be8187492 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/appsettings.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "HTTPS_PORT": 9443, + "TEST_APP_URL": "https://localhost:9443", + "TEST_TIMEOUT": "1800", + "RENDER_MODE": "WebAssembly" +} diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml new file mode 100644 index 000000000..6c44fa83e --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-core.yml @@ -0,0 +1,41 @@ +name: geoblazor-core-tests + +services: + test-app: + build: + context: ../.. + dockerfile: Dockerfile + args: + ARCGIS_API_KEY: ${ARCGIS_API_KEY} + GEOBLAZOR_LICENSE_KEY: ${GEOBLAZOR_CORE_LICENSE_KEY} + HTTP_PORT: ${HTTP_PORT} + HTTPS_PORT: ${HTTPS_PORT} + COVERAGE_FORMAT: ${COVERAGE_FORMAT} + COVERAGE_FILE_VERSION: ${COVERAGE_FILE_VERSION} + WFS_SERVERS: |- + "WFSServers": [ + { + "Url": "https://dservices1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/services/Counties_May_2023_Boundaries_EN_BUC/WFSServer", + "OutputFormat": "GEOJSON" + }, + { + "Url": "https://geobretagne.fr/geoserver/ows", + "OutputFormat": "json" + } + ] + stop_grace_period: 30s + environment: + - ASPNETCORE_ENVIRONMENT=Production + - COVERAGE_ENABLED=${COVERAGE_ENABLED:-false} + - COVERAGE_OUTPUT=/coverage/coverage.${COVERAGE_FILE_VERSION}.${COVERAGE_FORMAT:-xml} + ports: + - "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}" + - "${HTTPS_PORT:-9443}:${HTTPS_PORT:-9443}" + volumes: + - ./coverage:/coverage + healthcheck: + test: ["CMD-SHELL", "wget -q --spider --no-check-certificate https://localhost:${HTTPS_PORT:-9443} || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml new file mode 100644 index 000000000..44e77827a --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-compose-pro.yml @@ -0,0 +1,41 @@ +name: geoblazor-pro-tests + +services: + test-app: + build: + context: ../../.. + dockerfile: Dockerfile + args: + ARCGIS_API_KEY: ${ARCGIS_API_KEY} + GEOBLAZOR_LICENSE_KEY: ${GEOBLAZOR_PRO_LICENSE_KEY} + HTTP_PORT: ${HTTP_PORT} + HTTPS_PORT: ${HTTPS_PORT} + COVERAGE_FORMAT: ${COVERAGE_FORMAT} + COVERAGE_FILE_VERSION: ${COVERAGE_FILE_VERSION} + WFS_SERVERS: |- + "WFSServers": [ + { + "Url": "https://dservices1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/services/Counties_May_2023_Boundaries_EN_BUC/WFSServer", + "OutputFormat": "GEOJSON" + }, + { + "Url": "https://geobretagne.fr/geoserver/ows", + "OutputFormat": "json" + } + ] + stop_grace_period: 30s + environment: + - ASPNETCORE_ENVIRONMENT=Production + - COVERAGE_ENABLED=${COVERAGE_ENABLED:-false} + - COVERAGE_OUTPUT=/coverage/coverage.${COVERAGE_FILE_VERSION}.${COVERAGE_FORMAT:-xml} + ports: + - "${HTTP_PORT:-8080}:${HTTP_PORT:-8080}" + - "${HTTPS_PORT:-9443}:${HTTPS_PORT:-9443}" + volumes: + - ./coverage:/coverage + healthcheck: + test: ["CMD-SHELL", "wget -q --spider --no-check-certificate https://localhost:${HTTPS_PORT:-9443} || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh new file mode 100644 index 000000000..d9e94a894 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/docker-entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +SESSION_ID="geoblazor-coverage" + +# Trap SIGTERM to gracefully shutdown coverage collection +_term() { + echo "Received SIGTERM, shutting down coverage collection..." + if [ "$COVERAGE_ENABLED" = "true" ]; then + # Use dotnet-coverage shutdown to gracefully stop and write coverage + /tools/dotnet-coverage shutdown "$SESSION_ID" 2>&1 || true + echo "Coverage shutdown command sent" + # Give it time to write the coverage file + sleep 5 + echo "Coverage directory contents:" + ls -la "$(dirname "$COVERAGE_OUTPUT")" || true + fi +} + +trap _term SIGTERM SIGINT + +if [ "$COVERAGE_ENABLED" = "true" ]; then + echo "Starting with code coverage collection in server mode..." + echo "Session ID: $SESSION_ID" + echo "Coverage output: $COVERAGE_OUTPUT" + + # Start dotnet-coverage in server mode with session ID + # Note: We collect ALL assemblies (no --include-files filter) to capture + # GeoBlazor code that executes through test assemblies and the web app. + # The GeoBlazor Core and Pro DLLs are still in the report but may show low + # coverage because most component logic runs in JavaScript (ArcGIS SDK). + echo "Starting dotnet-coverage with verbose logging..." + /tools/dotnet-coverage collect \ + --session-id "$SESSION_ID" \ + -o "$COVERAGE_OUTPUT" \ + -f xml \ + -l "$COVERAGE_OUTPUT.log" \ + -ll Verbose \ + -- "$@" +else + echo "Starting without code coverage..." + exec "$@" +fi diff --git a/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj b/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj new file mode 100644 index 000000000..7f1c07ff7 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Automation/dymaptic.GeoBlazor.Core.Test.Automation.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + latest + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/AuthenticationManagerTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/AuthenticationManagerTests.cs index fb6180da9..8bc04e675 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/AuthenticationManagerTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/AuthenticationManagerTests.cs @@ -8,54 +8,55 @@ namespace dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Components; - /* - * ------------------------------------------------------------------------- - * CONFIGURATION SETUP (appsettings.json / user-secrets / CI env vars) - * ------------------------------------------------------------------------- - * These tests read the following keys from IConfiguration: - * - * TestPortalAppId -> App ID registered in your Enterprise Portal - * TestPortalUrl -> Your Portal base URL (either https://host OR https://host/portal) - * TestPortalClientSecret -> Client secret for the Portal app registration - * - * TestAGOAppId -> App ID registered in ArcGIS Online - * TestAGOUrl -> ArcGIS Online base URL (use https://www.arcgis.com) - * TestAGOClientSecret -> Client secret for the AGOL app registration - * - * TestApplicationBaseUrl -> (Optional) Base address for local HTTP calls if needed - * - * NOTES: - * - You need SEPARATE app registrations for AGOL and for Enterprise Portal if you test both. - * - For AGOL, use TestAGOUrl = https://www.arcgis.com (do NOT append /portal) - * - For Enterprise, TestPortalUrl should be https://yourserver/portal - * - * Recommended: keep secrets out of source control using .NET user-secrets: - * - * dotnet user-secrets init - * dotnet user-secrets set "TestPortalAppId" "" - * dotnet user-secrets set "TestPortalUrl" "https://yourserver/portal" - * dotnet user-secrets set "TestPortalClientSecret" "" - * - * dotnet user-secrets set "TestAGOAppId" "" - * dotnet user-secrets set "TestAGOUrl" "https://www.arcgis.com" - * dotnet user-secrets set "TestAGOClientSecret" "" - * - * Example appsettings.Development.json (non-secret values only): - * { - * "TestPortalUrl": "https://yourserver/portal", - * "TestAGOUrl": "https://www.arcgis.com", - * "TestApplicationBaseUrl": "https://localhost:7143" - * } - * - * In CI, set these as environment variables instead: - * TestPortalAppId, TestPortalUrl, TestPortalClientSecret, - * TestAGOAppId, TestAGOUrl, TestAGOClientSecret, TestApplicationBaseUrl - * - * ------------------------------------------------------------------------- - */ +/* + * ------------------------------------------------------------------------- + * CONFIGURATION SETUP (appsettings.json / user-secrets / CI env vars) + * ------------------------------------------------------------------------- + * These tests read the following keys from IConfiguration: + * + * TestPortalAppId -> App ID registered in your Enterprise Portal + * TestPortalUrl -> Your Portal base URL (either https://host OR https://host/portal) + * TestPortalClientSecret -> Client secret for the Portal app registration + * + * TestAGOAppId -> App ID registered in ArcGIS Online + * TestAGOUrl -> ArcGIS Online base URL (use https://www.arcgis.com) + * TestAGOClientSecret -> Client secret for the AGOL app registration + * + * TestApplicationBaseUrl -> (Optional) Base address for local HTTP calls if needed + * + * NOTES: + * - You need SEPARATE app registrations for AGOL and for Enterprise Portal if you test both. + * - For AGOL, use TestAGOUrl = https://www.arcgis.com (do NOT append /portal) + * - For Enterprise, TestPortalUrl should be https://yourserver/portal + * + * Recommended: keep secrets out of source control using .NET user-secrets: + * + * dotnet user-secrets init + * dotnet user-secrets set "TestPortalAppId" "" + * dotnet user-secrets set "TestPortalUrl" "https://yourserver/portal" + * dotnet user-secrets set "TestPortalClientSecret" "" + * + * dotnet user-secrets set "TestAGOAppId" "" + * dotnet user-secrets set "TestAGOUrl" "https://www.arcgis.com" + * dotnet user-secrets set "TestAGOClientSecret" "" + * + * Example appsettings.Development.json (non-secret values only): + * { + * "TestPortalUrl": "https://yourserver/portal", + * "TestAGOUrl": "https://www.arcgis.com", + * "TestApplicationBaseUrl": "https://localhost:7143" + * } + * + * In CI, set these as environment variables instead: + * TestPortalAppId, TestPortalUrl, TestPortalClientSecret, + * TestAGOAppId, TestAGOUrl, TestAGOClientSecret, TestApplicationBaseUrl + * + * ------------------------------------------------------------------------- + */ [IsolatedTest] +[CICondition(ConditionMode.Exclude)] [TestClass] -public class AuthenticationManagerTests: TestRunnerBase +public class AuthenticationManagerTests : TestRunnerBase { [Inject] public required AuthenticationManager AuthenticationManager { get; set; } @@ -72,11 +73,24 @@ public class AuthenticationManagerTests: TestRunnerBase [TestMethod] public async Task TestRegisterOAuthWithArcGISPortal() { + // Skip if OAuth credentials are not configured (e.g., in Docker/CI environments) + string? appId = Configuration["TestPortalAppId"]; + string? portalUrl = Configuration["TestPortalUrl"]; + string? clientSecret = Configuration["TestPortalClientSecret"]; + + if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(portalUrl) || string.IsNullOrEmpty(clientSecret)) + { + Assert.Inconclusive("Skipping: TestPortalAppId, TestPortalUrl, or TestPortalClientSecret not configured. " + + "These OAuth tests require credentials that are not available in Docker/CI environments."); + + return; + } + AuthenticationManager.ExcludeApiKey = true; - AuthenticationManager.AppId = Configuration["TestPortalAppId"]; - AuthenticationManager.PortalUrl = Configuration["TestPortalUrl"]; + AuthenticationManager.AppId = appId; + AuthenticationManager.PortalUrl = portalUrl; - TokenResponse tokenResponse = await RequestTokenAsync(Configuration["TestPortalClientSecret"]!); + TokenResponse tokenResponse = await RequestTokenAsync(clientSecret); Assert.IsTrue(tokenResponse.Success, tokenResponse.ErrorMessage); await AuthenticationManager.RegisterToken(tokenResponse.AccessToken!, tokenResponse.Expires!.Value); @@ -90,14 +104,28 @@ public async Task TestRegisterOAuthWithArcGISPortal() Assert.AreEqual(tokenResponse.Expires, expired); } + [CICondition(ConditionMode.Exclude)] [TestMethod] public async Task TestRegisterOAuthWithArcGISOnline() { + // Skip if OAuth credentials are not configured (e.g., in Docker/CI environments) + string? appId = Configuration["TestAGOAppId"]; + string? portalUrl = Configuration["TestAGOUrl"]; + string? clientSecret = Configuration["TestAGOClientSecret"]; + + if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(portalUrl) || string.IsNullOrEmpty(clientSecret)) + { + Assert.Inconclusive("Skipping: TestAGOAppId, TestAGOUrl, or TestAGOClientSecret not configured. " + + "These OAuth tests require credentials that are not available in Docker/CI environments."); + + return; + } + AuthenticationManager.ExcludeApiKey = true; - AuthenticationManager.AppId = Configuration["TestAGOAppId"]; - AuthenticationManager.PortalUrl = Configuration["TestAGOUrl"]; + AuthenticationManager.AppId = appId; + AuthenticationManager.PortalUrl = portalUrl; - TokenResponse tokenResponse = await RequestTokenAsync(Configuration["TestAGOClientSecret"]!); + TokenResponse tokenResponse = await RequestTokenAsync(clientSecret); Assert.IsTrue(tokenResponse.Success, tokenResponse.ErrorMessage); await AuthenticationManager.RegisterToken(tokenResponse.AccessToken!, tokenResponse.Expires!.Value); @@ -147,6 +175,7 @@ private async Task RequestTokenAsync(string clientSecret) } ArcGisError? errorCheck = JsonSerializer.Deserialize(content); + if (errorCheck?.Error != null) { return new TokenResponse(false, null, null, @@ -154,13 +183,16 @@ private async Task RequestTokenAsync(string clientSecret) } ArcGISTokenResponse? token = JsonSerializer.Deserialize(content); + if (token?.AccessToken == null) { - return new TokenResponse(false, null, null, "Please verify your ArcGISAppId, ArcGISClientSecret, and ArcGISPortalUrl values."); + return new TokenResponse(false, null, null, + "Please verify your ArcGISAppId, ArcGISClientSecret, and ArcGISPortalUrl values."); } TokenResponse tokenResponse = new TokenResponse(true, token.AccessToken, DateTimeOffset.FromUnixTimeSeconds(token.ExpiresIn).UtcDateTime); + return tokenResponse; } @@ -172,7 +204,9 @@ private void ResetAuthManager() t.GetField("_appId", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(AuthenticationManager, null); t.GetField("_portalUrl", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(AuthenticationManager, null); t.GetField("_apiKey", BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(AuthenticationManager, null); - t.GetField("_trustedServers", BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(AuthenticationManager, null); + + t.GetField("_trustedServers", BindingFlags.Instance | BindingFlags.NonPublic) + ?.SetValue(AuthenticationManager, null); t.GetField("_fontsUrl", BindingFlags.Instance | BindingFlags.NonPublic)?.SetValue(AuthenticationManager, null); // drop the JS interop module so Initialize() recreates it with fresh values diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/LocationServiceTests.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/LocationServiceTests.cs index 6269dc6ea..bdc7651e0 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/LocationServiceTests.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/LocationServiceTests.cs @@ -2,7 +2,6 @@ using dymaptic.GeoBlazor.Core.Components.Geometries; using dymaptic.GeoBlazor.Core.Model; using Microsoft.AspNetCore.Components; -using Microsoft.VisualStudio.TestTools.UnitTesting; #pragma warning disable BL0005 @@ -19,32 +18,41 @@ public class LocationServiceTests : TestRunnerBase [TestMethod] public async Task TestPerformAddressesToLocation(Action renderHandler) { - List
addresses = [_testAddress1, _testAddress2]; + List
addresses = [_testAddressEugene1, _testAddressEugene2]; List locations = await LocationService.AddressesToLocations(addresses); AddressCandidate? firstAddress = locations - .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddress1)); + .FirstOrDefault(x => x.Address!.Contains(_expectedStreetEugene1)); Assert.IsNotNull(firstAddress?.Location); - Assert.IsTrue(LocationsMatch(_expectedLocation1, firstAddress.Location)); + + Assert.IsTrue(LocationsMatch(_expectedLocationEugene1, firstAddress.Location), + $"Expected Long: {_expectedLocationEugene1.Longitude} Lat: {_expectedLocationEugene1.Latitude}, got Long: { + firstAddress.Location.Longitude} Lat: {firstAddress.Location.Latitude}"); AddressCandidate? secondAddress = locations - .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddress2)); + .FirstOrDefault(x => x.Address!.Contains(_expectedStreetEugene2)); Assert.IsNotNull(secondAddress?.Location); - Assert.IsTrue(LocationsMatch(_expectedLocation2, secondAddress.Location)); + + Assert.IsTrue(LocationsMatch(_expectedLocationEugene2, secondAddress.Location), + $"Expected Long: {_expectedLocationEugene2.Longitude} Lat: {_expectedLocationEugene2.Latitude}, got Long: { + secondAddress.Location.Longitude} Lat: {secondAddress.Location.Latitude}"); } [TestMethod] public async Task TestPerformAddressToLocation(Action renderHandler) { - List location = await LocationService.AddressToLocations(_testAddress1); + var location = await LocationService.AddressToLocations(_testAddressRedlands); AddressCandidate? firstAddress = location - .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddress1)); + .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddressRedlands)); Assert.IsNotNull(firstAddress?.Location); - Assert.IsTrue(LocationsMatch(_expectedLocation1, firstAddress.Location)); + + Assert.IsTrue(LocationsMatch(_expectedLocationRedlands, firstAddress.Location), + $"Expected Long: {_expectedLocationRedlands.Longitude} Lat: {_expectedLocationRedlands.Latitude + }, got Long: {firstAddress.Location.Longitude} Lat: {firstAddress.Location.Latitude}"); } [TestMethod] @@ -55,10 +63,13 @@ public async Task TestPerformAddressToLocationFromString(Action renderHandler) List location = await LocationService.AddressToLocations(addressString); AddressCandidate? firstAddress = location - .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddress1)); + .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddressRedlands)); Assert.IsNotNull(firstAddress?.Location); - Assert.IsTrue(LocationsMatch(_expectedLocation1, firstAddress.Location)); + + Assert.IsTrue(LocationsMatch(_expectedLocationRedlands, firstAddress.Location), + $"Expected Long: {_expectedLocationRedlands.Longitude} Lat: {_expectedLocationRedlands.Latitude + }, got Long: {firstAddress.Location.Longitude} Lat: {firstAddress.Location.Latitude}"); } [TestMethod] @@ -66,34 +77,45 @@ public async Task TestPerformAddressesToLocationFromString(Action renderHandler) { List addresses = [ - "132 New York Street, Redlands, CA 92373", - "400 New York Street, Redlands, CA 92373" + _expectedFullAddressEugene1, + _expectedFullAddressEugene2 ]; List locations = await LocationService.AddressesToLocations(addresses); AddressCandidate? firstAddress = locations - .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddress1)); + .FirstOrDefault(x => x.Address!.Contains(_expectedStreetEugene1)); Assert.IsNotNull(firstAddress?.Location); - Assert.IsTrue(LocationsMatch(_expectedLocation1, firstAddress.Location)); + + Assert.IsTrue(LocationsMatch(_expectedLocationEugene1, firstAddress.Location), + $"Expected Long: {_expectedLocationEugene1.Longitude} Lat: {_expectedLocationEugene1.Latitude}, got Long: { + firstAddress.Location.Longitude} Lat: {firstAddress.Location.Latitude}"); var secondAddress = locations - .FirstOrDefault(x => x.Address!.Contains(_expectedStreetAddress2)); + .FirstOrDefault(x => x.Address!.Contains(_expectedStreetEugene2)); Assert.IsNotNull(secondAddress?.Location); - Assert.IsTrue(LocationsMatch(_expectedLocation2, secondAddress.Location)); + + Assert.IsTrue(LocationsMatch(_expectedLocationEugene2, secondAddress.Location), + $"Expected Long: {_expectedLocationEugene2.Longitude} Lat: {_expectedLocationEugene2.Latitude}, got Long: { + secondAddress.Location.Longitude} Lat: {secondAddress.Location.Latitude}"); } - + private bool LocationsMatch(Point loc1, Point loc2) { - return Math.Abs(loc1.Latitude!.Value - loc2.Latitude!.Value) < 0.00001 + return (Math.Abs(loc1.Latitude!.Value - loc2.Latitude!.Value) < 0.00001) && Math.Abs(loc1.Longitude!.Value - loc2.Longitude!.Value) < 0.00001; } - private Address _testAddress1 = new("132 New York Street", "Redlands", "CA", "92373"); - private Address _testAddress2 = new("400 New York Street", "Redlands", "CA", "92373"); - private string _expectedStreetAddress1 = "132 New York St"; - private string _expectedStreetAddress2 = "400 New York St"; - private Point _expectedLocation1 = new(-117.19498330596601, 34.053834157090002); - private Point _expectedLocation2 = new(-117.195611240849, 34.057451663745); + private readonly Address _testAddressRedlands = new("132 New York Street", "Redlands", "CA", "92373"); + private readonly string _expectedStreetAddressRedlands = "132 New York St"; + private readonly Point _expectedLocationRedlands = new(-117.19498330596601, 34.053834157090002); + private readonly Address _testAddressEugene1 = new("1434 W 25th Ave", "Eugene", "OR", "97405"); + private readonly string _expectedFullAddressEugene1 = "1434 W 25th Ave, Eugene, OR 97405"; + private readonly string _expectedStreetEugene1 = "1434 W 25th Ave"; + private readonly Point _expectedLocationEugene1 = new(-123.114112505277, 44.0307112476); + private readonly string _expectedFullAddressEugene2 = "85 Oakway Center, Eugene, OR 97401"; + private readonly Address _testAddressEugene2 = new("85 Oakway Center", "Eugene", "OR", "97401"); + private readonly string _expectedStreetEugene2 = "85 Oakway Ctr"; + private readonly Point _expectedLocationEugene2 = new(-123.075320051552, 44.066269543984); } \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor index 3f37907ad..3c44e1b17 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor @@ -1,4 +1,4 @@ -@attribute [TestClass] +@using dymaptic.GeoBlazor.Core.Extensions

@Extensions.CamelCaseToSpaces(ClassName)

@if (_type?.GetCustomAttribute() != null) @@ -7,7 +7,7 @@ Isolated Test (iFrame) } -
@(_collapsed ? "\u25b6" : "\u25bc")
@if (_failed.Any()) @@ -18,13 +18,23 @@ @if (_running) { Running... @Remaining tests pending - , + + if (_passed.Any() || _failed.Any()) + { + | + } } - @if (_passed.Any() || _failed.Any()) + @if (_passed.Any() || _failed.Any() || _inconclusive.Any()) { - Passed: @_passed.Count - , - Failed: @_failed.Count + Passed: @_passed.Count + | + Failed: @_failed.Count + + if (_inconclusive.Any()) + { + | + Inconclusive: @_inconclusive.Count + } }

@@ -35,7 +45,10 @@
- +
}
-
- -@code { - - [Inject] - public IJSRuntime JsRuntime { get; set; } = null!; - - [Inject] - public JsModuleManager JsModuleManager { get; set; } = null!; - - [Inject] - public NavigationManager NavigationManager { get; set; } = null!; - - [Parameter] - public EventCallback OnTestResults { get; set; } - - [Parameter] - public TestResult? Results { get; set; } - - public async Task RunTests(bool onlyFailedTests = false, int skip = 0, - CancellationToken cancellationToken = default) - { - _running = true; - - try - { - _resultBuilder = new StringBuilder(); - _passed.Clear(); - - List methodsToRun = []; - - foreach (MethodInfo method in _methodInfos!.Skip(skip)) - { - if (onlyFailedTests && !_failed.ContainsKey(method.Name)) - { - continue; - } - - _testResults[method.Name] = string.Empty; - methodsToRun.Add(method); - } - - _failed.Clear(); - - foreach (MethodInfo method in methodsToRun) - { - if (cancellationToken.IsCancellationRequested) - { - break; - } - - await RunTest(method); - } - - if (_retryTests.Any() && !cancellationToken.IsCancellationRequested) - { - await Task.Delay(1000, cancellationToken); - - foreach (MethodInfo retryMethod in _retryTests) - { - _failed.Remove(retryMethod.Name); - await RunTest(retryMethod); - } - } - } - finally - { - _retryTests.Clear(); - _running = false; - await OnTestResults.InvokeAsync(new TestResult(ClassName, _methodInfos!.Length, _passed, _failed, _running)); - StateHasChanged(); - } - } - - public void Toggle(bool open) - { - _collapsed = !open; - StateHasChanged(); - } - - protected override void OnInitialized() - { - _type = GetType(); - _methodInfos = _type - .GetMethods() - .Where(m => m.GetCustomAttribute(typeof(TestMethodAttribute), false) != null) - .ToArray(); - - _testResults = _methodInfos.ToDictionary(m => m.Name, _ => string.Empty); - _interactionToggles = _methodInfos.ToDictionary(m => m.Name, _ => false); - } - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (_jsObjectReference is null) - { - IJSObjectReference? proJs = await JsModuleManager.GetProJsModule(JsRuntime, default); - IJSObjectReference coreJs = await JsModuleManager.GetCoreJsModule(JsRuntime, proJs, default); - - _jsObjectReference = await JsRuntime.InvokeAsync("import", - "./_content/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/testRunner.js"); - await _jsObjectReference.InvokeVoidAsync("initialize", coreJs); - } - - if (firstRender && Results is not null) - { - _passed = Results.Passed; - _failed = Results.Failed; - foreach (string passedTest in _passed.Keys) - { - _testResults[passedTest] = "

Passed

"; - } - foreach (string failedTest in _failed.Keys) - { - _testResults[failedTest] = "

Failed

"; - } - - StateHasChanged(); - } - } - - protected void AddMapRenderFragment(RenderFragment fragment, [CallerMemberName] string methodName = "") - { - _testRenderFragments[methodName] = fragment; - } - - protected async Task WaitForMapToRender([CallerMemberName] string methodName = "", int timeoutInSeconds = 10) - { - //we are delaying by 100 milliseconds each try. - //multiplying the timeout by 10 will get the correct number of tries - var tries = timeoutInSeconds * 10; - - await InvokeAsync(StateHasChanged); - - while (!methodsWithRenderedMaps.Contains(methodName) && (tries > 0)) - { - if (_mapRenderingExceptions.Remove(methodName, out Exception? ex)) - { - ExceptionDispatchInfo.Capture(ex).Throw(); - } - - await Task.Delay(100); - tries--; - } - - if (!methodsWithRenderedMaps.Contains(methodName)) - { - if (_running && _retryTests.All(mi => mi.Name != methodName)) - { - // Sometimes running multiple tests causes timeouts, give this another chance. - _retryTests.Add(_methodInfos!.First(mi => mi.Name == methodName)); - } - - throw new TimeoutException("Map did not render in allotted time."); - } - - methodsWithRenderedMaps.Remove(methodName); - } - - /// - /// Handles the LayerViewCreated event and waits for a specific layer type to render. - /// - /// - /// The name of the test method calling this function, used to track which layer view - /// - /// - /// Optional timeout in seconds to wait for the layer to render. Defaults to 10 seconds. - /// - /// - /// The type of layer to wait for rendering. Must inherit from . - /// - /// - /// Returns the for the specified layer type once it has rendered. - /// - /// - /// Throws if the specified layer type does not render within the allotted time. - /// - protected async Task WaitForLayerToRender( - [CallerMemberName] string methodName = "", - int timeoutInSeconds = 10) where TLayer: Layer - { - int tries = timeoutInSeconds * 10; - - while ((!layerViewCreatedEvents.ContainsKey(methodName) - // check if the layer view was created for the specified layer type - || layerViewCreatedEvents[methodName].All(lvce => lvce.Layer is not TLayer)) - && tries > 0) - { - await Task.Delay(100); - tries--; - } - - if (!layerViewCreatedEvents.ContainsKey(methodName) - || layerViewCreatedEvents[methodName].All(lvce => lvce.Layer is not TLayer)) - { - throw new TimeoutException($"Layer {typeof(TLayer).Name} did not render in allotted time, or LayerViewCreated was not set in MapView.OnLayerViewCreate"); - } - - LayerViewCreateEvent createEvent = layerViewCreatedEvents[methodName].First(lvce => lvce.Layer is TLayer); - layerViewCreatedEvents[methodName].Remove(createEvent); - - return createEvent; - } - - protected void ClearLayerViewEvents([CallerMemberName] string methodName = "") - { - layerViewCreatedEvents.Remove(methodName); - } - - /// - /// Handles the ListItemCreated event and waits for a ListItem to be created. - /// - /// - /// The name of the test method calling this function, used to track which layer view - /// - /// - /// Optional timeout in seconds to wait for the layer to render. Defaults to 10 seconds. - /// - /// - /// Returns the . - /// - /// - /// Throws if the specified layer type does not render within the allotted time. - /// - protected async Task WaitForListItemToBeCreated( - [CallerMemberName] string methodName = "", - int timeoutInSeconds = 10) - { - int tries = timeoutInSeconds * 10; - - while (!listItems.ContainsKey(methodName) - && tries > 0) - { - await Task.Delay(100); - tries--; - } - - if (!listItems.TryGetValue(methodName, out List? items)) - { - throw new TimeoutException("List Item did not render in allotted time, or ListItemCreated was not set in LayerListWidget.OnListItemCreatedHandler"); - } - - ListItem firstItem = items.First(); - listItems[methodName].Remove(firstItem); - - return firstItem; - } - - protected async Task AssertJavaScript(string jsAssertFunction, [CallerMemberName] string methodName = "", - int retryCount = 0, params object[] args) - { - try - { - List jsArgs = [methodName]; - jsArgs.AddRange(args); - - if (jsAssertFunction.Contains(".")) - { - string[] parts = jsAssertFunction.Split('.'); - - IJSObjectReference module = await JsRuntime.InvokeAsync("import", - $"./_content/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/{parts[0]}.js"); - await module.InvokeVoidAsync(parts[1], jsArgs.ToArray()); - } - else - { - await _jsObjectReference!.InvokeVoidAsync(jsAssertFunction, jsArgs.ToArray()); - } - } - catch (Exception) - { - if (retryCount < 4) - { - await Task.Delay(500); - await AssertJavaScript(jsAssertFunction, methodName, retryCount + 1, args); - } - else - { - throw; - } - } - } - - protected async Task WaitForJsTimeout(long time, [CallerMemberName] string methodName = "") - { - await _jsObjectReference!.InvokeVoidAsync("setJsTimeout", time, methodName); - - while (!await _jsObjectReference!.InvokeAsync("timeoutComplete", methodName)) - { - await Task.Delay(100); - } - } - - private async Task RunTest(MethodInfo methodInfo) - { - _currentTest = methodInfo.Name; - _testResults[methodInfo.Name] = "

Running...

"; - _resultBuilder = new StringBuilder(); - _passed.Remove(methodInfo.Name); - _failed.Remove(methodInfo.Name); - _testRenderFragments.Remove(methodInfo.Name); - _mapRenderingExceptions.Remove(methodInfo.Name); - methodsWithRenderedMaps.Remove(methodInfo.Name); - layerViewCreatedEvents.Remove(methodInfo.Name); - listItems.Remove(methodInfo.Name); - Console.WriteLine($"Running test {methodInfo.Name}"); - - try - { - object[] actions = methodInfo.GetParameters() - .Select(pi => - { - Type paramType = pi.ParameterType; - - if (paramType == typeof(Action)) - { - return (Action)(createEvent => LayerViewCreatedHandler(createEvent, methodInfo.Name)); - } - if (paramType == typeof(Func>)) - { - return (Func>)(item => ListItemCreatedHandler(item, methodInfo.Name)); - } - - return (Action)(() => RenderHandler(methodInfo.Name)); - }) - .ToArray(); - - try - { - if (methodInfo.ReturnType == typeof(Task)) - { - await (Task)methodInfo.Invoke(this, actions)!; - } - else - { - methodInfo.Invoke(this, actions); - } - } - catch (TargetInvocationException tie) when (tie.InnerException is not null) - { - throw tie.InnerException; - } - - _passed[methodInfo.Name] = _resultBuilder.ToString(); - _resultBuilder.AppendLine("

Passed

"); - } - catch (Exception ex) - { - if (_currentTest is null) - { - return; - } - - if (!_retryTests.Contains(methodInfo)) - { - _failed[methodInfo.Name] = $"{_resultBuilder}{Environment.NewLine}{ex.StackTrace}"; - _resultBuilder.AppendLine($"

{ex.Message.Replace(Environment.NewLine, "
")}
{ex.StackTrace?.Replace(Environment.NewLine, "
")}

"); - } - - if (ex.Message.Contains("Map component view is in an invalid state")) - { - await Task.Delay(1000); - // force a full reload to recover from this error - NavigationManager.NavigateTo("/", true); - } - } - - if (!_interactionToggles[methodInfo.Name]) - { - await CleanupTest(methodInfo.Name); - } - } - - protected void Log(string message) - { - _resultBuilder.AppendLine($"

{message}

"); - } - - [TestCleanup] - protected async Task CleanupTest(string testName) - { - methodsWithRenderedMaps.Remove(testName); - layerViewCreatedEvents.Remove(testName); - _testResults[testName] = _resultBuilder.ToString(); - _testRenderFragments.Remove(testName); - - await InvokeAsync(async () => - { - StateHasChanged(); - await OnTestResults.InvokeAsync(new TestResult(ClassName, _methodInfos!.Length, _passed, _failed, _running)); - }); - _interactionToggles[testName] = false; - _currentTest = null; - } - - private static void RenderHandler(string methodName) - { - methodsWithRenderedMaps.Add(methodName); - } - - private static void LayerViewCreatedHandler(LayerViewCreateEvent createEvent, string methodName) - { - if (!layerViewCreatedEvents.ContainsKey(methodName)) - { - layerViewCreatedEvents[methodName] = []; - } - - layerViewCreatedEvents[methodName].Add(createEvent); - } - - private static Task ListItemCreatedHandler(ListItem item, string methodName) - { - if (!listItems.ContainsKey(methodName)) - { - listItems[methodName] = []; - } - - listItems[methodName].Add(item); - - return Task.FromResult(item); - } - - private void OnRenderError(ErrorEventArgs arg) - { - _mapRenderingExceptions[arg.MethodName] = arg.Exception; - } - - private string ClassName => GetType().Name; - private int Remaining => _methodInfos is null ? 0 : _methodInfos.Length - (_passed.Count + _failed.Count); - private IJSObjectReference? _jsObjectReference; - private StringBuilder _resultBuilder = new(); - private Type? _type; - private MethodInfo[]? _methodInfos; - private Dictionary _testResults = new(); - private bool _collapsed = true; - private bool _running; - private readonly Dictionary _testRenderFragments = new(); - private static readonly List methodsWithRenderedMaps = new(); - private static readonly Dictionary> layerViewCreatedEvents = new(); - private static readonly Dictionary> listItems = new(); - private readonly Dictionary _mapRenderingExceptions = new(); - private Dictionary _passed = new(); - private Dictionary _failed = new(); - private Dictionary _interactionToggles = []; - private string? _currentTest; - private readonly List _retryTests = []; -} \ No newline at end of file + \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor.cs new file mode 100644 index 000000000..07507e71b --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestRunnerBase.razor.cs @@ -0,0 +1,553 @@ +using dymaptic.GeoBlazor.Core.Components; +using dymaptic.GeoBlazor.Core.Components.Layers; +using dymaptic.GeoBlazor.Core.Events; +using dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Logging; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Text.RegularExpressions; + + +namespace dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Components; + +[TestClass] +public partial class TestRunnerBase +{ + [Inject] + public required IJSRuntime JsRuntime { get; set; } + [Inject] + public required NavigationManager NavigationManager { get; set; } + [Inject] + public required JsModuleManager JsModuleManager { get; set; } + [Inject] + public required ITestLogger TestLogger { get; set; } + [Parameter] + public EventCallback OnTestResults { get; set; } + [Parameter] + public TestResult? Results { get; set; } + [Parameter] + public IJSObjectReference? JsTestRunner { get; set; } + + [CascadingParameter(Name = nameof(TestFilter))] + public string? TestFilter { get; set; } + + private string? FilterValue => TestFilter?.Contains('.') == true ? TestFilter.Split('.')[1] : null; + private string ClassName => GetType().Name; + private int Remaining => _methodInfos is null + ? 0 + : _methodInfos.Length - (_passed.Count + _failed.Count + _inconclusive.Count); + + public async Task RunTests(bool onlyFailedTests = false, int skip = 0, + CancellationToken cancellationToken = default) + { + _running = true; + + try + { + _resultBuilder = new StringBuilder(); + + if (!onlyFailedTests) + { + _passed.Clear(); + _inconclusive.Clear(); + } + + List methodsToRun = []; + _filteredTestCount = 0; + + foreach (MethodInfo method in _methodInfos!.Skip(skip)) + { + if (onlyFailedTests + && (_passed.ContainsKey(method.Name) || _inconclusive.ContainsKey(method.Name))) + { + continue; + } + + if (!FilterMatch(method.Name)) + { + // skip filtered out test + continue; + } + + _testResults[method.Name] = string.Empty; + methodsToRun.Add(method); + _filteredTestCount++; + } + + _failed.Clear(); + + foreach (MethodInfo method in methodsToRun) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + await RunTest(method); + } + + for (int i = 1; i < 2; i++) + { + if (_retryTests.Any() && !cancellationToken.IsCancellationRequested) + { + List retryTests = _retryTests.ToList(); + _retryTests.Clear(); + _retry = i; + await Task.Delay(1000, cancellationToken); + + foreach (MethodInfo retryMethod in retryTests) + { + await RunTest(retryMethod); + } + } + } + } + finally + { + _retryTests.Clear(); + _running = false; + _retry = 0; + + await OnTestResults.InvokeAsync(new TestResult(ClassName, _filteredTestCount, _passed, _failed, + _inconclusive, _running)); + StateHasChanged(); + } + } + + public void Toggle(bool open) + { + _collapsed = !open; + StateHasChanged(); + } + + protected override void OnInitialized() + { + _type = GetType(); + + _methodInfos = _type + .GetMethods() + .Where(m => m.GetCustomAttribute(typeof(TestMethodAttribute), false) != null + && FilterMatch(m.Name)) + .ToArray(); + + _testResults = _methodInfos + .ToDictionary(m => m.Name, _ => string.Empty); + _interactionToggles = _methodInfos.ToDictionary(m => m.Name, _ => false); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender && Results is not null) + { + _passed = Results.Passed; + _failed = Results.Failed; + _inconclusive = Results.Inconclusive; + + foreach (string passedTest in _passed.Keys) + { + _testResults[passedTest] = "

Passed

"; + } + + foreach (string failedTest in _failed.Keys) + { + _testResults[failedTest] = "

Failed

"; + } + + foreach (string inconclusiveTest in _inconclusive.Keys) + { + _testResults[inconclusiveTest] = "

Inconclusive

"; + } + + StateHasChanged(); + } + } + + protected void AddMapRenderFragment(RenderFragment fragment, [CallerMemberName] string methodName = "") + { + _testRenderFragments[methodName] = fragment; + } + + protected async Task WaitForMapToRender([CallerMemberName] string methodName = "", int timeoutInSeconds = 10) + { + //we are delaying by 100 milliseconds each try. + //multiplying the timeout by 10 will get the correct number of tries + var tries = timeoutInSeconds * 10; + + await InvokeAsync(StateHasChanged); + + while (!methodsWithRenderedMaps.Contains(methodName) && (tries > 0)) + { + if (_mapRenderingExceptions.Remove(methodName, out Exception? ex)) + { + if (_running && _retry < 2 && _retryTests.All(mi => mi.Name != methodName) + && !ex.Message.Contains("Invalid GeoBlazor registration key") + && !ex.Message.Contains("Invalid GeoBlazor Pro license key") + && !ex.Message.Contains("No GeoBlazor Registration key provided") + && !ex.Message.Contains("No GeoBlazor Pro license key provided") + && !ex.Message.Contains("Map component view is in an invalid state")) + { + switch (_retry) + { + case 0: + _resultBuilder.AppendLine("First failure: will retry 2 more times"); + + break; + case 1: + _resultBuilder.AppendLine("Second failure: will retry 1 more times"); + + break; + } + + // Sometimes running multiple tests causes timeouts, give this another chance. + _retryTests.Add(_methodInfos!.First(mi => mi.Name == methodName)); + } + + await TestLogger.LogError("Test Failed", ex); + + ExceptionDispatchInfo.Capture(ex).Throw(); + } + + await Task.Delay(100); + tries--; + } + + if (!methodsWithRenderedMaps.Contains(methodName)) + { + if (_running && _retryTests.All(mi => mi.Name != methodName)) + { + // Sometimes running multiple tests causes timeouts, give this another chance. + _retryTests.Add(_methodInfos!.First(mi => mi.Name == methodName)); + + throw new TimeoutException("Map did not render in allotted time. Will re-attempt shortly..."); + } + + throw new TimeoutException("Map did not render in allotted time."); + } + + methodsWithRenderedMaps.Remove(methodName); + } + + /// + /// Handles the LayerViewCreated event and waits for a specific layer type to render. + /// + /// + /// The name of the test method calling this function, used to track which layer view + /// + /// + /// Optional timeout in seconds to wait for the layer to render. Defaults to 10 seconds. + /// + /// + /// The type of layer to wait for rendering. Must inherit from . + /// + /// + /// Returns the for the specified layer type once it has rendered. + /// + /// + /// Throws if the specified layer type does not render within the allotted time. + /// + protected async Task WaitForLayerToRender([CallerMemberName] string methodName = "", + int timeoutInSeconds = 10) where TLayer : Layer + { + int tries = timeoutInSeconds * 10; + + while ((!layerViewCreatedEvents.ContainsKey(methodName) + + // check if the layer view was created for the specified layer type + || layerViewCreatedEvents[methodName].All(lvce => lvce.Layer is not TLayer)) + && tries > 0) + { + await Task.Delay(100); + tries--; + } + + if (!layerViewCreatedEvents.ContainsKey(methodName) + || layerViewCreatedEvents[methodName].All(lvce => lvce.Layer is not TLayer)) + { + throw new TimeoutException($"Layer {typeof(TLayer).Name + } did not render in allotted time, or LayerViewCreated was not set in MapView.OnLayerViewCreate"); + } + + LayerViewCreateEvent createEvent = layerViewCreatedEvents[methodName].First(lvce => lvce.Layer is TLayer); + layerViewCreatedEvents[methodName].Remove(createEvent); + + return createEvent; + } + + protected void ClearLayerViewEvents([CallerMemberName] string methodName = "") + { + layerViewCreatedEvents.Remove(methodName); + } + + /// + /// Handles the ListItemCreated event and waits for a ListItem to be created. + /// + /// + /// The name of the test method calling this function, used to track which layer view + /// + /// + /// Optional timeout in seconds to wait for the layer to render. Defaults to 10 seconds. + /// + /// + /// Returns the . + /// + /// + /// Throws if the specified layer type does not render within the allotted time. + /// + protected async Task WaitForListItemToBeCreated([CallerMemberName] string methodName = "", + int timeoutInSeconds = 10) + { + int tries = timeoutInSeconds * 10; + + while (!listItems.ContainsKey(methodName) + && tries > 0) + { + await Task.Delay(100); + tries--; + } + + if (!listItems.TryGetValue(methodName, out List? items)) + { + throw new TimeoutException( + "List Item did not render in allotted time, or ListItemCreated was not set in LayerListWidget.OnListItemCreatedHandler"); + } + + ListItem firstItem = items.First(); + listItems[methodName].Remove(firstItem); + + return firstItem; + } + + protected async Task AssertJavaScript(string jsAssertFunction, [CallerMemberName] string methodName = "", + int retryCount = 0, params object[] args) + { + try + { + List jsArgs = [methodName]; + jsArgs.AddRange(args); + + if (jsAssertFunction.Contains(".")) + { + string[] parts = jsAssertFunction.Split('.'); + + IJSObjectReference module = await JsRuntime.InvokeAsync("import", + $"./_content/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/{parts[0]}.js"); + await module.InvokeVoidAsync(parts[1], jsArgs.ToArray()); + } + else + { + await JsTestRunner!.InvokeVoidAsync(jsAssertFunction, jsArgs.ToArray()); + } + } + catch (Exception) + { + if (retryCount < 4) + { + await Task.Delay(500); + await AssertJavaScript(jsAssertFunction, methodName, retryCount + 1, args); + } + else + { + throw; + } + } + } + + protected async Task WaitForJsTimeout(long time, [CallerMemberName] string methodName = "") + { + await JsTestRunner!.InvokeVoidAsync("setJsTimeout", time, methodName); + + while (!await JsTestRunner!.InvokeAsync("timeoutComplete", methodName)) + { + await Task.Delay(100); + } + } + + protected void Log(string message) + { + _resultBuilder.AppendLine($"

{message}

"); + } + + protected async Task CleanupTest(string testName) + { + methodsWithRenderedMaps.Remove(testName); + layerViewCreatedEvents.Remove(testName); + _testResults[testName] = _resultBuilder.ToString(); + _testRenderFragments.Remove(testName); + + await InvokeAsync(async () => + { + StateHasChanged(); + + await OnTestResults.InvokeAsync(new TestResult(ClassName, _filteredTestCount, _passed, _failed, + _inconclusive, _running)); + }); + _interactionToggles[testName] = false; + _currentTest = null; + } + + private static void RenderHandler(string methodName) + { + methodsWithRenderedMaps.Add(methodName); + } + + private static void LayerViewCreatedHandler(LayerViewCreateEvent createEvent, string methodName) + { + if (!layerViewCreatedEvents.ContainsKey(methodName)) + { + layerViewCreatedEvents[methodName] = []; + } + + layerViewCreatedEvents[methodName].Add(createEvent); + } + + private static Task ListItemCreatedHandler(ListItem item, string methodName) + { + if (!listItems.ContainsKey(methodName)) + { + listItems[methodName] = []; + } + + listItems[methodName].Add(item); + + return Task.FromResult(item); + } + + private async Task RunTest(MethodInfo methodInfo) + { + if (JsTestRunner is null) + { + await GetJsTestRunner(); + } + + _currentTest = methodInfo.Name; + _testResults[methodInfo.Name] = "

Running...

"; + _resultBuilder = new StringBuilder(); + _passed.Remove(methodInfo.Name); + _failed.Remove(methodInfo.Name); + _inconclusive.Remove(methodInfo.Name); + _testRenderFragments.Remove(methodInfo.Name); + _mapRenderingExceptions.Remove(methodInfo.Name); + methodsWithRenderedMaps.Remove(methodInfo.Name); + layerViewCreatedEvents.Remove(methodInfo.Name); + listItems.Remove(methodInfo.Name); + await TestLogger.Log($"Running test {methodInfo.Name}"); + + try + { + var actions = methodInfo.GetParameters() + .Select(pi => + { + var paramType = pi.ParameterType; + + if (paramType == typeof(Action)) + { + return (Action)(createEvent => + LayerViewCreatedHandler(createEvent, methodInfo.Name)); + } + + if (paramType == typeof(Func>)) + { + return (Func>)(item => ListItemCreatedHandler(item, methodInfo.Name)); + } + + return (Action)(() => RenderHandler(methodInfo.Name)); + }) + .ToArray(); + + try + { + if (methodInfo.ReturnType == typeof(Task)) + { + await (Task)methodInfo.Invoke(this, actions)!; + } + else + { + methodInfo.Invoke(this, actions); + } + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + throw tie.InnerException; + } + + _passed[methodInfo.Name] = _resultBuilder.ToString(); + _resultBuilder.AppendLine("

Passed

"); + } + catch (Exception ex) + { + if (_currentTest is null) + { + return; + } + + var textResult = $"{_resultBuilder}{Environment.NewLine}{ex.Message}{Environment.NewLine}{ex.StackTrace + }"; + string displayColor; + + if (ex is AssertInconclusiveException) + { + _inconclusive[methodInfo.Name] = textResult; + displayColor = "white"; + } + else + { + _failed[methodInfo.Name] = textResult; + displayColor = "red"; + } + + _resultBuilder.AppendLine($"

{ + ex.Message.Replace(Environment.NewLine, "
")}
{ + ex.StackTrace?.Replace(Environment.NewLine, "
")}

"); + } + + if (!_interactionToggles[methodInfo.Name]) + { + await CleanupTest(methodInfo.Name); + } + } + + private void OnRenderError(ErrorEventArgs arg) + { + _mapRenderingExceptions[arg.MethodName] = arg.Exception; + } + + private async Task GetJsTestRunner() + { + JsTestRunner = await JsRuntime.InvokeAsync("import", + "./_content/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/testRunner.js"); + IJSObjectReference? proJs = await JsModuleManager.GetProJsModule(JsRuntime, CancellationToken.None); + IJSObjectReference coreJs = await JsModuleManager.GetCoreJsModule(JsRuntime, proJs, CancellationToken.None); + await JsTestRunner.InvokeVoidAsync("initialize", coreJs); + } + + private bool FilterMatch(string testName) + { + return FilterValue is null + || Regex.IsMatch(testName, $"^{FilterValue}$", RegexOptions.IgnoreCase); + } + + private static readonly List methodsWithRenderedMaps = new(); + private static readonly Dictionary> layerViewCreatedEvents = new(); + private static readonly Dictionary> listItems = new(); + private readonly Dictionary _testRenderFragments = new(); + private readonly Dictionary _mapRenderingExceptions = new(); + private readonly List _retryTests = []; + private StringBuilder _resultBuilder = new(); + private Type? _type; + private MethodInfo[]? _methodInfos; + private Dictionary _testResults = new(); + private bool _collapsed = true; + private bool _running; + private Dictionary _passed = new(); + private Dictionary _failed = new(); + private Dictionary _inconclusive = new(); + private int _filteredTestCount; + private Dictionary _interactionToggles = []; + private string? _currentTest; + private int _retry; +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestWrapper.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestWrapper.razor index c3a3be6d5..5268007ff 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestWrapper.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/TestWrapper.razor @@ -37,6 +37,10 @@ [Parameter] [EditorRequired] public TestResult? Results { get; set; } + + [Parameter] + [EditorRequired] + public required IJSObjectReference JsTestRunner { get; set; } public async Task RunTests(bool onlyFailedTests = false, int skip = 0, CancellationToken cancellationToken = default) { @@ -113,7 +117,8 @@ private Dictionary Parameters => new() { { nameof(OnTestResults), OnTestResults }, - { nameof(Results), Results } + { nameof(Results), Results }, + { nameof(JsTestRunner), JsTestRunner } }; private BlazorFrame.BlazorFrame? _isolatedFrame; diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WMSLayerTests.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WMSLayerTests.razor index 256c112fa..65f256746 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WMSLayerTests.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WMSLayerTests.razor @@ -27,7 +27,7 @@ ); - await WaitForMapToRender(); + await WaitForMapToRender(timeoutInSeconds: 30); LayerViewCreateEvent createEvent = await WaitForLayerToRender(); Assert.IsInstanceOfType(createEvent.Layer); @@ -56,7 +56,7 @@ ); - await WaitForMapToRender(); + await WaitForMapToRender(timeoutInSeconds: 30); LayerViewCreateEvent createEvent = await WaitForLayerToRender(); Assert.IsInstanceOfType(createEvent.Layer); WMSLayer createdLayer = (WMSLayer)createEvent.Layer; diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor index 0f3885d3e..668fbdd9f 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Components/WebMapTests.razor @@ -140,7 +140,7 @@ OnClick="ClickHandler"> - + ); diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Configuration/ConfigurationHelper.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Configuration/ConfigurationHelper.cs new file mode 100644 index 000000000..71d73a0d1 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Configuration/ConfigurationHelper.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Configuration; +using System.Text.Json; + + +namespace dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Configuration; + +public static class ConfigurationHelper +{ + + /// + /// Recursively converts IConfiguration to a nested Dictionary and serializes to JSON. + /// + public static string ToJson(this IConfiguration config) + { + var dict = ToDictionary(config); + var options = new JsonSerializerOptions + { + WriteIndented = true // Pretty print + }; + return JsonSerializer.Serialize(dict, options); + } + + /// + /// Recursively builds a dictionary from IConfiguration. + /// + private static Dictionary ToDictionary(IConfiguration config) + { + var result = new Dictionary(); + + foreach (var child in config.GetChildren()) + { + // If the child has further children, recurse + if (child.GetChildren().Any()) + { + result[child.Key] = ToDictionary(child); + } + else + { + result[child.Key] = child.Value; + } + } + + return result; + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Logging/ITestLogger.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Logging/ITestLogger.cs new file mode 100644 index 000000000..84cadcc7b --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Logging/ITestLogger.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.Logging; +using System.Net.Http.Json; +using System.Text; + + +namespace dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Logging; + +public interface ITestLogger +{ + public Task Log(string message); + + public Task LogError(string message, Exception? exception = null); + + public Task LogError(string message, SerializableException? exception); +} + +public class ServerTestLogger(ILogger logger) : ITestLogger +{ + public Task Log(string message) + { + logger.LogInformation(message); + + return Task.CompletedTask; + } + + public Task LogError(string message, Exception? exception = null) + { + logger.LogError(exception, message); + return Task.CompletedTask; + } + + public Task LogError(string message, SerializableException? exception) + { + if (exception is not null) + { + logger.LogError("{Message}\n{Exception}", message, exception.ToString()); + } + else + { + logger.LogError("{Message}", message); + } + + return Task.CompletedTask; + } +} + +public class ClientTestLogger(IHttpClientFactory httpClientFactory, ILogger logger) : ITestLogger +{ + public async Task Log(string message) + { + using var httpClient = httpClientFactory.CreateClient(nameof(ClientTestLogger)); + logger.LogInformation(message); + + try + { + await httpClient.PostAsJsonAsync("/log", new LogMessage(message, null)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending log message to server"); + } + } + + public async Task LogError(string message, Exception? exception = null) + { + await LogError(message, SerializableException.FromException(exception)); + } + + public async Task LogError(string message, SerializableException? exception) + { + using var httpClient = httpClientFactory.CreateClient(nameof(ClientTestLogger)); + + if (exception is not null) + { + logger.LogError("{Message}\n{Exception}", message, exception.ToString()); + } + else + { + logger.LogError("{Message}", message); + } + + try + { + await httpClient.PostAsJsonAsync("/log-error", new LogMessage(message, exception)); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending log message to server"); + } + } +} + +public record LogMessage(string Message, SerializableException? Exception); + +/// +/// A serializable representation of an exception that preserves all important information +/// including the stack trace, which is lost when deserializing a regular Exception. +/// +public record SerializableException( + string Type, + string Message, + string? StackTrace, + SerializableException? InnerException) +{ + public static SerializableException? FromException(Exception? exception) + { + if (exception is null) return null; + + return new SerializableException( + exception.GetType().FullName ?? exception.GetType().Name, + exception.Message, + exception.StackTrace, + FromException(exception.InnerException)); + } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"{Type}: {Message}"); + + if (!string.IsNullOrEmpty(StackTrace)) + { + sb.AppendLine(StackTrace); + } + + if (InnerException is not null) + { + sb.AppendLine($" ---> {InnerException}"); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/Index.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/Index.razor index baf366c17..d0083bda9 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/Index.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/Index.razor @@ -30,7 +30,7 @@ else if (_running) { - + } else { @@ -45,21 +45,27 @@ else
@if (_running) { - Running... @Remaining tests pending + Running... @Remaining tests pending } else if (_results.Any()) { - Complete - , - Passed: @Passed - , - Failed: @Failed + Complete + | + Passed: @Passed + | + Failed: @Failed + + if (Inconclusive > 0) + { + | + Inconclusive: @Inconclusive + } } @foreach (KeyValuePair result in _results.OrderBy(kvp => kvp.Key)) {

- @Extensions.CamelCaseToSpaces(result.Key) - @((MarkupString)$"Passed: {result.Value.Passed.Count}, Failed: {result.Value.Failed.Count}") + @BuildResultSummaryLine(result.Key, result.Value)

} @@ -67,264 +73,13 @@ else foreach (Type type in _testClassTypes) { - bool isIsolated = type.GetCustomAttribute() != null; + bool isIsolated = _testClassTypes.Count > 1 + && type.GetCustomAttribute() != null; } -} - -@code { - [Inject] - public required IConfiguration Configuration { get; set; } - - [Inject] - public required IHostApplicationLifetime HostApplicationLifetime { get; set; } - - [Inject] - public required IJSRuntime JsRuntime { get; set; } - - [Inject] - public required NavigationManager NavigationManager { get; set; } - - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - _jsObjectReference ??= await JsRuntime.InvokeAsync("import", "./_content/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/testRunner.js"); - if (firstRender) - { - NavigationManager.RegisterLocationChangingHandler(OnLocationChanging); - - await LoadSettings(); - - StateHasChanged(); - - if (!_settings.RetainResultsOnReload) - { - return; - } - - FindAllTests(); - - Dictionary? cachedResults = - await _jsObjectReference.InvokeAsync?>("getTestResults"); - - if (cachedResults is { Count: > 0 }) - { - _results = cachedResults; - } - - if (Configuration["runOnStart"] == "true") - { - bool passed = await RunTests(false, _cts.Token); - - if (!passed) - { - Environment.ExitCode = 1; - } - HostApplicationLifetime.StopApplication(); - } - - if (_results!.Count > 0) - { - string? firstUnpassedClass = _testClassNames - .FirstOrDefault(t => !_results.ContainsKey(t) || _results[t].Passed.Count == 0); - if (firstUnpassedClass is not null && _testClassNames.IndexOf(firstUnpassedClass) > 0) - { - await ScrollAndOpenClass(firstUnpassedClass); - } - } - StateHasChanged(); - } - } - - private void FindAllTests() - { - _results = []; - var assembly = Assembly.GetExecutingAssembly(); - Type[] types = assembly.GetTypes(); - try - { - var proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro.Test.Blazor.Shared"); - types = types.Concat(proAssembly.GetTypes() - .Where(t => t.Name != "ProTestRunnerBase")).ToArray(); - } - catch - { - //ignore if not running pro - } - foreach (Type type in types.Where(t => !t.Name.EndsWith("GeneratedTests"))) - { - if (type.IsAssignableTo(typeof(TestRunnerBase)) && (type.Name != nameof(TestRunnerBase))) - { - _testClassTypes.Add(type); - _testComponents[type.Name] = null; - - int testCount = type.GetMethods() - .Count(m => m.GetCustomAttribute(typeof(TestMethodAttribute), false) != null); - _results![type.Name] = new TestResult(type.Name, testCount, [], [], false); - } - } - - // sort alphabetically - _testClassTypes.Sort((t1, t2) => string.Compare(t1.Name, t2.Name, StringComparison.Ordinal)); - _testClassNames = _testClassTypes.Select(t => t.Name).ToList(); - } - - private async Task RunNewTests(bool onlyFailedTests = false, CancellationToken token = default) - { - string? firstUntestedClass = _testClassNames - .FirstOrDefault(t => !_results!.ContainsKey(t) || _results[t].Passed.Count == 0); - - if (firstUntestedClass is not null) - { - int index = _testClassNames.IndexOf(firstUntestedClass); - await RunTests(onlyFailedTests, token, index); - } - else - { - await RunTests(onlyFailedTests, token); - } - } - - private async Task RunTests(bool onlyFailedTests = false, CancellationToken token = default, - int offset = 0) - { - _running = true; - foreach (var kvp in _testComponents.OrderBy(k => _testClassNames.IndexOf(k.Key)).Skip(offset)) - { - if (token.IsCancellationRequested) - { - break; - } - - if (_results!.TryGetValue(kvp.Key, out TestResult? results)) - { - if (onlyFailedTests && results.Failed.Count == 0) - { - break; - } - } - if (kvp.Value != null) - { - await kvp.Value!.RunTests(onlyFailedTests, cancellationToken: token); - } - } - - _running = false; - await InvokeAsync(StateHasChanged); - var resultBuilder = new StringBuilder($@" -# GeoBlazor Unit Test Results -{DateTime.Now} -Passed: {_results!.Values.Select(r => r.Passed.Count).Sum()} -Failed: {_results.Values.Select(r => r.Failed.Count).Sum()}"); - foreach (KeyValuePair result in _results) - { - resultBuilder.AppendLine($@" -## {result.Key} -Passed: {result.Value.Passed.Count} -Failed: {result.Value.Failed.Count}"); - foreach (KeyValuePair methodResult in result.Value.Passed) - { - resultBuilder.AppendLine($@"### {methodResult.Key} - Passed -{methodResult.Value}"); - } - - foreach (KeyValuePair methodResult in result.Value.Failed) - { - resultBuilder.AppendLine($@"### {methodResult.Key} - Failed -{methodResult.Value}"); - } - } - Console.WriteLine(resultBuilder.ToString()); - - return _results.Values.All(r => r.Failed.Count == 0); - } - - private async Task OnTestResults(TestResult result) - { - _results![result.ClassName] = result; - await SaveResults(); - await InvokeAsync(StateHasChanged); - if (_settings.StopOnFail && result.Failed.Count > 0) - { - await CancelRun(); - await ScrollAndOpenClass(result.ClassName); - } - } - - private void ToggleAll() - { - _showAll = !_showAll; - foreach (TestWrapper? component in _testComponents.Values) - { - component?.Toggle(_showAll); - } - } - - private async Task ScrollAndOpenClass(string className) - { - await _jsObjectReference!.InvokeVoidAsync("scrollToTestClass", className); - TestWrapper? testClass = _testComponents[className]; - testClass?.Toggle(true); - } - - private async Task CancelRun() - { - await _jsObjectReference!.InvokeVoidAsync("setWaitCursor", false); - await Task.Yield(); - - await InvokeAsync(async () => - { - await _cts.CancelAsync(); - _cts = new CancellationTokenSource(); - }); - } - - private async ValueTask OnLocationChanging(LocationChangingContext context) - { - await SaveResults(); - } - - private async Task SaveResults() - { - await _jsObjectReference!.InvokeVoidAsync("saveTestResults", _results); - } - - private async Task SaveSettings() - { - await _jsObjectReference!.InvokeVoidAsync("saveSettings", _settings); - } - - private async Task LoadSettings() - { - TestSettings? settings = await _jsObjectReference!.InvokeAsync("loadSettings"); - if (settings is not null) - { - _settings = settings; - } - } - - private int Remaining => _results?.Sum(r => - r.Value.TestCount - (r.Value.Passed.Count + r.Value.Failed.Count)) ?? 0; - private int Passed => _results?.Sum(r => r.Value.Passed.Count) ?? 0; - private int Failed => _results?.Sum(r => r.Value.Failed.Count) ?? 0; - private IJSObjectReference? _jsObjectReference; - private Dictionary? _results; - private bool _running; - private readonly List _testClassTypes = []; - private List _testClassNames = []; - private readonly Dictionary _testComponents = new(); - private bool _showAll; - private CancellationTokenSource _cts = new(); - private TestSettings _settings = new(true, true); - - public record TestSettings(bool StopOnFail, bool RetainResultsOnReload) - { - public bool StopOnFail { get; set; } = StopOnFail; - public bool RetainResultsOnReload { get; set; } = RetainResultsOnReload; - } - } \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/Index.razor.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/Index.razor.cs new file mode 100644 index 000000000..d8756f55c --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/Index.razor.cs @@ -0,0 +1,411 @@ +using dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Components; +using dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Logging; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.JSInterop; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + + +namespace dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Pages; + +public partial class Index +{ + [Inject] + public required IJSRuntime JsRuntime { get; set; } + [Inject] + public required NavigationManager NavigationManager { get; set; } + [Inject] + public required JsModuleManager JsModuleManager { get; set; } + [Inject] + public required ITestLogger TestLogger { get; set; } + [Inject] + public required IAppValidator AppValidator { get; set; } + [Inject] + public required IConfiguration Configuration { get; set; } + [CascadingParameter(Name = nameof(RunOnStart))] + public required bool RunOnStart { get; set; } + /// + /// Only run Pro Tests + /// + [CascadingParameter(Name = nameof(ProOnly))] + public required bool ProOnly { get; set; } + + [CascadingParameter(Name = nameof(TestFilter))] + public string? TestFilter { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_allPassed) + { + return; + } + + if (firstRender) + { + try + { + await AppValidator.ValidateLicense(); + } + catch (Exception) + { + IConfigurationSection geoblazorConfig = Configuration.GetSection("GeoBlazor"); + + throw new InvalidRegistrationException($"Failed to validate GeoBlazor License Key: { + geoblazorConfig.GetValue("LicenseKey", geoblazorConfig.GetValue("RegistrationKey", "No Key Found")) + }"); + } + + _jsTestRunner = await JsRuntime.InvokeAsync("import", + "./_content/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/testRunner.js"); + IJSObjectReference? proJs = await JsModuleManager.GetProJsModule(JsRuntime, CancellationToken.None); + IJSObjectReference coreJs = await JsModuleManager.GetCoreJsModule(JsRuntime, proJs, CancellationToken.None); + WFSServer[] wfsServers = Configuration.GetSection("WFSServers").Get()!; + await _jsTestRunner.InvokeVoidAsync("initialize", coreJs, wfsServers); + + NavigationManager.RegisterLocationChangingHandler(OnLocationChanging); + + await LoadSettings(); + + if (!_settings.RetainResultsOnReload) + { + return; + } + + FindAllTests(); + + Dictionary? cachedResults = + await _jsTestRunner.InvokeAsync?>("getTestResults"); + + if (cachedResults is { Count: > 0 }) + { + _results = cachedResults; + } + + if (_results!.Count > 0) + { + string? firstUnpassedClass = _testClassNames + .FirstOrDefault(t => !_results.ContainsKey(t) + || (_results[t].Passed.Count == 0 && _results[t].Inconclusive.Count == 0)); + + if (firstUnpassedClass is not null && _testClassNames.IndexOf(firstUnpassedClass) > 0) + { + await ScrollAndOpenClass(firstUnpassedClass); + } + } + + // need an extra render cycle to register the `_testComponents` dictionary + StateHasChanged(); + } + else if (RunOnStart && !_running) + { + // Auto-run configuration + _running = true; + + // give everything time to load correctly + await Task.Delay(1000); + await TestLogger.Log("Starting Test Auto-Run:"); + string? attempts = await JsRuntime.InvokeAsync("localStorage.getItem", "runAttempts"); + + int attemptCount = 0; + + if (attempts is not null && int.TryParse(attempts, out attemptCount)) + { + await TestLogger.Log($"Attempt #{attemptCount}"); + } + + await TestLogger.Log("----------"); + + _allPassed = await RunTests(true, _cts.Token); + + if (!_allPassed) + { + await TestLogger.Log( + "Test Run Failed or Errors Encountered. Reload the page to re-run failed tests."); + await JsRuntime.InvokeVoidAsync("localStorage.setItem", "runAttempts", ++attemptCount); + } + } + } + + private void FindAllTests() + { + _results = []; + Type[] types; + + if (ProOnly) + { + var proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro.Test.Blazor.Shared"); + + types = proAssembly.GetTypes() + .Where(t => t.Name != "ProTestRunnerBase") + .ToArray(); + } + else + { + var assembly = Assembly.Load("dymaptic.GeoBlazor.Core.Test.Blazor.Shared"); + types = assembly.GetTypes(); + + try + { + var proAssembly = Assembly.Load("dymaptic.GeoBlazor.Pro.Test.Blazor.Shared"); + + types = types.Concat(proAssembly.GetTypes() + .Where(t => t.Name != "ProTestRunnerBase")) + .ToArray(); + } + catch + { + //ignore if not running pro + } + } + + foreach (Type type in types) + { + if (!string.IsNullOrWhiteSpace(TestFilter)) + { + string filter = TestFilter.Split('.')[0]; + if (!Regex.IsMatch(type.Name, $"^{filter}$", RegexOptions.IgnoreCase)) + { + continue; + } + } + + if (type.IsAssignableTo(typeof(TestRunnerBase)) && (type.Name != nameof(TestRunnerBase))) + { + _testClassTypes.Add(type); + _testComponents[type.Name] = null; + + int testCount = type.GetMethods() + .Count(m => m.GetCustomAttribute(typeof(TestMethodAttribute), false) != null); + _results![type.Name] = new TestResult(type.Name, testCount, [], [], [], false); + } + } + + // sort alphabetically + _testClassTypes.Sort((t1, t2) => string.Compare(t1.Name, t2.Name, StringComparison.Ordinal)); + _testClassNames = _testClassTypes.Select(t => t.Name).ToList(); + } + + private async Task RunNewTests(bool onlyFailedTests = false, CancellationToken token = default) + { + string? firstUntestedClass = _testClassNames + .FirstOrDefault(t => !_results!.ContainsKey(t) + || (_results[t].Passed.Count == 0 && _results[t].Inconclusive.Count == 0)); + + if (firstUntestedClass is not null) + { + int index = _testClassNames.IndexOf(firstUntestedClass); + await RunTests(onlyFailedTests, token, index); + } + else + { + await RunTests(onlyFailedTests, token); + } + } + + private async Task RunTests(bool onlyFailedTests = false, CancellationToken token = default, + int offset = 0) + { + _running = true; + + foreach (var kvp in _testComponents.OrderBy(k => _testClassNames.IndexOf(k.Key)).Skip(offset)) + { + if (token.IsCancellationRequested) + { + break; + } + + if (_results!.TryGetValue(kvp.Key, out TestResult? results)) + { + if (onlyFailedTests && results.Failed.Count == 0 + && (results.Passed.Count > 0 || results.Inconclusive.Count > 0)) + { + break; + } + } + + if (kvp.Value != null) + { + await kvp.Value!.RunTests(onlyFailedTests, cancellationToken: token); + } + } + + var resultBuilder = new StringBuilder($""" + + # GeoBlazor Unit Test Results + {DateTime.Now} + Passed: {_results!.Values.Select(r => r.Passed.Count).Sum()} + Failed: {_results.Values.Select(r => r.Failed.Count).Sum()} + Inconclusive: {_results.Values.Select(r => r.Inconclusive.Count).Sum()} + """); + + foreach (KeyValuePair result in _results) + { + resultBuilder.AppendLine($""" + + ## {result.Key} + Passed: {result.Value.Passed.Count} + Failed: {result.Value.Failed.Count} + Inconclusive: {result.Value.Inconclusive.Count} + """); + + foreach (KeyValuePair methodResult in result.Value.Passed) + { + resultBuilder.AppendLine($""" + ### {methodResult.Key} - Passed + {methodResult.Value} + """); + } + + foreach (KeyValuePair methodResult in result.Value.Failed) + { + resultBuilder.AppendLine($""" + ### {methodResult.Key} - Failed + {methodResult.Value} + """); + } + + foreach (KeyValuePair methodResult in result.Value.Inconclusive) + { + resultBuilder.AppendLine($""" + ### {methodResult.Key} - Inconclusive + {methodResult.Value} + """); + } + } + + await TestLogger.Log(resultBuilder.ToString()); + + await InvokeAsync(async () => + { + StateHasChanged(); + await Task.Delay(1000, token); + _running = false; + }); + + return _results.Values.All(r => r.Failed.Count == 0); + } + + private async Task OnTestResults(TestResult result) + { + _results![result.ClassName] = result; + await SaveResults(); + await InvokeAsync(StateHasChanged); + + if (_settings.StopOnFail && result.Failed.Count > 0) + { + await CancelRun(); + await ScrollAndOpenClass(result.ClassName); + } + } + + private void ToggleAll() + { + _showAll = !_showAll; + + foreach (TestWrapper? component in _testComponents.Values) + { + component?.Toggle(_showAll); + } + } + + private async Task ScrollAndOpenClass(string className) + { + await _jsTestRunner!.InvokeVoidAsync("scrollToTestClass", className); + TestWrapper? testClass = _testComponents[className]; + testClass?.Toggle(true); + } + + private async Task CancelRun() + { + await _jsTestRunner!.InvokeVoidAsync("setWaitCursor", false); + await Task.Yield(); + + await InvokeAsync(async () => + { + await _cts.CancelAsync(); + _cts = new CancellationTokenSource(); + _running = false; + }); + } + + private async ValueTask OnLocationChanging(LocationChangingContext context) + { + await SaveResults(); + } + + private async Task SaveResults() + { + await _jsTestRunner!.InvokeVoidAsync("saveTestResults", _results); + } + + private async Task SaveSettings() + { + await _jsTestRunner!.InvokeVoidAsync("saveSettings", _settings); + } + + private async Task LoadSettings() + { + TestSettings? settings = await _jsTestRunner!.InvokeAsync("loadSettings"); + + if (settings is not null) + { + _settings = settings; + } + } + + private MarkupString BuildResultSummaryLine(string testName, TestResult result) + { + StringBuilder builder = new(testName); + builder.Append(" - "); + + if (result.Pending > 0) + { + builder.Append($"Pending: {result.Pending}"); + } + + if (result.Passed.Count > 0 || result.Failed.Count > 0 || result.Inconclusive.Count > 0) + { + if (result.Pending > 0) + { + builder.Append(" | "); + } + builder.Append($"Passed: {result.Passed.Count}"); + builder.Append(" | "); + builder.Append($"Failed: {result.Failed.Count}"); + if (result.Inconclusive.Count > 0) + { + builder.Append(" | "); + builder.Append($"Failed: {result.Inconclusive.Count}"); + } + } + + return new MarkupString(builder.ToString()); + } + + private int Remaining => _results?.Sum(r => + r.Value.TestCount - (r.Value.Passed.Count + r.Value.Failed.Count + r.Value.Inconclusive.Count)) ?? 0; + private int Passed => _results?.Sum(r => r.Value.Passed.Count) ?? 0; + private int Failed => _results?.Sum(r => r.Value.Failed.Count) ?? 0; + private int Inconclusive => _results?.Sum(r => r.Value.Inconclusive.Count) ?? 0; + private IJSObjectReference? _jsTestRunner; + private Dictionary? _results; + private bool _running; + private readonly List _testClassTypes = []; + private List _testClassNames = []; + private readonly Dictionary _testComponents = new(); + private bool _showAll; + private CancellationTokenSource _cts = new(); + private TestSettings _settings = new(false, true); + private bool _allPassed; + + public record TestSettings(bool StopOnFail, bool RetainResultsOnReload) + { + public bool StopOnFail { get; set; } = StopOnFail; + public bool RetainResultsOnReload { get; set; } = RetainResultsOnReload; + } + + private record WFSServer(string Url, string OutputFormat); +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/TestFrame.razor b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/TestFrame.razor index 9d24aa4e7..1db753396 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/TestFrame.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/Pages/TestFrame.razor @@ -124,7 +124,10 @@ private Dictionary Parameters => new() { - { nameof(OnTestResults), EventCallback.Factory.Create(this, OnTestResults) }, + { + nameof(OnTestResults), + EventCallback.Factory.Create(this, OnTestResults) + }, { nameof(Results), _results } }; diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/TestResult.cs b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/TestResult.cs index 5a3c4f570..9e2656ff5 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/TestResult.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/TestResult.cs @@ -2,9 +2,16 @@ namespace dymaptic.GeoBlazor.Core.Test.Blazor.Shared; -public record TestResult(string ClassName, int TestCount, - Dictionary Passed, Dictionary Failed, - bool InProgress); +public record TestResult( + string ClassName, + int TestCount, + Dictionary Passed, + Dictionary Failed, + Dictionary Inconclusive, + bool InProgress) +{ + public int Pending => TestCount - (Passed.Count + Failed.Count); +} public record ErrorEventArgs(Exception Exception, string MethodName); \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/dymaptic.GeoBlazor.Core.Test.Blazor.Shared.csproj b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/dymaptic.GeoBlazor.Core.Test.Blazor.Shared.csproj index 11d4efe98..895f3ae72 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/dymaptic.GeoBlazor.Core.Test.Blazor.Shared.csproj +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/dymaptic.GeoBlazor.Core.Test.Blazor.Shared.csproj @@ -18,16 +18,17 @@ - + - + - + + - + diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/css/site.css b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/css/site.css index 80985b99d..67872984c 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/css/site.css +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/css/site.css @@ -91,3 +91,31 @@ button { .blazor-error-boundary::after { content: "An error has occurred." } + +.passed { + color: green; +} + +.failed { + color: red; +} + +.pending { + color: orange; +} + +.completed { + color: blue; +} + +.inconclusive { + color: gray; +} + +.bold { + font-weight: bold; +} + +.stop-btn { + background-color: hotpink; +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/testRunner.js b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/testRunner.js index 4599fbc0b..a3240d3cd 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/testRunner.js +++ b/test/dymaptic.GeoBlazor.Core.Test.Blazor.Shared/wwwroot/testRunner.js @@ -6,7 +6,7 @@ export let SimpleRenderer; let esriConfig; -export function initialize(core) { +export function initialize(core, wfsServers) { Core = core; arcGisObjectRefs = Core.arcGisObjectRefs; Color = Core.Color; @@ -14,6 +14,31 @@ export function initialize(core) { SimpleRenderer = Core.SimpleRenderer; esriConfig = Core.esriConfig; setWaitCursor() + + if (!wfsServers) { + return; + } + + core.esriConfig.request.interceptors.push({ + before: (params) => { + if (wfsServers) { + for (let server of wfsServers) { + let serverUrl = server.url; + if (params.url.includes(serverUrl)) { + let serverOutputFormat = server.outputFormat; + let requestType = getCaseInsensitive(params.requestOptions.query, 'request'); + let outputFormat = getCaseInsensitive(params.requestOptions.query, 'outputFormat'); + + if (requestType.toLowerCase() === 'getfeature' && !outputFormat) { + params.requestOptions.query.outputFormat = serverOutputFormat; + } + let path = params.url.replace('https://', ''); + params.url = params.url.replace(serverUrl, `https://${location.host}/sample/wfs/url?url=${path}`); + } + } + } + } + }) } export function setWaitCursor(wait) { diff --git a/test/dymaptic.GeoBlazor.Core.Test.Unit/Usings.cs b/test/dymaptic.GeoBlazor.Core.Test.Unit/Usings.cs index ab67c7ea9..eefb0a27d 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.Unit/Usings.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.Unit/Usings.cs @@ -1 +1,4 @@ -global using Microsoft.VisualStudio.TestTools.UnitTesting; \ No newline at end of file +global using Microsoft.VisualStudio.TestTools.UnitTesting; + + +[assembly: Parallelize(Scope = ExecutionScope.ClassLevel)] \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Program.cs b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Program.cs index 586720efc..0ba9aff60 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Program.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using dymaptic.GeoBlazor.Core; +using dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Logging; using dymaptic.GeoBlazor.Core.Test.WebApp.Client; using Microsoft.Extensions.Hosting; @@ -9,5 +10,10 @@ builder.Configuration.AddInMemoryCollection(); builder.Services.AddGeoBlazor(builder.Configuration); builder.Services.AddScoped(); +builder.Services.AddHttpClient(client => + client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(client => + client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)); await builder.Build().RunAsync(); diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Routes.razor b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Routes.razor index 5c98e3946..a762a4da4 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Routes.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/Routes.razor @@ -3,8 +3,21 @@ NotFoundPage="@typeof(NotFound)"> - - + + + + + + + +@code { + [Parameter] + [EditorRequired] + public required bool RunOnStart { get; set; } + + [Parameter] + public string? TestFilter { get; set; } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/WasmApplicationLifetime.cs b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/WasmApplicationLifetime.cs index 8879ed19e..0ff0ca0d1 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/WasmApplicationLifetime.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.Client/WasmApplicationLifetime.cs @@ -3,16 +3,18 @@ namespace dymaptic.GeoBlazor.Core.Test.WebApp.Client; -public class WasmApplicationLifetime: IHostApplicationLifetime +public class WasmApplicationLifetime(IHttpClientFactory httpClientFactory) : IHostApplicationLifetime { - public CancellationToken ApplicationStarted => CancellationToken.None; + private readonly CancellationTokenSource _stoppingCts = new(); + private readonly CancellationTokenSource _stoppedCts = new(); - public CancellationToken ApplicationStopping => CancellationToken.None; - - public CancellationToken ApplicationStopped => CancellationToken.None; + public CancellationToken ApplicationStarted => CancellationToken.None; // Already started in WASM + public CancellationToken ApplicationStopping => _stoppingCts.Token; + public CancellationToken ApplicationStopped => _stoppedCts.Token; public void StopApplication() { - throw new NotImplementedException(); + using HttpClient httpClient = httpClientFactory.CreateClient(nameof(WasmApplicationLifetime)); + _ = httpClient.PostAsync($"exit?exitCode={Environment.ExitCode}", null); } } \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/App.razor b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/App.razor index 8bed667a1..6c0f0fe51 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/App.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/App.razor @@ -6,11 +6,12 @@ - + + @@ -24,7 +25,9 @@ @{ #endif } - + @@ -34,10 +37,13 @@ [Inject] public required IConfiguration Configuration { get; set; } -#if DEBUG + [Inject] + public required NavigationManager NavigationManager { get; set; } + protected override void OnParametersSet() { base.OnParametersSet(); +#if DEBUG IComponentRenderMode oldRenderMode = _configuredRenderMode; _configuredRenderMode = Configuration.GetValue("RenderMode", "Auto") switch { @@ -50,8 +56,50 @@ { StateHasChanged(); } - } #endif + _runOnStart = Configuration.GetValue("RunOnStart", false); + _testFilter = Configuration.GetValue("TestFilter"); + + Uri uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri); + Dictionary queryDict = QueryHelpers.ParseQuery(uri.Query); + + foreach (string key in queryDict.Keys) + { + switch (key.ToLowerInvariant()) + { + case "runonstart": + if (bool.TryParse(queryDict[key].ToString(), out bool queryRunValue)) + { + _runOnStart = queryRunValue; + } + + break; + case "testfilter": + if (queryDict[key].ToString() is { Length: > 0 } queryFilterValue) + { + _testFilter = queryFilterValue; + } + + break; + case "rendermode": + if (queryDict[key].ToString() is { Length: > 0 } queryRenderModeValue) + { + _configuredRenderMode = queryRenderModeValue.ToLowerInvariant() switch + { + "server" => InteractiveServer, + "webassembly" => InteractiveWebAssembly, + "wasm" => InteractiveWebAssembly, + _ => InteractiveAuto + }; + } + + break; + } + } + } + + private bool _runOnStart; + private string? _testFilter; private IComponentRenderMode _configuredRenderMode = InteractiveAuto; } \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/_Imports.razor b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/_Imports.razor index a14e61484..20801559f 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/_Imports.razor +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Components/_Imports.razor @@ -1,15 +1,4 @@ -@using System.Net.Http -@using System.Net.Http.Json -@using Microsoft.AspNetCore.Components.Forms -@using Microsoft.AspNetCore.Components.Routing -@using Microsoft.AspNetCore.Components.Web -@using static Microsoft.AspNetCore.Components.Web.RenderMode -@using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.JSInterop -@using dymaptic.GeoBlazor.Core.Test.WebApp -@using dymaptic.GeoBlazor.Core.Test.WebApp.Client -@using dymaptic.GeoBlazor.Core.Test.WebApp.Components -@using dymaptic.GeoBlazor.Core +@using dymaptic.GeoBlazor.Core @using dymaptic.GeoBlazor.Core.Attributes @using dymaptic.GeoBlazor.Core.Components @using dymaptic.GeoBlazor.Core.Components.Geometries @@ -27,4 +16,17 @@ @using dymaptic.GeoBlazor.Core.Interfaces @using dymaptic.GeoBlazor.Core.Model @using dymaptic.GeoBlazor.Core.Options -@using dymaptic.GeoBlazor.Core.Results \ No newline at end of file +@using dymaptic.GeoBlazor.Core.Results +@using dymaptic.GeoBlazor.Core.Test.WebApp +@using dymaptic.GeoBlazor.Core.Test.WebApp.Client +@using dymaptic.GeoBlazor.Core.Test.WebApp.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.WebUtilities +@using Microsoft.Extensions.Primitives +@using Microsoft.JSInterop +@using System.Net.Http +@using System.Net.Http.Json +@using static Microsoft.AspNetCore.Components.Web.RenderMode \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Program.cs b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Program.cs index 1aee62607..37ed0ecaf 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Program.cs +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Program.cs @@ -1,8 +1,9 @@ using dymaptic.GeoBlazor.Core.Test.WebApp.Components; using dymaptic.GeoBlazor.Core; using dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Components; +using dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Logging; +using dymaptic.GeoBlazor.Core.Test.WebApp; using dymaptic.GeoBlazor.Core.Test.WebApp.Client; -using Microsoft.AspNetCore.StaticFiles; using System.Text.Json; @@ -16,6 +17,7 @@ .AddInteractiveWebAssemblyComponents(); builder.Services.AddGeoBlazor(builder.Configuration); + builder.Services.AddScoped(); WebApplication app = builder.Build(); @@ -44,6 +46,9 @@ .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(Routes).Assembly, typeof(TestRunnerBase).Assembly); + + app.MapTestLogger(); + app.MapApplicationManagement(); app.Run(); diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Properties/launchSettings.json b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Properties/launchSettings.json index 5e5f251da..615165fb7 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Properties/launchSettings.json +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/Properties/launchSettings.json @@ -21,6 +21,28 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } + }, + "wasm-debugger": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7249;http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "auto-run": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7249;http://localhost:5281", + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "RunOnStart": "true", + "RenderMode": "WebAssembly" + } } } } diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/TestApi.cs b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/TestApi.cs new file mode 100644 index 000000000..fcd335ff7 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/TestApi.cs @@ -0,0 +1,29 @@ +using dymaptic.GeoBlazor.Core.Test.Blazor.Shared.Logging; + + +namespace dymaptic.GeoBlazor.Core.Test.WebApp; + +public static class TestApi +{ + extension(WebApplication app) + { + public void MapTestLogger() + { + app.MapPost("/log", (LogMessage message, ITestLogger logger) => + logger.Log(message.Message)); + + app.MapPost("/log-error", (LogMessage message, ITestLogger logger) => + logger.LogError(message.Message, message.Exception)); + } + + public void MapApplicationManagement() + { + app.MapPost("/exit", (string exitCode, ITestLogger logger, IHostApplicationLifetime lifetime) => + { + logger.Log($"Exiting application with code {exitCode}"); + Environment.ExitCode = int.Parse(exitCode); + lifetime.StopApplication(); + }); + } + } +} \ No newline at end of file diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/appsettings.json b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/appsettings.json new file mode 100644 index 000000000..37c260186 --- /dev/null +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj index ea64e4203..e393ebd0c 100644 --- a/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj +++ b/test/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp/dymaptic.GeoBlazor.Core.Test.WebApp.csproj @@ -3,6 +3,9 @@ net10.0 aspnet-dymaptic.GeoBlazor.Core.Test.WebApp-881b5a42-0b71-4c8c-9901-8d12693bd109 + + $(StaticWebAssetEndpointExclusionPattern);appsettings* +