diff --git a/.github/actions/packtool-e2e/action.yml b/.github/actions/packtool-e2e/action.yml index 89d454e63..84e42ae9b 100644 --- a/.github/actions/packtool-e2e/action.yml +++ b/.github/actions/packtool-e2e/action.yml @@ -1,15 +1,11 @@ -name: Run InfiniFrame Pack Tool E2E -description: Sets up pack tool, runs publish, and validates packed output. +name: Run InfiniFrame SingleFile E2E +description: Builds, publishes as single-file, and validates packed output. inputs: tool-project: - description: Path to InfiniFrame.Tools.Pack.csproj + description: Path to InfiniFrame.SingleFile.csproj required: false - default: src/InfiniFrame.Tools.Pack/InfiniFrame.Tools.Pack.csproj - package-output: - description: Directory where tool package artifacts are emitted - required: false - default: artifacts/dotnet-tools + default: src/InfiniFrame.SingleFile/InfiniFrame.SingleFile.csproj project: description: Path to target app csproj required: true @@ -20,14 +16,6 @@ inputs: description: Build configuration required: false default: Release - framework: - description: Target framework - required: false - default: net10.0 - self-contained: - description: Self-contained mode - required: false - default: "true" output: description: Output directory for packed publish required: true @@ -41,48 +29,35 @@ inputs: runs: using: composite steps: - - name: Build tool project - shell: bash - run: | - dotnet build "${{ inputs['tool-project'] }}" -c Release --no-restore - - - name: Pack tool project + - name: Publish single-file via MSBuild target shell: bash run: | - dotnet pack "${{ inputs['tool-project'] }}" -c Release --no-build --no-restore -o "${{ inputs['package-output'] }}" - - - name: Install or update global tool - shell: bash - run: | - if dotnet tool list --global | grep -q "InfiniLore.InfiniFrame.Tools.Pack"; then - dotnet tool update --global InfiniLore.InfiniFrame.Tools.Pack --add-source "${{ inputs['package-output'] }}" --ignore-failed-sources - else - dotnet tool install --global InfiniLore.InfiniFrame.Tools.Pack --add-source "${{ inputs['package-output'] }}" --ignore-failed-sources - fi - - - name: Add global tools path - shell: bash - run: | - echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" - if [ -n "${USERPROFILE:-}" ]; then - echo "$USERPROFILE/.dotnet/tools" >> "$GITHUB_PATH" - fi - - - name: Verify tool command - shell: bash - run: | - infiniframe-pack --help + set -euo pipefail + dotnet publish "${{ inputs.project }}" \ + -t:InfiniFrameSingleFile \ + -r "${{ inputs.rid }}" \ + -c "${{ inputs.configuration }}" \ + -p:InfiniFrameSingleFileActive=true \ + -p:InfiniFrameSingleFileRid="${{ inputs.rid }}" \ + -p:InfiniFrameSingleFileSelfContained=true - - name: Run pack publish + - name: Locate publish output shell: bash run: | set -euo pipefail - infiniframe-pack publish "${{ inputs.project }}" \ - --rid "${{ inputs.rid }}" \ - --configuration "${{ inputs.configuration }}" \ - --framework "${{ inputs.framework }}" \ - --self-contained "${{ inputs['self-contained'] }}" \ - --output "${{ inputs.output }}" + project_dir="$(dirname "${{ inputs.project }}")" + output_dir="${{ inputs.output }}" + mkdir -p "$output_dir" + + # Find the publish directory + publish_dir=$(find "$project_dir/bin" -type d -name "publish" -path "*${{ inputs.rid }}*" | head -1) + if [ -z "$publish_dir" ]; then + echo "Could not find publish directory under $project_dir/bin" + exit 1 + fi + + cp -r "$publish_dir"/* "$output_dir/" + echo "Copied publish output to $output_dir" - name: Validate output shape shell: bash @@ -95,6 +70,8 @@ runs: if [ ! -f "$main_output" ]; then echo "Expected single-file output is missing: $main_output" + echo "Contents of output directory:" + ls -la "$output_dir/" exit 1 fi diff --git a/.github/workflows/ci-testing.yml b/.github/workflows/ci-testing.yml index 99eec96c4..457798fa1 100644 --- a/.github/workflows/ci-testing.yml +++ b/.github/workflows/ci-testing.yml @@ -17,6 +17,10 @@ on: description: 'PR number to test. Leave empty to test the current branch commit.' required: false type: string + enable_coverage: + description: 'Enable code coverage' + type: boolean + default: false run_windows: description: 'Run Windows GUI tests' type: boolean @@ -66,4 +70,16 @@ jobs: run_trim_aot: ${{ github.event_name == 'push' || inputs.run_trim_aot }} enable_test_exports: true + enable_coverage: ${{ github.event_name == 'push' || inputs.enable_coverage }} secrets: inherit + + coverage: + name: Coverage Badges + needs: [run] + if: ${{ github.event_name == 'push' || inputs.enable_coverage }} + uses: ./.github/workflows/shared-coverage.yml + with: + pr_number: ${{ inputs.pr_number }} + permissions: + contents: write + pull-requests: write diff --git a/.github/workflows/shared-coverage.yml b/.github/workflows/shared-coverage.yml new file mode 100644 index 000000000..176b4879c --- /dev/null +++ b/.github/workflows/shared-coverage.yml @@ -0,0 +1,167 @@ +#file: noinspection UndefinedAction,UndefinedParamsPresent +name: "Shared: Coverage Badges" + +on: + workflow_call: + inputs: + badge_branch: + description: 'Branch to push badge updates to (defaults to current branch)' + type: string + required: false + default: '' + pr_number: + description: 'PR number to post a coverage comment on (optional)' + type: string + required: false + default: '' + +permissions: + contents: write + pull-requests: write + +jobs: + coverage: + name: Generate Coverage Badges + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + # TypeScript Coverage + - name: Download TS Coverage Artifact + uses: actions/download-artifact@v8 + with: + name: ts-coverage + path: ts-coverage + + - name: Extract TS Coverage + id: ts + shell: pwsh + run: | + $lcov = Get-Content "ts-coverage/lcov.info" -Raw + $totalLines = 0 + $totalHit = 0 + [regex]::Matches($lcov, 'LF:(\d+)') | ForEach-Object { + $totalLines += [int]$_.Groups[1].Value + } + [regex]::Matches($lcov, 'LH:(\d+)') | ForEach-Object { + $totalHit += [int]$_.Groups[1].Value + } + $pct = if ($totalLines -gt 0) { [math]::Round(($totalHit / $totalLines) * 100, 1) } else { 0 } + "pct=$pct" >> $env:GITHUB_OUTPUT + Write-Host "TS coverage: $pct% ($totalHit / $totalLines lines)" + + # C# Coverage + - name: Download C# Coverage Artifacts + uses: actions/download-artifact@v8 + with: + pattern: cs-coverage-* + path: cs-coverage + merge-multiple: true + + - name: Aggregate C# Coverage + id: cs + shell: pwsh + run: | + $totalLines = 0 + $totalCovered = 0 + Get-ChildItem "cs-coverage" -Recurse -Filter "*.cobertura.xml" -ErrorAction SilentlyContinue | ForEach-Object { + $content = Get-Content $_.FullName -Raw + # Match only the root element to avoid double-counting subtotals + if ($content -match ']*lines-valid="(\d+)"') { + $totalLines += [int]$Matches[1] + } + if ($content -match ']*lines-covered="(\d+)"') { + $totalCovered += [int]$Matches[1] + } + } + $pct = if ($totalLines -gt 0) { [math]::Round(($totalCovered / $totalLines) * 100, 1) } else { 0 } + "pct=$pct" >> $env:GITHUB_OUTPUT + Write-Host "C# coverage: $pct% ($totalCovered / $totalLines lines)" + + # Badge JSON + - name: Write Badge JSON + shell: pwsh + run: | + $ts = "${{ steps.ts.outputs.pct }}%" + $cs = "${{ steps.cs.outputs.pct }}%" + $tsColor = if ([double]"${{ steps.ts.outputs.pct }}" -ge 90) { "brightgreen" } + elseif ([double]"${{ steps.ts.outputs.pct }}" -ge 75) { "yellow" } + else { "red" } + $csColor = if ([double]"${{ steps.cs.outputs.pct }}" -ge 90) { "brightgreen" } + elseif ([double]"${{ steps.cs.outputs.pct }}" -ge 75) { "yellow" } + else { "red" } + + @{schemaVersion=1; label="coverage"; message=$ts; color=$tsColor} | + ConvertTo-Json -Compress | Set-Content "badges/ts-coverage.json" + @{schemaVersion=1; label="coverage"; message=$cs; color=$csColor} | + ConvertTo-Json -Compress | Set-Content "badges/cs-coverage.json" + + Write-Host "Badges: TS=$ts ($tsColor), C#=$cs ($csColor)" + + # PR Comment + - name: Post Coverage Comment on PR + if: ${{ inputs.pr_number != '' && inputs.pr_number != '0' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: pwsh + run: | + $prNumber = "${{ inputs.pr_number }}" + $newTs = [double]"${{ steps.ts.outputs.pct }}" + $newCs = [double]"${{ steps.cs.outputs.pct }}" + + # Read previous badge values from the checked-out repo + $oldTs = 0.0 + $oldCs = 0.0 + if (Test-Path "badges/ts-coverage.json") { + $old = Get-Content "badges/ts-coverage.json" -Raw | ConvertFrom-Json + $oldTs = [double]($old.message -replace '%','') + } + if (Test-Path "badges/cs-coverage.json") { + $old = Get-Content "badges/cs-coverage.json" -Raw | ConvertFrom-Json + $oldCs = [double]($old.message -replace '%','') + } + + # Determine styling: green if improved, red if regressed, gray if unchanged + function Get-Trend([double]$old, [double]$new) { + if ($new -gt $old) { return @{ icon = "📈"; color = "green"; label = "improved" } } + if ($new -lt $old) { return @{ icon = "📉"; color = "red"; label = "regressed" } } + return @{ icon = "➡️"; color = "gray"; label = "unchanged" } + } + + $tsTrend = Get-Trend $oldTs $newTs + $csTrend = Get-Trend $oldCs $newCs + + $tsDelta = $newTs - $oldTs + $csDelta = $newCs - $oldCs + $tsDeltaStr = if ($tsDelta -gt 0) { "+$tsDelta" } else { "$tsDelta" } + $csDeltaStr = if ($csDelta -gt 0) { "+$csDelta" } else { "$csDelta" } + + $body = "## 📊 Code Coverage Report`n`n" + $body += "| Language | Coverage | Delta | Trend |`n" + $body += "|----------|----------|-------|-------|`n" + $body += "| TypeScript | **${newTs}%** | ${tsDeltaStr}% | $($tsTrend.icon) $($tsTrend.label) |`n" + $body += "| C# | **${newCs}%** | ${csDeltaStr}% | $($csTrend.icon) $($csTrend.label) |" + + # Find existing coverage comment to update (avoid spam) + $existingComment = $null + $comments = gh api "repos/${{ github.repository }}/issues/${prNumber}/comments" --paginate 2>$null | ConvertFrom-Json + if ($comments) { + $existingComment = $comments | Where-Object { $_.body -match "## 📊 Code Coverage Report" } | Select-Object -First 1 + } + + if ($existingComment) { + gh api "repos/${{ github.repository }}/issues/comments/$($existingComment.id)" -X PATCH -f body="$body" 2>$null + Write-Host "Updated existing coverage comment #$($existingComment.id) on PR #$prNumber" + } else { + gh api "repos/${{ github.repository }}/issues/${prNumber}/comments" -X POST -f body="$body" 2>$null + Write-Host "Posted new coverage comment on PR #$prNumber" + } + + - name: Commit Badge Updates + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add badges/ + git diff --staged --quiet || git commit -m "ci: update coverage badges" + git push origin HEAD:${{ inputs.badge_branch || github.ref_name }} || echo "No changes to push" diff --git a/.github/workflows/shared-testing-js.yml b/.github/workflows/shared-testing-js.yml index 01cd08b0a..8ed3d5f25 100644 --- a/.github/workflows/shared-testing-js.yml +++ b/.github/workflows/shared-testing-js.yml @@ -13,6 +13,11 @@ on: description: 'Commit SHA used for status/check updates' type: string required: true + enable_coverage: + description: 'Enable code coverage collection' + type: boolean + required: false + default: false permissions: contents: read @@ -21,7 +26,7 @@ permissions: jobs: js-validation: - name: CI Testing - JS Bundling + name: CI Testing - JS runs-on: ubuntu-latest env: TARGET_SHA: ${{ inputs.target_sha }} @@ -50,7 +55,21 @@ jobs: - name: Run unit tests shell: bash working-directory: src/InfiniFrame.Js - run: npm run test + run: | + if [ "${{ inputs.enable_coverage }}" = "true" ]; then + npm run test:coverage + else + npm run test + fi + + - name: Upload TS Coverage + if: ${{ inputs.enable_coverage == true && success() }} + uses: actions/upload-artifact@v7 + with: + name: ts-coverage + path: src/InfiniFrame.Js/coverage/lcov.info + if-no-files-found: warn + retention-days: 1 - name: Complete Js Check if: always() @@ -60,7 +79,7 @@ jobs: with: repo: ${{ github.repository }} sha: ${{ env.TARGET_SHA }} - context: CI Testing - Js Bundling + context: CI Testing - Js target-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} job-status: ${{ job.status }} success-description: Js validation finished successfully diff --git a/.github/workflows/shared-testing-linux.yml b/.github/workflows/shared-testing-linux.yml index 591f8039c..4238003b3 100644 --- a/.github/workflows/shared-testing-linux.yml +++ b/.github/workflows/shared-testing-linux.yml @@ -20,6 +20,10 @@ on: type: boolean required: false default: false + enable_coverage: + type: boolean + required: false + default: false jobs: linux: @@ -237,7 +241,6 @@ jobs: tests/InfiniTests.InfiniFrame/InfiniTests.InfiniFrame.csproj ) net10_test_projects=( - tests/InfiniTests.InfiniFrame.Tools.Pack/InfiniTests.InfiniFrame.Tools.Pack.csproj ) run_test() { @@ -246,17 +249,25 @@ jobs: local project for project in "${test_projects[@]}"; do echo "--- ${project} (${framework}) ---" - dotnet test "$project" \ - --configuration Release \ - --no-build \ - --no-restore \ - --framework "$framework" \ - -p:NativeArch=${{ matrix.arch }} \ - -p:InfiniFrameSkipNativeBuild=true \ - -p:InfiniFrameEnableTestExports=${{ inputs.enable_test_exports }} \ - -- \ - --no-ansi \ - --maximum-parallel-tests 1 || exit_code=$? + local test_args=( + "$project" + --configuration Release + --no-build + --no-restore + --framework "$framework" + -p:NativeArch=${{ matrix.arch }} + -p:InfiniFrameSkipNativeBuild=true + -p:InfiniFrameEnableTestExports=${{ inputs.enable_test_exports }} + -- + --no-ansi + --maximum-parallel-tests 1 + ) + + if [ "${{ inputs.enable_coverage }}" = "true" ] && [ "$framework" = "net10.0" ]; then + test_args+=(--coverage --coverage-output-format cobertura --results-directory TestResults) + fi + + dotnet test "${test_args[@]}" || exit_code=$? done if [ "$framework" = "net10.0" ]; then @@ -327,10 +338,19 @@ jobs: core* if-no-files-found: ignore + - name: Upload C# Coverage + if: ${{ inputs.enable_coverage == true && success() }} + uses: actions/upload-artifact@v7 + with: + name: cs-coverage-linux-${{ matrix.arch }}-${{ matrix.display_server }} + path: TestResults/**/*.cobertura.xml + if-no-files-found: warn + retention-days: 1 + - name: Pack Tool E2E uses: ./.github/actions/packtool-e2e with: - project: examples/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj + project: examples/SingleFileExe/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj rid: ${{ matrix.rid }} output: artifacts/pack-e2e/InfiniFrameExample.SingleFileExe main-output-name: InfiniFrameExample.SingleFileExe diff --git a/.github/workflows/shared-testing-macos.yml b/.github/workflows/shared-testing-macos.yml index 7428adbca..1334a08ae 100644 --- a/.github/workflows/shared-testing-macos.yml +++ b/.github/workflows/shared-testing-macos.yml @@ -20,6 +20,10 @@ on: type: boolean required: false default: false + enable_coverage: + type: boolean + required: false + default: false jobs: macos: @@ -126,20 +130,29 @@ jobs: framework_exit_code=0 echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Starting $framework" - dotnet test --solution InfiniFrame.GitHubActions.Testing.slnf \ - --configuration Release \ - --no-build \ - --no-restore \ - --framework "$framework" \ - -p:NativeArch=${{ matrix.arch }} \ - -p:InfiniFrameSkipNativeBuild=true \ - -p:InfiniFrameEnableTestExports=${{ inputs.enable_test_exports }} \ - --results-directory "artifacts/testresults/$framework" \ - --diagnostic \ - --diagnostic-output-directory "artifacts/testdiag/$framework" \ - --diagnostic-file-prefix "mtp-$framework" \ - --diagnostic-verbosity Warning \ - --no-ansi || framework_exit_code=$? + test_args=( + --solution InfiniFrame.GitHubActions.Testing.slnf + --configuration Release + --no-build + --no-restore + --framework "$framework" + -p:NativeArch=${{ matrix.arch }} + -p:InfiniFrameSkipNativeBuild=true + -p:InfiniFrameEnableTestExports=${{ inputs.enable_test_exports }} + --results-directory "artifacts/testresults/$framework" + --diagnostic-output-directory "artifacts/testdiag/$framework" + --no-ansi + -- + --diagnostic + --diagnostic-file-prefix "mtp-$framework" + --diagnostic-verbosity Warning + ) + + if [ "${{ inputs.enable_coverage }}" = "true" ] && [ "$framework" = "net10.0" ]; then + test_args+=(--coverage --coverage-output-format cobertura) + fi + + dotnet test "${test_args[@]}" || framework_exit_code=$? echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Finished $framework with exit code $framework_exit_code" @@ -171,10 +184,19 @@ jobs: artifacts/testresults/** TestResults/** + - name: Upload C# Coverage + if: ${{ inputs.enable_coverage == true && success() }} + uses: actions/upload-artifact@v7 + with: + name: cs-coverage-macos-${{ matrix.arch }} + path: artifacts/testresults/net10.0/**/*.cobertura.xml + if-no-files-found: warn + retention-days: 1 + - name: Pack Tool E2E uses: ./.github/actions/packtool-e2e with: - project: examples/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj + project: examples/SingleFileExe/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj rid: ${{ matrix.rid }} output: artifacts/pack-e2e/InfiniFrameExample.SingleFileExe main-output-name: InfiniFrameExample.SingleFileExe diff --git a/.github/workflows/shared-testing-windows.yml b/.github/workflows/shared-testing-windows.yml index a96846504..568c3eded 100644 --- a/.github/workflows/shared-testing-windows.yml +++ b/.github/workflows/shared-testing-windows.yml @@ -20,6 +20,10 @@ on: type: boolean required: false default: false + enable_coverage: + type: boolean + required: false + default: false jobs: windows: @@ -192,13 +196,22 @@ jobs: "/p:InfiniFrameEnableTestExports=${{ inputs.enable_test_exports }}" ) + if ("${{ inputs.enable_coverage }}" -eq "true" -and $framework -eq "net10.0") { + $testArgs += @( + "--coverage", + "--coverage-output-format", "cobertura", + "--results-directory", "TestResults" + ) + } + if ("${{ matrix.arch }}" -eq "arm64") { $frameworkDiagRoot = Join-Path $env:GITHUB_WORKSPACE "artifacts\testdiag\$framework" New-Item -ItemType Directory -Force -Path $frameworkDiagRoot | Out-Null $testArgs += @( + "--diagnostic-output-directory", $frameworkDiagRoot, + "--", "--diagnostic", - "--diagnostic-synchronous-write", - "--diagnostic-output-directory", $frameworkDiagRoot + "--diagnostic-synchronous-write" ) } @@ -208,6 +221,15 @@ jobs: exit $exitCode + - name: Upload C# Coverage + if: ${{ inputs.enable_coverage == true && success() }} + uses: actions/upload-artifact@v7 + with: + name: cs-coverage-windows-${{ matrix.arch }} + path: TestResults/**/*.cobertura.xml + if-no-files-found: warn + retention-days: 1 + - name: Collect ARM64 Windows Event Logs if: always() && matrix.arch == 'arm64' shell: pwsh @@ -246,7 +268,7 @@ jobs: - name: Pack Tool E2E uses: ./.github/actions/packtool-e2e with: - project: examples/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj + project: examples/SingleFileExe/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj rid: ${{ matrix.rid }} output: artifacts/pack-e2e/InfiniFrameExample.SingleFileExe main-output-name: InfiniFrameExample.SingleFileExe.exe diff --git a/.github/workflows/shared-testing.yml b/.github/workflows/shared-testing.yml index 62e56db21..a53c16402 100644 --- a/.github/workflows/shared-testing.yml +++ b/.github/workflows/shared-testing.yml @@ -45,6 +45,11 @@ on: type: boolean required: false default: false + enable_coverage: + description: 'Enable code coverage collection' + type: boolean + required: false + default: false jobs: @@ -94,6 +99,7 @@ jobs: with: checkout_ref: ${{ inputs.checkout_ref }} target_sha: ${{ needs.prepare.outputs.sha }} + enable_coverage: ${{ inputs.enable_coverage }} native-build: name: Build Native Artifacts @@ -125,6 +131,7 @@ jobs: target_sha: ${{ needs.prepare.outputs.sha }} workflow_url: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} enable_test_exports: ${{ inputs.enable_test_exports }} + enable_coverage: ${{ inputs.enable_coverage }} secrets: inherit macos: @@ -138,6 +145,7 @@ jobs: target_sha: ${{ needs.prepare.outputs.sha }} workflow_url: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} enable_test_exports: ${{ inputs.enable_test_exports }} + enable_coverage: ${{ inputs.enable_coverage }} secrets: inherit windows: @@ -151,6 +159,7 @@ jobs: target_sha: ${{ needs.prepare.outputs.sha }} workflow_url: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} enable_test_exports: ${{ inputs.enable_test_exports }} + enable_coverage: ${{ inputs.enable_coverage }} secrets: inherit windows-playwright: diff --git a/.gitignore b/.gitignore index 57e258714..cc19a4a45 100644 --- a/.gitignore +++ b/.gitignore @@ -375,6 +375,7 @@ healthchecksdb # Local test/runner virtualenv /.run/.venv/ +src/InfiniFrame.Js/coverage/ src/InfiniFrame.Js/wwwroot/InfiniFrame.js src/InfiniFrame.Js/wwwroot/InfiniFrame.dev.js src/InfiniFrame.Js/wwwroot/InfiniFrame.dev.js.map diff --git a/Directory.Packages.props b/Directory.Packages.props index 8cc478dc6..2e8c427b2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -28,20 +28,21 @@ + - - + + + + + + - - - - \ No newline at end of file diff --git a/InfiniFrame.GitHubActions.Release.slnf b/InfiniFrame.GitHubActions.Release.slnf index fa9085dd4..ca9e1db73 100644 --- a/InfiniFrame.GitHubActions.Release.slnf +++ b/InfiniFrame.GitHubActions.Release.slnf @@ -1,4 +1,4 @@ -{ +{ "solution": { "path": "InfiniFrame.slnx", "projects": [ @@ -8,7 +8,7 @@ "src\\InfiniFrame.Js\\InfiniFrame.Js.csproj", "src\\InfiniFrame.NativeBridge\\InfiniFrame.NativeBridge.csproj", "src\\InfiniFrame.Shared\\InfiniFrame.Shared.csproj", - "src\\InfiniFrame.Tools.Pack\\InfiniFrame.Tools.Pack.csproj", + "src\\InfiniFrame.SingleFile\\InfiniFrame.SingleFile.csproj", "src\\InfiniFrame.WebServer\\InfiniFrame.WebServer.csproj" ] } diff --git a/InfiniFrame.GitHubActions.Testing.Automation.slnf b/InfiniFrame.GitHubActions.Testing.Automation.slnf index e8ca09694..7d7f10570 100644 --- a/InfiniFrame.GitHubActions.Testing.Automation.slnf +++ b/InfiniFrame.GitHubActions.Testing.Automation.slnf @@ -8,7 +8,7 @@ "src\\InfiniFrame.Js\\InfiniFrame.Js.csproj", "src\\InfiniFrame.NativeBridge\\InfiniFrame.NativeBridge.csproj", "src\\InfiniFrame.Shared\\InfiniFrame.Shared.csproj", - "src\\InfiniFrame.Tools.Pack\\InfiniFrame.Tools.Pack.csproj", + "src\\InfiniFrame.SingleFile\\InfiniFrame.SingleFile.csproj", "src\\InfiniFrame.WebServer\\InfiniFrame.WebServer.csproj", "tests\\InfiniTests\\InfiniTests.csproj", diff --git a/InfiniFrame.GitHubActions.Testing.slnf b/InfiniFrame.GitHubActions.Testing.slnf index a811d7c2f..b70d50c2d 100644 --- a/InfiniFrame.GitHubActions.Testing.slnf +++ b/InfiniFrame.GitHubActions.Testing.slnf @@ -8,7 +8,7 @@ "src\\InfiniFrame.Js\\InfiniFrame.Js.csproj", "src\\InfiniFrame.NativeBridge\\InfiniFrame.NativeBridge.csproj", "src\\InfiniFrame.Shared\\InfiniFrame.Shared.csproj", - "src\\InfiniFrame.Tools.Pack\\InfiniFrame.Tools.Pack.csproj", + "src\\InfiniFrame.SingleFile\\InfiniFrame.SingleFile.csproj", "src\\InfiniFrame.WebServer\\InfiniFrame.WebServer.csproj", "tests\\InfiniTests\\InfiniTests.csproj", @@ -20,7 +20,6 @@ "tests\\InfiniTests.InfiniFrame.Shared\\InfiniTests.InfiniFrame.Shared.csproj", "tests\\InfiniTests.InfiniFrame.WebServer\\InfiniTests.InfiniFrame.WebServer.csproj", - "tests\\InfiniTests.InfiniFrame.Tools.Pack\\InfiniTests.InfiniFrame.Tools.Pack.csproj" ] } } diff --git a/InfiniFrame.GitHubActions.slnf b/InfiniFrame.GitHubActions.slnf index eeacdd8e0..ab83a35d1 100644 --- a/InfiniFrame.GitHubActions.slnf +++ b/InfiniFrame.GitHubActions.slnf @@ -8,7 +8,7 @@ "src\\InfiniFrame.Js\\InfiniFrame.Js.csproj", "src\\InfiniFrame.NativeBridge\\InfiniFrame.NativeBridge.csproj", "src\\InfiniFrame.Shared\\InfiniFrame.Shared.csproj", - "src\\InfiniFrame.Tools.Pack\\InfiniFrame.Tools.Pack.csproj", + "src\\InfiniFrame.SingleFile\\InfiniFrame.SingleFile.csproj", "src\\InfiniFrame.WebServer\\InfiniFrame.WebServer.csproj", "tests\\InfiniTests\\InfiniTests.csproj", @@ -20,7 +20,6 @@ "tests\\InfiniTests.InfiniFrame.Shared\\InfiniTests.InfiniFrame.Shared.csproj", "tests\\InfiniTests.InfiniFrame.WebServer\\InfiniTests.InfiniFrame.WebServer.csproj", - "tests\\InfiniTests.InfiniFrame.Tools.Pack\\InfiniTests.InfiniFrame.Tools.Pack.csproj", "tests\\InfiniAutomationTests\\InfiniAutomationTests.csproj", "tests\\InfiniAutomationTests.BlazorWebView.MudBlazor\\InfiniAutomationTests.BlazorWebView.MudBlazor.csproj", diff --git a/InfiniFrame.slnx b/InfiniFrame.slnx index 4d002517a..1be00245f 100644 --- a/InfiniFrame.slnx +++ b/InfiniFrame.slnx @@ -13,6 +13,10 @@ + + + + @@ -73,6 +77,7 @@ + @@ -100,15 +105,21 @@ - - - - - - + + + + + + + + + + + + @@ -116,15 +127,13 @@ + - - - @@ -148,7 +157,4 @@ - - - diff --git a/README.md b/README.md index b602acd46..2f9268479 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Supports **Windows** (WebView2), **Linux** (WebKit2GTK), and **macOS** (WKWebVie > original Photino authors [![CI: Platform Tests](https://github.com/InfiniLore/InfiniFrame/actions/workflows/ci-testing.yml/badge.svg)](https://github.com/InfiniLore/InfiniFrame/actions/workflows/ci-testing.yml) +![TypeScript Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/InfiniLore/InfiniFrame/refs/heads/coverage/badges/ts-coverage.json) +![C# Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/InfiniLore/InfiniFrame/refs/heads/coverage/badges/cs-coverage.json) ## Packages diff --git a/badges/cs-coverage.json b/badges/cs-coverage.json new file mode 100644 index 000000000..40cbad19a --- /dev/null +++ b/badges/cs-coverage.json @@ -0,0 +1 @@ +{"label":"coverage","message":"8.3%","schemaVersion":1,"color":"red"} diff --git a/badges/ts-coverage.json b/badges/ts-coverage.json new file mode 100644 index 000000000..d10f22e10 --- /dev/null +++ b/badges/ts-coverage.json @@ -0,0 +1 @@ +{"label":"coverage","message":"93.6%","schemaVersion":1,"color":"brightgreen"} diff --git a/docs/package-lock.json b/docs/package-lock.json index ed2a75800..87b6a9653 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -5323,26 +5323,6 @@ "@types/ms": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -6181,18 +6161,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8451,9 +8419,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.24.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", - "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -9371,12 +9339,6 @@ "tslib": "2" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause" - }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", @@ -10747,19 +10709,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/loader-utils": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", @@ -13273,6 +13222,95 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -18195,36 +18233,31 @@ } }, "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", "license": "MIT", "dependencies": { - "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" }, "bin": { "webpack": "bin/webpack.js" @@ -18476,22 +18509,10 @@ } }, "node_modules/webpack/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, "engines": { "node": ">= 0.6" } diff --git a/docs/package.json b/docs/package.json index 7ca17ec0e..8aba0ee2a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,10 +22,10 @@ "typescript": "^7.0.2" }, "overrides": { - "brace-expansion": "^5.0.8", - "webpack": "^5.105.4", + "brace-expansion": "^5.0.9", + "webpack": "^5.109.2", "serialize-javascript": "^7.1.0", - "uuid": "^14.0.0" + "uuid": "^14.0.1" }, "engines": { "node": ">=20.0" diff --git a/examples/Directory.Build.targets b/examples/Directory.Build.targets deleted file mode 100644 index fcf5ce63b..000000000 --- a/examples/Directory.Build.targets +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/examples/InfiniFrameExample.BlazorWebView/InfiniFrameExample.BlazorWebView.csproj b/examples/InfiniFrameExample.BlazorWebView/InfiniFrameExample.BlazorWebView.csproj index a9539c479..ad2ba120c 100644 --- a/examples/InfiniFrameExample.BlazorWebView/InfiniFrameExample.BlazorWebView.csproj +++ b/examples/InfiniFrameExample.BlazorWebView/InfiniFrameExample.BlazorWebView.csproj @@ -1,8 +1,14 @@ - - + + net10.0 WinExe + 14.0 + enable + enable true + false + true + ../../assets/favicon.ico @@ -17,4 +23,21 @@ + + + + wwwroot/favicon.ico + Always + + + + + + + + builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) + + diff --git a/examples/InfiniFrameExample.NativeMenu/InfiniFrameExample.NativeMenu.csproj b/examples/InfiniFrameExample.NativeMenu/InfiniFrameExample.NativeMenu.csproj index 52dd1afdb..8246a1a71 100644 --- a/examples/InfiniFrameExample.NativeMenu/InfiniFrameExample.NativeMenu.csproj +++ b/examples/InfiniFrameExample.NativeMenu/InfiniFrameExample.NativeMenu.csproj @@ -2,10 +2,13 @@ net10.0 Exe + 14.0 enable enable false + true true + ../../assets/favicon.ico @@ -16,4 +19,21 @@ + + + + wwwroot/favicon.ico + Always + + + + + + + + builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) + + diff --git a/examples/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj b/examples/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj deleted file mode 100644 index fe4a80b09..000000000 --- a/examples/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - net10.0 - Exe - enable - enable - - - - - - - - - builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) - - - diff --git a/examples/InfiniFrameExample.SingleFileExe/pack-single-file.ps1 b/examples/InfiniFrameExample.SingleFileExe/pack-single-file.ps1 deleted file mode 100644 index de86d7e12..000000000 --- a/examples/InfiniFrameExample.SingleFileExe/pack-single-file.ps1 +++ /dev/null @@ -1,35 +0,0 @@ -param( - [string]$Configuration = "Debug", - [string]$Framework = "net10.0", - [string]$Rid = "auto", - [bool]$SelfContained = $true -) - -$ErrorActionPreference = "Stop" - -$projectDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$projectPath = Join-Path $projectDir "InfiniFrameExample.SingleFileExe.csproj" -$repoPackProject = Join-Path $projectDir "..\..\src\InfiniFrame.Tools.Pack\InfiniFrame.Tools.Pack.csproj" -$localToolExe = Join-Path $HOME ".dotnet\tools\infiniframe-pack.exe" - -if (Test-Path $repoPackProject) { - $packCommand = @("dotnet", "run", "--project", $repoPackProject, "--") -} -elseif (Test-Path $localToolExe) { - $packCommand = @($localToolExe) -} -else { - $packCommand = @("infiniframe-pack") -} - -$publishArgs = @( - "publish", - $projectPath, - "--rid", $Rid, - "--configuration", $Configuration, - "--framework", $Framework, - "--self-contained", $SelfContained.ToString().ToLowerInvariant() -) - -$packPrefix = if ($packCommand.Length -gt 1) { $packCommand[1..($packCommand.Length - 1)] } else { @() } -& $packCommand[0] ($packPrefix + $publishArgs) diff --git a/examples/InfiniFrameExample.TrimAotSmoke/InfiniFrameExample.TrimAotSmoke.csproj b/examples/InfiniFrameExample.TrimAotSmoke/InfiniFrameExample.TrimAotSmoke.csproj index 25225135d..7ce940760 100644 --- a/examples/InfiniFrameExample.TrimAotSmoke/InfiniFrameExample.TrimAotSmoke.csproj +++ b/examples/InfiniFrameExample.TrimAotSmoke/InfiniFrameExample.TrimAotSmoke.csproj @@ -1,14 +1,16 @@ - Exe net10.0 + Exe + 14.0 enable enable false - + true true win-x64 true + ../../assets/favicon.ico @@ -19,4 +21,21 @@ + + + + wwwroot/favicon.ico + Always + + + + + + + + builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) + + diff --git a/examples/InfiniFrameExample.WebApp.Blazor/InfiniFrameExample.WebApp.Blazor.csproj b/examples/InfiniFrameExample.WebApp.Blazor/InfiniFrameExample.WebApp.Blazor.csproj deleted file mode 100644 index 03398d293..000000000 --- a/examples/InfiniFrameExample.WebApp.Blazor/InfiniFrameExample.WebApp.Blazor.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - Always - - - - - - - - - - - diff --git a/examples/InfiniFrameExample.WebApp/InfiniFrameExample.WebApp.csproj b/examples/InfiniFrameExample.WebApp/InfiniFrameExample.WebApp.csproj deleted file mode 100644 index d55d30365..000000000 --- a/examples/InfiniFrameExample.WebApp/InfiniFrameExample.WebApp.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - Always - - - - - - - - diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/App.razor b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/App.razor new file mode 100644 index 000000000..bc8e178c9 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/App.razor @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Layouts/MainLayout.razor b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Layouts/MainLayout.razor new file mode 100644 index 000000000..621bdf96b --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Layouts/MainLayout.razor @@ -0,0 +1,29 @@ +@using global::MudBlazor +@inherits LayoutComponentBase + + + + + + + + + + + + @Body + + + + + +@code { + + private bool _drawerOpen = true; + + private void ToggleDrawer() + { + _drawerOpen = !_drawerOpen; + } +} diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Layouts/NavMenu.razor b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Layouts/NavMenu.razor new file mode 100644 index 000000000..7bd39cde1 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Layouts/NavMenu.razor @@ -0,0 +1,9 @@ +@using global::MudBlazor + + + Home + + + Counter + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/Counter.razor b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/Counter.razor new file mode 100644 index 000000000..c7e063b97 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/Counter.razor @@ -0,0 +1,49 @@ +@page "/counter" +@using global::MudBlazor +@inject IInfiniFrameWindow Window +@inject ILogger Logger + +Counter + + + Counter + + + @_currentCount + + + + + Click me + + + Reset + + + + @if (_lastAction is not null) + { + + @_lastAction + + } + + +@code { + private int _currentCount; + private string? _lastAction; + + private void IncrementCount() + { + _currentCount++; + Window.Features.Position.CenterOnCurrentMonitor(); + Logger.LogWarning("Count: {Count}, Zoom enabled: {Zoom}", _currentCount, Window.Features.State.IsZoomEnabled); + _lastAction = $"Clicked {_currentCount} time(s). Window centered on monitor."; + } + + private void ResetCount() + { + _currentCount = 0; + _lastAction = "Counter reset."; + } +} diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/Index.razor b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/Index.razor new file mode 100644 index 000000000..3c4292423 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/Index.razor @@ -0,0 +1,23 @@ +@page "/" +@using global::MudBlazor + +Home + + + Hello, world! + + + Welcome to your new MudBlazor + Tailwind CSS app running in InfiniFrame. + + + + + + + Go to Counter + + + Fetch Data + + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/PageNotFound.razor b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/PageNotFound.razor new file mode 100644 index 000000000..52d1a4d98 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/Pages/PageNotFound.razor @@ -0,0 +1,16 @@ +@page "/PageNotFound" +@using global::MudBlazor +@layout MainLayout + +Page Not Found + + + + 404 + + Sorry, there's nothing at this address. + + + Go Home + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/_Imports.razor b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/_Imports.razor new file mode 100644 index 000000000..ababbfc19 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Components/_Imports.razor @@ -0,0 +1,15 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Microsoft.Extensions.Logging +@using MudBlazor +@using InfiniFrame +@using InfiniFrame.Blazor +@using InfiniFrameExample.SingleFileExe.MudBlazor.Components +@using InfiniFrameExample.SingleFileExe.MudBlazor.Components.Layouts +@using InfiniFrameExample.SingleFileExe.MudBlazor.Components.Pages diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/InfiniFrameExample.SingleFileExe.MudBlazor.csproj b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/InfiniFrameExample.SingleFileExe.MudBlazor.csproj new file mode 100644 index 000000000..cb7448412 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/InfiniFrameExample.SingleFileExe.MudBlazor.csproj @@ -0,0 +1,49 @@ + + + net10.0 + Exe + 14.0 + enable + enable + false + true + ../../../assets/favicon.ico + false + true + win-x64 + + + + + + + + + + + + + + + + + + + wwwroot/favicon.ico + Always + + + + + + builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Program.cs b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Program.cs new file mode 100644 index 000000000..64c9180c4 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/Program.cs @@ -0,0 +1,62 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrame.BlazorWebView; +using InfiniFrame.SingleFile; +using InfiniFrameExample.SingleFileExe.MudBlazor.Components; +using MudBlazor.Services; +using Serilog; + +namespace InfiniFrameExample.SingleFileExe.MudBlazor; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public static class Program { + [STAThread] + private static void Main(string[] args) { + InfiniFrameSingleFile.Initialize(); + + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Async(c => c.Console()) + .CreateLogger(); + + try { + Log.Information("Starting InfiniFrame MudBlazor example..."); + + var appBuilder = InfiniFrameBlazorAppBuilder.CreateDefault(args); + + appBuilder.Services + .AddLogging(config => { + config.ClearProviders(); + config.AddSerilog(); + }) + .AddSerilog(config => { + config.WriteTo.Async(static c => c.Console()) + .MinimumLevel.Debug(); + }) + .AddMudServices(); + + appBuilder.RootComponents.Add("app"); + + appBuilder.WindowBuilder + .SetIconFile("wwwroot/favicon.ico") + .RegisterOpenExternalTargetWebMessageHandler(); + + InfiniFrameSingleFile.AttachWithBlazor(appBuilder.WindowBuilder); + + Log.Information("Building InfiniFrame application..."); + InfiniFrameBlazorApp application = appBuilder.Build(); + + Log.Information("Running application..."); + application.Run(); + } + catch (Exception ex) { + Log.Fatal(ex, "Application terminated unexpectedly"); + } + finally { + Log.CloseAndFlush(); + } + } +} diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/wwwroot/index.html b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/wwwroot/index.html new file mode 100644 index 000000000..372255fce --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.MudBlazor/wwwroot/index.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + +Loading... + +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/InfiniFrameExample.SingleFileExe.React.csproj b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/InfiniFrameExample.SingleFileExe.React.csproj new file mode 100644 index 000000000..70a8d7eaf --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/InfiniFrameExample.SingleFileExe.React.csproj @@ -0,0 +1,41 @@ + + + net10.0 + Exe + 14.0 + enable + enable + false + true + ../../../assets/favicon.ico + false + + + + + + + + + wwwroot/favicon.ico + Always + + + + + + builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + true + win-x64 + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/Program.cs b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/Program.cs new file mode 100644 index 000000000..913923bae --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/Program.cs @@ -0,0 +1,22 @@ +using InfiniFrame; +using System.Drawing; +using InfiniFrame.SingleFile; + +namespace InfiniFrameExample.SingleFileExe.React; + +public static class Program { + [STAThread] + public static void Main(string[] args) { + InfiniFrameSingleFile.Initialize(); + + IInfiniFrameWindowBuilder builder = InfiniFrameWindowBuilder.Create() + .SetTitle("InfiniFrame + React") + .SetSize(new Size(960, 640)) + .CenteredOnMainMonitor(); + + InfiniFrameSingleFile.AttachWithStaticWwwroot(builder); + + IInfiniFrameWindow window = builder.Build(); + window.WaitForClose(); + } +} diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/app.css b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/app.css new file mode 100644 index 000000000..91a3ef44e --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/app.css @@ -0,0 +1,10 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; display: flex; justify-content: center; align-items: center; min-height: 100vh; } +.app { text-align: center; } +h1 { font-size: 2.5rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, #61dafb, #42b883); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } +.subtitle { color: #888; margin-bottom: 2rem; } +.card { background: #16213e; border-radius: 12px; padding: 2rem; margin: 1rem auto; max-width: 400px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); } +button { font-size: 1.5rem; padding: 1rem 2rem; border: none; border-radius: 8px; background: #61dafb; color: #1a1a2e; cursor: pointer; font-weight: bold; transition: transform 0.1s, background 0.2s; } +button:hover { background: #4fa8d9; } +button:active { transform: scale(0.95); } +.info { color: #666; margin-top: 2rem; font-size: 0.9rem; } diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/app.jsx b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/app.jsx new file mode 100644 index 000000000..1561efda9 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/app.jsx @@ -0,0 +1,22 @@ +const { useState } = React; + +function App() { + const [count, setCount] = useState(0); + + return ( +
+

InfiniFrame + React

+

Single-file executable with embedded React app

+
+ +
+

+ This React app runs from an embedded resource inside a single .exe file. +

+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/index.html b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/index.html new file mode 100644 index 000000000..b7a2f4465 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.React/wwwroot/index.html @@ -0,0 +1,16 @@ + + + + + + InfiniFrame + React + + + + + + +
+ + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/InfiniFrameExample.SingleFileExe.Vue.csproj b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/InfiniFrameExample.SingleFileExe.Vue.csproj new file mode 100644 index 000000000..98f0e6b57 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/InfiniFrameExample.SingleFileExe.Vue.csproj @@ -0,0 +1,41 @@ + + + net10.0 + Exe + 14.0 + enable + enable + false + true + ../../../assets/favicon.ico + false + + + + + + + + + wwwroot/favicon.ico + Always + + + + + + builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + true + win-x64 + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/Program.cs b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/Program.cs new file mode 100644 index 000000000..054d2e9c6 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/Program.cs @@ -0,0 +1,22 @@ +using InfiniFrame; +using System.Drawing; +using InfiniFrame.SingleFile; + +namespace InfiniFrameExample.SingleFileExe.Vue; + +public static class Program { + [STAThread] + public static void Main(string[] args) { + InfiniFrameSingleFile.Initialize(); + + IInfiniFrameWindowBuilder builder = InfiniFrameWindowBuilder.Create() + .SetTitle("InfiniFrame + Vue") + .SetSize(new Size(960, 640)) + .CenteredOnMainMonitor(); + + InfiniFrameSingleFile.AttachWithStaticWwwroot(builder); + + IInfiniFrameWindow window = builder.Build(); + window.WaitForClose(); + } +} diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/app.css b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/app.css new file mode 100644 index 000000000..97aab93b0 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/app.css @@ -0,0 +1,10 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #eee; display: flex; justify-content: center; align-items: center; min-height: 100vh; } +#app { text-align: center; } +h1 { font-size: 2.5rem; margin-bottom: 0.5rem; background: linear-gradient(135deg, #42b883, #35495e); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } +.subtitle { color: #888; margin-bottom: 2rem; } +.card { background: #16213e; border-radius: 12px; padding: 2rem; margin: 1rem auto; max-width: 400px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); } +button { font-size: 1.5rem; padding: 1rem 2rem; border: none; border-radius: 8px; background: #42b883; color: #fff; cursor: pointer; font-weight: bold; transition: transform 0.1s, background 0.2s; } +button:hover { background: #369970; } +button:active { transform: scale(0.95); } +.info { color: #666; margin-top: 2rem; font-size: 0.9rem; } diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/app.js b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/app.js new file mode 100644 index 000000000..17a70d105 --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/app.js @@ -0,0 +1,10 @@ +const { createApp } = Vue; + +createApp({ + data() { + return { + title: 'InfiniFrame + Vue', + count: 0 + }; + } +}).mount('#app'); diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/index.html b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/index.html new file mode 100644 index 000000000..4aa2ba4ac --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe.Vue/wwwroot/index.html @@ -0,0 +1,25 @@ + + + + + + InfiniFrame + Vue + + + +
+

{{ title }}

+

Single-file executable with embedded Vue app

+
+ +
+

+ This Vue app runs from an embedded resource inside a single .exe file. +

+
+ + + + diff --git a/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj new file mode 100644 index 000000000..70a8d7eaf --- /dev/null +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/InfiniFrameExample.SingleFileExe.csproj @@ -0,0 +1,41 @@ + + + net10.0 + Exe + 14.0 + enable + enable + false + true + ../../../assets/favicon.ico + false + + + + + + + + + wwwroot/favicon.ico + Always + + + + + + builds/$(Configuration)/$(TargetFramework)/%(RecursiveDir)%(Filename)%(Extension) + + + + + + + + true + win-x64 + + diff --git a/examples/InfiniFrameExample.SingleFileExe/Program.cs b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/Program.cs similarity index 65% rename from examples/InfiniFrameExample.SingleFileExe/Program.cs rename to examples/SingleFileExe/InfiniFrameExample.SingleFileExe/Program.cs index 55a0927f5..f63fa2b62 100644 --- a/examples/InfiniFrameExample.SingleFileExe/Program.cs +++ b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/Program.cs @@ -3,6 +3,7 @@ // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; using System.Drawing; +using InfiniFrame.SingleFile; namespace InfiniFrameExample.SingleFileExe; // --------------------------------------------------------------------------------------------------------------------- @@ -11,20 +12,17 @@ namespace InfiniFrameExample.SingleFileExe; public static class Program { [STAThread] public static void Main(string[] args) { - InfiniFrameSingleFileBootstrap.Initialize(); + InfiniFrameSingleFile.Initialize(); - IInfiniFrameWindow window = InfiniFrameWindowBuilder.Create() + IInfiniFrameWindowBuilder builder = InfiniFrameWindowBuilder.Create() .SetTitle("InfiniFrame Embedded wwwroot") .SetSize(new Size(960, 640)) - .CenteredOnMainMonitor() - .UseEmbeddedWwwrootAssets( - scheme: "app", - includePhysicalFallback: true, - physicalWwwrootPath: Path.Join(AppContext.BaseDirectory, "wwwroot"), - setStartUrl: true - ) - .Build(); + .CenteredOnMainMonitor(); + + InfiniFrameSingleFile.AttachWithStaticWwwroot(builder); + + IInfiniFrameWindow window = builder.Build(); window.WaitForClose(); } -} \ No newline at end of file +} diff --git a/examples/InfiniFrameExample.SingleFileExe/wwwroot/app.css b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/wwwroot/app.css similarity index 100% rename from examples/InfiniFrameExample.SingleFileExe/wwwroot/app.css rename to examples/SingleFileExe/InfiniFrameExample.SingleFileExe/wwwroot/app.css diff --git a/examples/InfiniFrameExample.SingleFileExe/wwwroot/app.js b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/wwwroot/app.js similarity index 100% rename from examples/InfiniFrameExample.SingleFileExe/wwwroot/app.js rename to examples/SingleFileExe/InfiniFrameExample.SingleFileExe/wwwroot/app.js diff --git a/examples/InfiniFrameExample.SingleFileExe/wwwroot/index.html b/examples/SingleFileExe/InfiniFrameExample.SingleFileExe/wwwroot/index.html similarity index 100% rename from examples/InfiniFrameExample.SingleFileExe/wwwroot/index.html rename to examples/SingleFileExe/InfiniFrameExample.SingleFileExe/wwwroot/index.html diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/App.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/App.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/App.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/App.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/Layouts/MainLayout.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Layouts/MainLayout.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/Layouts/MainLayout.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Layouts/MainLayout.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/Layouts/NavMenu.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Layouts/NavMenu.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/Layouts/NavMenu.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Layouts/NavMenu.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/Counter.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/Counter.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/Counter.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/Counter.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/FetchData.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/FetchData.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/FetchData.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/FetchData.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/Index.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/Index.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/Index.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/Index.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/PageNotFound.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/PageNotFound.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/Pages/PageNotFound.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Pages/PageNotFound.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/Routes.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Routes.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/Routes.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/Routes.razor diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Components/_Imports.razor b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/_Imports.razor similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Components/_Imports.razor rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Components/_Imports.razor diff --git a/examples/WebApp/InfiniFrameExample.WebApp.Blazor/InfiniFrameExample.WebApp.Blazor.csproj b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/InfiniFrameExample.WebApp.Blazor.csproj new file mode 100644 index 000000000..3d7351f37 --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/InfiniFrameExample.WebApp.Blazor.csproj @@ -0,0 +1,26 @@ + + + net10.0 + Exe + 14.0 + enable + enable + false + true + ../../../assets/favicon.ico + + + + + + + + + + + + + + + + diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Program.cs b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Program.cs similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Program.cs rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Program.cs diff --git a/examples/InfiniFrameExample.WebApp.Blazor/Properties/launchSettings.json b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/Properties/launchSettings.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/Properties/launchSettings.json rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/Properties/launchSettings.json diff --git a/examples/InfiniFrameExample.WebApp.Blazor/README.md b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/README.md similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/README.md rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/README.md diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/app.css b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/app.css similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/app.css rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/app.css diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css.map b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css.map similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css.map rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/bootstrap/bootstrap.min.css.map diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/FONT-LICENSE b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/FONT-LICENSE similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/FONT-LICENSE rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/FONT-LICENSE diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/ICON-LICENSE b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/ICON-LICENSE similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/ICON-LICENSE rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/ICON-LICENSE diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/README.md b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/README.md similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/README.md rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/README.md diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.eot b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.eot similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.eot rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.eot diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.otf b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.otf similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.otf rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.otf diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.svg b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.svg similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.svg rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.svg diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.woff b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.woff similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.woff rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/css/open-iconic/font/fonts/open-iconic.woff diff --git a/examples/InfiniFrameExample.WebApp.Blazor/wwwroot/sample-data/weather.json b/examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/sample-data/weather.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Blazor/wwwroot/sample-data/weather.json rename to examples/WebApp/InfiniFrameExample.WebApp.Blazor/wwwroot/sample-data/weather.json diff --git a/examples/InfiniFrameExample.WebApp.React/InfiniFrameExample.WebApp.React.csproj b/examples/WebApp/InfiniFrameExample.WebApp.React/InfiniFrameExample.WebApp.React.csproj similarity index 61% rename from examples/InfiniFrameExample.WebApp.React/InfiniFrameExample.WebApp.React.csproj rename to examples/WebApp/InfiniFrameExample.WebApp.React/InfiniFrameExample.WebApp.React.csproj index cdf1aede3..475d54a43 100644 --- a/examples/InfiniFrameExample.WebApp.React/InfiniFrameExample.WebApp.React.csproj +++ b/examples/WebApp/InfiniFrameExample.WebApp.React/InfiniFrameExample.WebApp.React.csproj @@ -1,13 +1,20 @@ - - + + net10.0 + Exe + 14.0 + enable + enable + false + true + ../../../assets/favicon.ico $(MSBuildProjectDirectory)/Source/InfiniFrame.React $(FrontendDirectory)/package-lock.json - - + + @@ -28,4 +35,5 @@ + diff --git a/examples/InfiniFrameExample.WebApp.React/Program.cs b/examples/WebApp/InfiniFrameExample.WebApp.React/Program.cs similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Program.cs rename to examples/WebApp/InfiniFrameExample.WebApp.React/Program.cs diff --git a/examples/InfiniFrameExample.WebApp.React/Properties/launchSettings.json b/examples/WebApp/InfiniFrameExample.WebApp.React/Properties/launchSettings.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Properties/launchSettings.json rename to examples/WebApp/InfiniFrameExample.WebApp.React/Properties/launchSettings.json diff --git a/examples/InfiniFrameExample.WebApp.React/README.md b/examples/WebApp/InfiniFrameExample.WebApp.React/README.md similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/README.md rename to examples/WebApp/InfiniFrameExample.WebApp.React/README.md diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/.gitignore b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/.gitignore similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/.gitignore rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/.gitignore diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/README.md b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/README.md similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/README.md rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/README.md diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/eslint.config.js b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/eslint.config.js similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/eslint.config.js rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/eslint.config.js diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/index.html b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/index.html similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/index.html rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/index.html diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package-lock.json b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package-lock.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package-lock.json rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package-lock.json diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package.json b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package.json rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/package.json diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/public/vite.svg b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/public/vite.svg similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/public/vite.svg rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/public/vite.svg diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.css b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.css similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.css rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.css diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.tsx b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.tsx similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.tsx rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/App.tsx diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/assets/react.svg b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/assets/react.svg similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/assets/react.svg rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/assets/react.svg diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/index.css b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/index.css similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/index.css rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/index.css diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/main.tsx b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/main.tsx similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/main.tsx rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/main.tsx diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/vite-env.d.ts b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/vite-env.d.ts similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/vite-env.d.ts rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/src/vite-env.d.ts diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.app.json b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.app.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.app.json rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.app.json diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.json b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.json rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.json diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.node.json b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.node.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.node.json rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/tsconfig.node.json diff --git a/examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/vite.config.ts b/examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/vite.config.ts similarity index 100% rename from examples/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/vite.config.ts rename to examples/WebApp/InfiniFrameExample.WebApp.React/Source/InfiniFrame.React/vite.config.ts diff --git a/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/index-DvYrboBt.js b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/index-DvYrboBt.js new file mode 100644 index 000000000..c125b944e --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/index-DvYrboBt.js @@ -0,0 +1,9 @@ +var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports);(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var t=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function te(){}var S={H:null,A:null,T:null,S:null},ne=Object.prototype.hasOwnProperty;function re(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function ie(e,t){return re(e.type,t,e.props)}function C(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ae(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var oe=/\/+/g;function se(e,t){return typeof e==`object`&&e&&e.key!=null?ae(``+e.key):t.toString(36)}function ce(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(te,te):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function le(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,le(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+se(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(oe,`$&/`)+`/`),le(o,r,i,``,function(e){return e})):o!=null&&(C(o)&&(o=ie(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(oe,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{n.exports=t()})),r=e((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m){if(n(c)!==null)m=!0,ee||(ee=!0,C());else{var t=n(l);t!==null&&se(x,t.startTime-e)}}}var ee=!1,te=-1,S=5,ne=-1;function re(){return g?!0:!(e.unstable_now()-net&&re());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&se(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}}}finally{i?C():ee=!1}}}var C;if(typeof y==`function`)C=function(){y(ie)};else if(typeof MessageChannel<`u`){var ae=new MessageChannel,oe=ae.port2;ae.port1.onmessage=ie,C=function(){oe.postMessage(null)}}else C=function(){_(ie,0)};function se(t,n){te=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(te),te=-1):h=!0,se(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,C()))),r},e.unstable_shouldYield=re,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),i=e(((e,t)=>{t.exports=r()})),a=e((e=>{var t=n();function r(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=a()})),s=e((e=>{var t=i(),r=n(),a=o();function s(e){var t=`https://react.dev/errors/`+e;if(1me||(e.current=pe[me],pe[me]=null,me--)}function D(e,t){me++,pe[me]=e.current,e.current=t}var ge=he(null),_e=he(null),ve=he(null),ye=he(null);function be(e,t){switch(D(ve,t),D(_e,e),D(ge,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}E(ge),D(ge,e)}function xe(){E(ge),E(_e),E(ve)}function Se(e){e.memoizedState!==null&&D(ye,e);var t=ge.current,n=Hd(t,e.type);t!==n&&(D(_e,e),D(ge,n))}function Ce(e){_e.current===e&&(E(ge),E(_e)),ye.current===e&&(E(ye),Qf._currentValue=fe)}var we,Te;function Ee(e){if(we===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);we=t&&t[1]||``,Te=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{De=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?Ee(n):``}function ke(e,t){switch(e.tag){case 26:case 27:case 5:return Ee(e.type);case 16:return Ee(`Lazy`);case 13:return e.child!==t&&t!==null?Ee(`Suspense Fallback`):Ee(`Suspense`);case 19:return Ee(`SuspenseList`);case 0:case 15:return Oe(e.type,!1);case 11:return Oe(e.type.render,!1);case 1:return Oe(e.type,!0);case 31:return Ee(`Activity`);default:return``}}function Ae(e){try{var t=``,n=null;do t+=ke(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var je=Object.prototype.hasOwnProperty,Me=t.unstable_scheduleCallback,Ne=t.unstable_cancelCallback,Pe=t.unstable_shouldYield,Fe=t.unstable_requestPaint,Ie=t.unstable_now,Le=t.unstable_getCurrentPriorityLevel,Re=t.unstable_ImmediatePriority,ze=t.unstable_UserBlockingPriority,Be=t.unstable_NormalPriority,Ve=t.unstable_LowPriority,He=t.unstable_IdlePriority,Ue=t.log,We=t.unstable_setDisableYieldValue,Ge=null,Ke=null;function qe(e){if(typeof Ue==`function`&&We(e),Ke&&typeof Ke.setStrictMode==`function`)try{Ke.setStrictMode(Ge,e)}catch{}}var Je=Math.clz32?Math.clz32:Ze,Ye=Math.log,Xe=Math.LN2;function Ze(e){return e>>>=0,e===0?32:31-(Ye(e)/Xe|0)|0}var Qe=256,$e=262144,et=4194304;function tt(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function nt(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=tt(n))):i=tt(o):i=tt(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=tt(n))):i=tt(o)):i=tt(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function rt(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function it(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function at(){var e=et;return et<<=1,!(et&62914560)&&(et=4194304),e}function ot(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function st(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function ct(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),bn=!1;if(yn)try{var xn={};Object.defineProperty(xn,"passive",{get:function(){bn=!0}}),window.addEventListener(`test`,xn,xn),window.removeEventListener(`test`,xn,xn)}catch{bn=!1}var Sn=null,Cn=null,wn=null;function Tn(){if(wn)return wn;var e,t=Cn,n=t.length,r,i=`value`in Sn?Sn.value:Sn.textContent,a=i.length;for(e=0;e=rr),or=` `,sr=!1;function cr(e,t){switch(e){case`keyup`:return tr.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function lr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var ur=!1;function dr(e,t){switch(e){case`compositionend`:return lr(t);case`keypress`:return t.which===32?(sr=!0,or):null;case`textInput`:return e=t.data,e===or&&sr?null:e;default:return null}}function fr(e,t){if(ur)return e===`compositionend`||!nr&&cr(e,t)?(e=Tn(),wn=Cn=Sn=null,ur=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Pr(n)}}function Ir(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ir(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Lr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Kt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Kt(e.document)}return t}function Rr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var zr=yn&&`documentMode`in document&&11>=document.documentMode,Br=null,Vr=null,Hr=null,Ur=!1;function Wr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ur||Br==null||Br!==Kt(r)||(r=Br,`selectionStart`in r&&Rr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Hr&&Nr(Hr,r)||(Hr=r,r=Ed(Vr,`onSelect`),0>=o,i-=o,Ii=1<<32-Je(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),o=a(_,o,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),A&&Ri(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(i,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(i,h),o=a(y,o,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(i,h),A&&Ri(i,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(i,v.value,l),v!==null&&(o=a(v,o,g),d===null?u=v:d.sibling=v,d=v);return A&&Ri(i,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,i,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),o=a(v,o,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(i,e)}),A&&Ri(i,g),u}function b(e,r,a,c){if(typeof a==`object`&&a&&a.type===y&&a.key===null&&(a=a.props.children),typeof a==`object`&&a){switch(a.$$typeof){case _:a:{for(var l=a.key;r!==null;){if(r.key===l){if(l=a.type,l===y){if(r.tag===7){n(e,r.sibling),c=i(r,a.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===C&&Fa(l)===r.type){n(e,r.sibling),c=i(r,a.props),Ha(c,a),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}a.type===y?(c=Ci(a.props.children,e.mode,c,a.key),c.return=e,e=c):(c=Si(a.type,a.key,a.props,null,e.mode,c),Ha(c,a),c.return=e,e=c)}return o(e);case v:a:{for(l=a.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===a.containerInfo&&r.stateNode.implementation===a.implementation){n(e,r.sibling),c=i(r,a.children||[]),c.return=e,e=c;break a}n(e,r);break}t(e,r),r=r.sibling}c=Ei(a,e.mode,c),c.return=e,e=c}return o(e);case C:return a=Fa(a),b(e,r,a,c)}if(de(a))return h(e,r,a,c);if(ce(a)){if(l=ce(a),typeof l!=`function`)throw Error(s(150));return a=l.call(a),g(e,r,a,c)}if(typeof a.then==`function`)return b(e,r,Va(a),c);if(a.$$typeof===te)return b(e,r,ua(e,a),c);Ua(e,a)}return typeof a==`string`&&a!==``||typeof a==`number`||typeof a==`bigint`?(a=``+a,r!==null&&r.tag===6?(n(e,r.sibling),c=i(r,a),c.return=e,e=c):(n(e,r),c=wi(a,e.mode,c),c.return=e,e=c),o(e)):n(e,r)}return function(e,t,n,r){try{Ba=0;var i=b(e,t,n,r);return za=null,i}catch(t){if(t===ka||t===ja)throw t;var a=vi(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Ga=Wa(!0),Ka=Wa(!1),qa=!1;function Ja(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ya(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Xa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Za(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,W&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=hi(e),mi(e,null,n),t}return di(e,r,t,n),hi(e)}function Qa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ut(e,n)}}function $a(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var eo=!1;function to(){if(eo){var e=ba;if(e!==null)throw e}}function no(e,t,n,r){eo=!1;var i=e.updateQueue;qa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(q&f)===f:(r&f)===f){f!==0&&f===ya&&(eo=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:qa=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Kl|=o,e.lanes=o,e.memoizedState=d}}function ro(e,t){if(typeof e!=`function`)throw Error(s(191,e));e.call(t)}function io(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=w.T,s={};w.T=s,zs(e,!1,t,n);try{var c=i(),l=w.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Rs(e,t,Ca(c,r),pu(e)):Rs(e,t,r,pu(e))}catch(n){Rs(e,t,{then:function(){},status:`rejected`,reason:n},pu())}finally{T.p=a,o!==null&&s.types!==null&&(o.types=s.types),w.T=o}}function Os(){}function ks(e,t,n,r){if(e.tag!==5)throw Error(s(476));var i=As(e).queue;Ds(e,i,t,fe,n===null?Os:function(){return js(e),n(r)})}function As(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:fe,baseState:fe,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:fe},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Bo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function js(e){var t=As(e);t.next===null&&(t=e.alternate.memoizedState),Rs(e,t.next.queue,{},pu())}function Ms(){return j(Qf)}function Ns(){return R().memoizedState}function Ps(){return R().memoizedState}function Fs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=pu();e=Xa(n);var r=Za(t,e,n);r!==null&&(hu(r,t,n),Qa(r,t,n)),t={cache:ha()},e.payload=t;return}t=t.return}}function Is(e,t,n){var r=pu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Bs(e)?Vs(t,n):(n=fi(e,t,n,r),n!==null&&(hu(n,e,r),Hs(n,t,r)))}function Ls(e,t,n){Rs(e,t,n,pu())}function Rs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Bs(e))Vs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Mr(s,o))return di(e,t,i,0),G===null&&ui(),!1}catch{}if(n=fi(e,t,i,r),n!==null)return hu(n,e,r),Hs(n,t,r),!0}return!1}function zs(e,t,n,r){if(r={lane:2,revertLane:dd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Bs(e)){if(t)throw Error(s(479))}else t=fi(e,n,r,2),t!==null&&hu(t,e,2)}function Bs(e){var t=e.alternate;return e===P||t!==null&&t===P}function Vs(e,t){xo=bo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Hs(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ut(e,n)}}var Us={readContext:j,use:Ro,useCallback:L,useContext:L,useEffect:L,useImperativeHandle:L,useLayoutEffect:L,useInsertionEffect:L,useMemo:L,useReducer:L,useRef:L,useState:L,useDebugValue:L,useDeferredValue:L,useTransition:L,useSyncExternalStore:L,useId:L,useHostTransitionStatus:L,useFormState:L,useActionState:L,useOptimistic:L,useMemoCache:L,useCacheRefresh:L};Us.useEffectEvent=L;var Ws={readContext:j,use:Ro,useCallback:function(e,t){return Fo().memoizedState=[e,t===void 0?null:t],e},useContext:j,useEffect:ms,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),fs(4194308,4,bs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return fs(4194308,4,e,t)},useInsertionEffect:function(e,t){fs(4,2,e,t)},useMemo:function(e,t){var n=Fo();t=t===void 0?null:t;var r=e();if(So){qe(!0);try{e()}finally{qe(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Fo();if(n!==void 0){var i=n(t);if(So){qe(!0);try{n(t)}finally{qe(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=Is.bind(null,P,e),[r.memoizedState,e]},useRef:function(e){var t=Fo();return e={current:e},t.memoizedState=e},useState:function(e){e=Xo(e);var t=e.queue,n=Ls.bind(null,P,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Ss,useDeferredValue:function(e,t){return Ts(Fo(),e,t)},useTransition:function(){var e=Xo(!1);return e=Ds.bind(null,P,e.queue,!0,!1),Fo().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=P,i=Fo();if(A){if(n===void 0)throw Error(s(407));n=n()}else{if(n=t(),G===null)throw Error(s(349));q&127||Go(r,t,n)}i.memoizedState=n;var a={value:n,getSnapshot:t};return i.queue=a,ms(qo.bind(null,r,a,e),[e]),r.flags|=2048,us(9,{destroy:void 0},Ko.bind(null,r,a,n,t),null),n},useId:function(){var e=Fo(),t=G.identifierPrefix;if(A){var n=Li,r=Ii;n=(r&~(1<<32-Je(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=Co++,0<\/script>`,a=a.removeChild(a.firstChild);break;case`select`:a=typeof r.is==`string`?o.createElement(`select`,{is:r.is}):o.createElement(`select`),r.multiple?a.multiple=!0:r.size&&(a.size=r.size);break;default:a=typeof r.is==`string`?o.createElement(i,{is:r.is}):o.createElement(i)}}a[_t]=t,a[vt]=r;a:for(o=t.child;o!==null;){if(o.tag===5||o.tag===6)a.appendChild(o.stateNode);else if(o.tag!==4&&o.tag!==27&&o.child!==null){o.child.return=o,o=o.child;continue}if(o===t)break a;for(;o.sibling===null;){if(o.return===null||o.return===t)break a;o=o.return}o.sibling.return=o.return,o=o.sibling}t.stateNode=a;a:switch(Pd(a,i,r),i){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Lc(t)}}return B(t),Rc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Lc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(s(166));if(e=ve.current,Xi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,i=Ui,i!==null)switch(i.tag){case 27:case 5:r=i.memoizedProps}e[_t]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||Md(e.nodeValue,n)),e||qi(t,!0)}else e=Bd(e).createTextNode(r),e[_t]=t,t.stateNode=e}return B(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Xi(t),n!==null){if(e===null){if(!r)throw Error(s(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(s(557));e[_t]=t}else Zi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),e=!1}else n=Qi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(_o(t),t):(_o(t),null);if(t.flags&128)throw Error(s(558))}return B(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(i=Xi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!i)throw Error(s(318));if(i=t.memoizedState,i=i===null?null:i.dehydrated,!i)throw Error(s(317));i[_t]=t}else Zi(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;B(t),i=!1}else i=Qi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=i),i=!0;if(!i)return t.flags&256?(_o(t),t):(_o(t),null)}return _o(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,i=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(i=r.alternate.memoizedState.cachePool.pool),a=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(a=r.memoizedState.cachePool.pool),a!==i&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Bc(t,t.updateQueue),B(t),null);case 4:return xe(),e===null&&Sd(t.stateNode.containerInfo),B(t),null;case 10:return ia(t.type),B(t),null;case 19:if(E(N),r=t.memoizedState,r===null)return B(t),null;if(i=!!(t.flags&128),a=r.rendering,a===null){if(i)Vc(r,!1);else{if(Y!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(a=vo(e),a!==null){for(t.flags|=128,Vc(r,!1),e=a.updateQueue,t.updateQueue=e,Bc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)xi(n,e),n=n.sibling;return D(N,N.current&1|2),A&&Ri(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Ie()>nu&&(t.flags|=128,i=!0,Vc(r,!1),t.lanes=4194304)}}else{if(!i){if(e=vo(a),e!==null){if(t.flags|=128,i=!0,e=e.updateQueue,t.updateQueue=e,Bc(t,e),Vc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!a.alternate&&!A)return B(t),null}else 2*Ie()-r.renderingStartTime>nu&&n!==536870912&&(t.flags|=128,i=!0,Vc(r,!1),t.lanes=4194304)}r.isBackwards?(a.sibling=t.child,t.child=a):(e=r.last,e===null?t.child=a:e.sibling=a,r.last=a)}return r.tail===null?(B(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Ie(),e.sibling=null,n=N.current,D(N,i?n&1|2:n&1),A&&Ri(t,r.treeForkCount),e);case 22:case 23:return _o(t),lo(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(B(t),t.subtreeFlags&6&&(t.flags|=8192)):B(t),n=t.updateQueue,n!==null&&Bc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&E(Ta),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),ia(M),B(t),null;case 25:return null;case 30:return null}throw Error(s(156,t.tag))}function Uc(e,t){switch(Vi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ia(M),xe(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Ce(t),null;case 31:if(t.memoizedState!==null){if(_o(t),t.alternate===null)throw Error(s(340));Zi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(_o(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(s(340));Zi()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return E(N),null;case 4:return xe(),null;case 10:return ia(t.type),null;case 22:case 23:return _o(t),lo(),e!==null&&E(Ta),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return ia(M),null;case 25:return null;default:return null}}function Wc(e,t){switch(Vi(t),t.tag){case 3:ia(M),xe();break;case 26:case 27:case 5:Ce(t);break;case 4:xe();break;case 31:t.memoizedState!==null&&_o(t);break;case 13:_o(t);break;case 19:E(N);break;case 10:ia(t.type);break;case 22:case 23:_o(t),lo(),e!==null&&E(Ta);break;case 24:ia(M)}}function Gc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Z(t,t.return,e)}}function Kc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Z(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Z(t,t.return,e)}}function qc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{io(t,n)}catch(t){Z(e,e.return,t)}}}function Jc(e,t,n){n.props=Zs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Z(e,t,n)}}function Yc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Z(e,t,n)}}function Xc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null){if(typeof r==`function`)try{r()}catch(n){Z(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Z(e,t,n)}else n.current=null}}function Zc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Z(e,e.return,t)}}function Qc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[vt]=t}catch(t){Z(e,e.return,t)}}function $c(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function el(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||$c(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function tl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=un));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(tl(e,t,n),e=e.sibling;e!==null;)tl(e,t,n),e=e.sibling}function nl(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(nl(e,t,n),e=e.sibling;e!==null;)nl(e,t,n),e=e.sibling}function rl(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[_t]=e,t[vt]=n}catch(t){Z(e,e.return,t)}}var il=!1,V=!1,al=!1,ol=typeof WeakSet==`function`?WeakSet:Set,H=null;function sl(e,t){if(e=e.containerInfo,Rd=sp,e=Lr(e),Rr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var i=r.anchorOffset,a=r.focusNode;r=r.focusOffset;try{n.nodeType,a.nodeType}catch{n=null;break a}var o=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||i!==0&&f.nodeType!==3||(c=o+i),f!==a||r!==0&&f.nodeType!==3||(l=o+r),f.nodeType===3&&(o+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===i&&(c=o),p===a&&++d===r&&(l=o),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,H=t;H!==null;)if(t=H,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,H=e;else for(;H!==null;){switch(t=H,a=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(a,r,n),a[_t]=e,O(a),r=a;break a;case`link`:var o=Vf(`link`,`href`,i).get(r+(n.href||``));if(o){for(var c=0;cg&&(o=g,g=h,h=o);var _=Fr(s,h),v=Fr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,w.T=null,n=lu,lu=null;var a=au,o=su;if(X=0,ou=au=null,su=0,W&6)throw Error(s(331));var c=W;if(W|=4,Il(a.current),Ol(a,a.current,o,n),W=c,id(0,!1),Ke&&typeof Ke.onPostCommitFiberRoot==`function`)try{Ke.onPostCommitFiberRoot(Ge,a)}catch{}return!0}finally{T.p=i,w.T=r,Vu(e,t)}}function Wu(e,t,n){t=Oi(n,t),t=rc(e.stateNode,t,2),e=Za(e,t,2),e!==null&&(st(e,2),rd(e))}function Z(e,t,n){if(e.tag===3)Wu(e,e,n);else for(;t!==null;){if(t.tag===3){Wu(t,e,n);break}if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(iu===null||!iu.has(r))){e=Oi(n,e),n=ic(2),r=Za(t,n,2),r!==null&&(ac(n,r,t,e),st(r,2),rd(r));break}}t=t.return}}function Gu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Bl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Wl=!0,i.add(n),e=Ku.bind(null,e,t,n),t.then(e,e))}function Ku(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,G===e&&(q&n)===n&&(Y===4||Y===3&&(q&62914560)===q&&300>Ie()-eu?!(W&2)&&Su(e,0):Jl|=n,Xl===q&&(Xl=0)),rd(e)}function qu(e,t){t===0&&(t=at()),e=pi(e,t),e!==null&&(st(e,t),rd(e))}function Ju(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),qu(e,n)}function Yu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,i=e.memoizedState;i!==null&&(n=i.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(s(314))}r!==null&&r.delete(t),qu(e,n)}function Xu(e,t){return Me(e,t)}var Zu=null,Qu=null,$u=!1,ed=!1,td=!1,nd=0;function rd(e){e!==Qu&&e.next===null&&(Qu===null?Zu=Qu=e:Qu=Qu.next=e),ed=!0,$u||($u=!0,ud())}function id(e,t){if(!td&&ed){td=!0;do for(var n=!1,r=Zu;r!==null;){if(!t){if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Je(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ld(r,a))}else a=q,a=nt(r,r===G?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||rt(r,a)||(n=!0,ld(r,a))}r=r.next}while(n);td=!1}}function ad(){od()}function od(){ed=$u=!1;var e=0;nd!==0&&Gd()&&(e=nd);for(var t=Ie(),n=null,r=Zu;r!==null;){var i=r.next,a=sd(r,t);a===0?(r.next=null,n===null?Zu=i:n.next=i,i===null&&(Qu=n)):(n=r,(e!==0||a&3)&&(ed=!0)),r=i}X!==0&&X!==5||id(e,!1),nd!==0&&(nd=0)}function sd(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Jt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),O(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Jt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Jt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Jt(n.imageSizes)+`"]`)):i+=`[href="`+Jt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),O(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Jt(r)+`"][href="`+Jt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),O(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=kt(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);O(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=kt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),O(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=kt(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),O(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var i=(i=ve.current)?gf(i):null;if(!i)throw Error(s(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=kt(i).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var a=kt(i).hoistableStyles,o=a.get(e);if(o||(i=i.ownerDocument||i,o={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},a.set(e,o),(a=i.querySelector(jf(e)))&&!a._p&&(o.instance=a,o.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),a||Nf(i,e,n,o.state))),t&&r===null)throw Error(s(528,``));return o}if(t&&r!==null)throw Error(s(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=kt(i).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(s(444,e))}}function Af(e){return`href="`+Jt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),O(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Jt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Jt(n.href)+`"]`);if(r)return t.instance=r,O(r),r;var i=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),O(r),Pd(r,`style`,i),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:i=Af(n.href);var a=e.querySelector(jf(i));if(a)return t.state.loading|=4,t.instance=a,O(a),a;r=Mf(n),(i=mf.get(i))&&Rf(r,i),a=(e.ownerDocument||e).createElement(`link`),O(a);var o=a;return o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),t.state.loading|=4,Lf(a,n.precedence,e),t.instance=a;case`script`:return a=Pf(n.src),(i=e.querySelector(Ff(a)))?(t.instance=i,O(i),i):(r=n,(i=mf.get(a))&&(r=h({},n),zf(r,i)),e=e.ownerDocument||e,i=e.createElement(`script`),O(i),Pd(i,`link`,r),e.head.appendChild(i),t.instance=i);case`void`:return null;default:throw Error(s(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,O(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),O(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=s()})),l=n(),u=c(),d=`/assets/react-Bnzpvx9H.svg`,f=`/vite.svg`,p=e((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),m=e(((e,t)=>{t.exports=p()}))();function h(){let[e,t]=(0,l.useState)(0);return(0,m.jsxs)(m.Fragment,{children:[(0,m.jsxs)(`div`,{children:[(0,m.jsx)(`a`,{href:`https://vite.dev`,target:`_blank`,children:(0,m.jsx)(`img`,{src:f,className:`logo`,alt:`Vite logo`})}),(0,m.jsx)(`a`,{href:`https://react.dev`,target:`_blank`,children:(0,m.jsx)(`img`,{src:d,className:`logo react`,alt:`React logo`})})]}),(0,m.jsx)(`h1`,{children:`Vite + React`}),(0,m.jsxs)(`div`,{className:`card`,children:[(0,m.jsxs)(`button`,{onClick:()=>t(e=>e+1),children:[`count is `,e]}),(0,m.jsxs)(`p`,{children:[`Edit `,(0,m.jsx)(`code`,{children:`src/App.tsx`}),` and save to test HMR`]})]}),(0,m.jsx)(`p`,{className:`read-the-docs`,children:`Click on the Vite and React logos to learn more`})]})}(0,u.createRoot)(document.getElementById(`root`)).render((0,m.jsx)(l.StrictMode,{children:(0,m.jsx)(h,{})})); \ No newline at end of file diff --git a/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/index-hoDP6v4Q.css b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/index-hoDP6v4Q.css new file mode 100644 index 000000000..b2b47b0c3 --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/index-hoDP6v4Q.css @@ -0,0 +1 @@ +:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;color:#ffffffde;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#242424;font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;font-weight:400;line-height:1.5}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}a{color:#646cff;-webkit-text-decoration:inherit;text-decoration:inherit;font-weight:500}a:hover{color:#535bf2}body{place-items:center;min-width:320px;min-height:100vh;margin:0;display:flex}h1{font-size:3.2em;line-height:1.1}button{cursor:pointer;background-color:#1a1a1a;border:1px solid #0000;border-radius:8px;padding:.6em 1.2em;font-family:inherit;font-size:1em;font-weight:500;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme:light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}#root{text-align:center;max-width:1280px;margin:0 auto;padding:2rem}.logo{will-change:filter;height:6em;padding:1.5em;transition:filter .3s}.logo:hover{filter:drop-shadow(0 0 2em #646cffaa)}.logo.react:hover{filter:drop-shadow(0 0 2em #61dafbaa)}@keyframes logo-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (prefers-reduced-motion:no-preference){a:nth-of-type(2) .logo{animation:20s linear infinite logo-spin}}.card{padding:2em}.read-the-docs{color:#888} diff --git a/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/react-Bnzpvx9H.svg b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/react-Bnzpvx9H.svg new file mode 100644 index 000000000..6d2236355 --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/assets/react-Bnzpvx9H.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/index.html b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/index.html new file mode 100644 index 000000000..da3f4841c --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/index.html @@ -0,0 +1,14 @@ + + + + + + + Vite + React + TS + + + + +
+ + diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/public/vite.svg b/examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/vite.svg similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/public/vite.svg rename to examples/WebApp/InfiniFrameExample.WebApp.React/wwwroot/vite.svg diff --git a/examples/InfiniFrameExample.WebApp.Vue/InfiniFrameExample.WebApp.Vue.csproj b/examples/WebApp/InfiniFrameExample.WebApp.Vue/InfiniFrameExample.WebApp.Vue.csproj similarity index 60% rename from examples/InfiniFrameExample.WebApp.Vue/InfiniFrameExample.WebApp.Vue.csproj rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/InfiniFrameExample.WebApp.Vue.csproj index 27f78de0a..5b3297f8b 100644 --- a/examples/InfiniFrameExample.WebApp.Vue/InfiniFrameExample.WebApp.Vue.csproj +++ b/examples/WebApp/InfiniFrameExample.WebApp.Vue/InfiniFrameExample.WebApp.Vue.csproj @@ -1,13 +1,20 @@ - + net10.0 + Exe + 14.0 + enable + enable + false + true + ../../../assets/favicon.ico $(MSBuildProjectDirectory)/Source/InfiniFrame.Vue $(FrontendDirectory)/package-lock.json - - + + @@ -21,8 +28,8 @@ - <_ContentIncludedByDefault Remove="wwwroot\assets\index-BCm1kFHf.js" /> - <_ContentIncludedByDefault Remove="wwwroot\assets\index-D-FX-CIJ.css" /> + <_ContentIncludedByDefault Remove="wwwroot\assets\index-BCm1kFHf.js" /> + <_ContentIncludedByDefault Remove="wwwroot\assets\index-D-FX-CIJ.css" /> @@ -37,4 +44,5 @@ + diff --git a/examples/InfiniFrameExample.WebApp.Vue/Program.cs b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Program.cs similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Program.cs rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Program.cs diff --git a/examples/InfiniFrameExample.WebApp.Vue/Properties/launchSettings.json b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Properties/launchSettings.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Properties/launchSettings.json rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Properties/launchSettings.json diff --git a/examples/InfiniFrameExample.WebApp.Vue/README.md b/examples/WebApp/InfiniFrameExample.WebApp.Vue/README.md similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/README.md rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/README.md diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/.gitignore b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/.gitignore similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/.gitignore rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/.gitignore diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/README.md b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/README.md similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/README.md rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/README.md diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/index.html b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/index.html similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/index.html rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/index.html diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package-lock.json b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package-lock.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package-lock.json rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package-lock.json diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package.json b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package.json rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/package.json diff --git a/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/public/vite.svg b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/public/vite.svg new file mode 100644 index 000000000..6447244f1 --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/public/vite.svg @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/App.vue b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/App.vue similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/App.vue rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/App.vue diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/assets/vue.svg b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/assets/vue.svg similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/assets/vue.svg rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/assets/vue.svg diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/Fullscreen.vue b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/Fullscreen.vue similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/Fullscreen.vue rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/Fullscreen.vue diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/HelloWorld.vue b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/HelloWorld.vue similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/HelloWorld.vue rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/HelloWorld.vue diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/NewWindow.vue b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/NewWindow.vue similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/NewWindow.vue rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/NewWindow.vue diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/TitleChange.vue b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/TitleChange.vue similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/TitleChange.vue rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/components/TitleChange.vue diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/main.ts b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/main.ts similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/main.ts rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/main.ts diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/style.css b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/style.css similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/style.css rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/style.css diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/vite-env.d.ts b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/vite-env.d.ts similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/vite-env.d.ts rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/src/vite-env.d.ts diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.app.json b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.app.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.app.json rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.app.json diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.json b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.json rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.json diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.node.json b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.node.json similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.node.json rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/tsconfig.node.json diff --git a/examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/vite.config.ts b/examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/vite.config.ts similarity index 100% rename from examples/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/vite.config.ts rename to examples/WebApp/InfiniFrameExample.WebApp.Vue/Source/InfiniFrame.Vue/vite.config.ts diff --git a/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/assets/index-BMx06hun.js b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/assets/index-BMx06hun.js new file mode 100644 index 000000000..59f8e833e --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/assets/index-BMx06hun.js @@ -0,0 +1 @@ +(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){let t=Object.create(null);for(let n of e.split(`,`))t[n]=1;return e=>e in t}var t={},n=[],r=()=>{},i=()=>!1,a=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),o=e=>e.startsWith(`onUpdate:`),s=Object.assign,c=(e,t)=>{let n=e.indexOf(t);n>-1&&e.splice(n,1)},l=Object.prototype.hasOwnProperty,u=(e,t)=>l.call(e,t),d=Array.isArray,f=e=>x(e)===`[object Map]`,p=e=>x(e)===`[object Set]`,m=e=>x(e)===`[object Date]`,h=e=>typeof e==`function`,g=e=>typeof e==`string`,_=e=>typeof e==`symbol`,v=e=>typeof e==`object`&&!!e,y=e=>(v(e)||h(e))&&h(e.then)&&h(e.catch),b=Object.prototype.toString,x=e=>b.call(e),S=e=>x(e).slice(8,-1),C=e=>x(e)===`[object Object]`,w=e=>g(e)&&e!==`NaN`&&e[0]!==`-`&&``+parseInt(e,10)===e,ee=e(`,key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted`),te=e=>{let t=Object.create(null);return(n=>t[n]||(t[n]=e(n)))},ne=/-\w/g,T=te(e=>e.replace(ne,e=>e.slice(1).toUpperCase())),re=/\B([A-Z])/g,E=te(e=>e.replace(re,`-$1`).toLowerCase()),ie=te(e=>e.charAt(0).toUpperCase()+e.slice(1)),ae=te(e=>e?`on${ie(e)}`:``),D=(e,t)=>!Object.is(e,t),oe=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},se=e=>{let t=parseFloat(e);return isNaN(t)?e:t},ce,le=()=>ce||=typeof globalThis<`u`?globalThis:typeof self<`u`?self:typeof window<`u`?window:typeof global<`u`?global:{};function ue(e){if(d(e)){let t={};for(let n=0;n{if(e){let n=e.split(fe);n.length>1&&(t[n[0].trim()]=n[1].trim())}}),t}function k(e){let t=``;if(g(e))t=e;else if(d(e))for(let n=0;n!!(e&&e.__v_isRef===!0),xe=e=>g(e)?e:e==null?``:d(e)||v(e)&&(e.toString===b||!h(e.toString))?be(e)?xe(e.value):JSON.stringify(e,Se,2):String(e),Se=(e,t)=>be(t)?Se(e,t.value):f(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((e,[t,n],r)=>(e[Ce(t,r)+` =>`]=n,e),{})}:p(t)?{[`Set(${t.size})`]:[...t.values()].map(e=>Ce(e))}:_(t)?Ce(t):v(t)&&!d(t)&&!C(t)?String(t):t,Ce=(e,t=``)=>_(e)?`Symbol(${e.description??t})`:e,A,we=class{constructor(e=!1){this.detached=e,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this._warnOnRun=!0,this.__v_skip=!0,!e&&A&&(A.active?(this.parent=A,this.index=(A.scopes||(A.scopes=[])).push(this)-1):(this._active=!1,this._warnOnRun=!1))}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let e,t;if(this.scopes){let n=this.scopes.slice();for(e=0,t=n.length;e0&&--this._on===0){if(A===this)A=this.prevScope;else{let e=A;for(;e;){if(e.prevScope===this){e.prevScope=this.prevScope;break}e=e.prevScope}}this.prevScope=void 0}}stop(e){if(this._active){this._active=!1;let t,n;for(t=0,n=this.effects.length;t0)return;if(Ae){let e=Ae;for(Ae=void 0;e;){let t=e.next;e.next=void 0,e.flags&=-9,e=t}}let e;for(;ke;){let t=ke;for(ke=void 0;t;){let n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(t){e||=t}t=n}}if(e)throw e}function Pe(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Fe(e){let t,n=e.depsTail,r=n;for(;r;){let e=r.prevDep;r.version===-1?(r===n&&(n=e),Re(r),ze(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=e}e.deps=t,e.depsTail=n}function Ie(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Le(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Le(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===He)||(e.globalVersion=He,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ie(e))))return;e.flags|=2;let t=e.dep,n=j,r=M;j=e,M=!0;try{Pe(e);let n=e.fn(e._value);(t.version===0||D(n,e._value))&&(e.flags|=128,e._value=n,t.version++)}catch(e){throw t.version++,e}finally{j=n,M=r,Fe(e),e.flags&=-3}}function Re(e,t=!1){let{dep:n,prevSub:r,nextSub:i}=e;if(r&&(r.nextSub=i,e.prevSub=void 0),i&&(i.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let e=n.computed.deps;e;e=e.nextDep)Re(e,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function ze(e){let{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}var M=!0,Be=[];function N(){Be.push(M),M=!1}function P(){let e=Be.pop();M=e===void 0||e}function Ve(e){let{cleanup:t}=e;if(e.cleanup=void 0,t){let e=j;j=void 0;try{t()}finally{j=e}}}var He=0,Ue=class{constructor(e,t){this.sub=e,this.dep=t,this.version=t.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}},We=class{constructor(e){this.computed=e,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(e){if(!j||!M||j===this.computed)return;let t=this.activeLink;if(t===void 0||t.sub!==j)t=this.activeLink=new Ue(j,this),j.deps?(t.prevDep=j.depsTail,j.depsTail.nextDep=t,j.depsTail=t):j.deps=j.depsTail=t,Ge(t);else if(t.version===-1&&(t.version=this.version,t.nextDep)){let e=t.nextDep;e.prevDep=t.prevDep,t.prevDep&&(t.prevDep.nextDep=e),t.prevDep=j.depsTail,t.nextDep=void 0,j.depsTail.nextDep=t,j.depsTail=t,j.deps===t&&(j.deps=e)}return t}trigger(e){this.version++,He++,this.notify(e)}notify(e){Me();try{for(let e=this.subs;e;e=e.prevSub)e.sub.notify()&&e.sub.dep.notify()}finally{Ne()}}};function Ge(e){if(e.dep.sc++,e.sub.flags&4){let t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let e=t.deps;e;e=e.nextDep)Ge(e)}let n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}var Ke=new WeakMap,qe=Symbol(``),Je=Symbol(``),Ye=Symbol(``);function F(e,t,n){if(M&&j){let t=Ke.get(e);t||Ke.set(e,t=new Map);let r=t.get(n);r||(t.set(n,r=new We),r.map=t,r.key=n),r.track()}}function Xe(e,t,n,r,i,a){let o=Ke.get(e);if(!o){He++;return}let s=e=>{e&&e.trigger()};if(Me(),t===`clear`)o.forEach(s);else{let i=d(e),a=i&&w(n);if(i&&n===`length`){let e=Number(r);o.forEach((t,n)=>{(n===`length`||n===Ye||!_(n)&&n>=e)&&s(t)})}else switch((n!==void 0||o.has(void 0))&&s(o.get(n)),a&&s(o.get(Ye)),t){case`add`:i?a&&s(o.get(`length`)):(s(o.get(qe)),f(e)&&s(o.get(Je)));break;case`delete`:i||(s(o.get(qe)),f(e)&&s(o.get(Je)));break;case`set`:f(e)&&s(o.get(qe))}}Ne()}function Ze(e){let t=R(e);return t===e?t:(F(t,`iterate`,Ye),L(e)?t:t.map(Lt))}function Qe(e){return F(e=R(e),`iterate`,Ye),e}function I(e,t){return Pt(e)?Rt(Nt(e)?Lt(t):t):Lt(t)}var $e={__proto__:null,[Symbol.iterator](){return et(this,Symbol.iterator,e=>I(this,e))},concat(...e){return Ze(this).concat(...e.map(e=>d(e)?Ze(e):e))},entries(){return et(this,`entries`,e=>(e[1]=I(this,e[1]),e))},every(e,t){return nt(this,`every`,e,t,void 0,arguments)},filter(e,t){return nt(this,`filter`,e,t,e=>e.map(e=>I(this,e)),arguments)},find(e,t){return nt(this,`find`,e,t,e=>I(this,e),arguments)},findIndex(e,t){return nt(this,`findIndex`,e,t,void 0,arguments)},findLast(e,t){return nt(this,`findLast`,e,t,e=>I(this,e),arguments)},findLastIndex(e,t){return nt(this,`findLastIndex`,e,t,void 0,arguments)},forEach(e,t){return nt(this,`forEach`,e,t,void 0,arguments)},includes(...e){return it(this,`includes`,e)},indexOf(...e){return it(this,`indexOf`,e)},join(e){return Ze(this).join(e)},lastIndexOf(...e){return it(this,`lastIndexOf`,e)},map(e,t){return nt(this,`map`,e,t,void 0,arguments)},pop(){return at(this,`pop`)},push(...e){return at(this,`push`,e)},reduce(e,...t){return rt(this,`reduce`,e,t)},reduceRight(e,...t){return rt(this,`reduceRight`,e,t)},shift(){return at(this,`shift`)},some(e,t){return nt(this,`some`,e,t,void 0,arguments)},splice(...e){return at(this,`splice`,e)},toReversed(){return Ze(this).toReversed()},toSorted(e){return Ze(this).toSorted(e)},toSpliced(...e){return Ze(this).toSpliced(...e)},unshift(...e){return at(this,`unshift`,e)},values(){return et(this,`values`,e=>I(this,e))}};function et(e,t,n){let r=Qe(e),i=r[t]();return r!==e&&!L(e)&&(i._next=i.next,i.next=()=>{let e=i._next();return e.done||(e.value=n(e.value)),e}),i}var tt=Array.prototype;function nt(e,t,n,r,i,a){let o=Qe(e),s=o!==e&&!L(e),c=o[t];if(c!==tt[t]){let t=c.apply(e,a);return s?Lt(t):t}let l=n;o!==e&&(s?l=function(t,r){return n.call(this,I(e,t),r,e)}:n.length>2&&(l=function(t,r){return n.call(this,t,r,e)}));let u=c.call(o,l,r);return s&&i?i(u):u}function rt(e,t,n,r){let i=Qe(e),a=i!==e&&!L(e),o=n,s=!1;i!==e&&(a?(s=r.length===0,o=function(t,r,i){return s&&(s=!1,t=I(e,t)),n.call(this,t,I(e,r),i,e)}):n.length>3&&(o=function(t,r,i){return n.call(this,t,r,i,e)}));let c=i[t](o,...r);return s?I(e,c):c}function it(e,t,n){let r=R(e);F(r,`iterate`,Ye);let i=r[t](...n);return(i===-1||i===!1)&&Ft(n[0])?(n[0]=R(n[0]),r[t](...n)):i}function at(e,t,n=[]){N(),Me();let r=R(e)[t].apply(e,n);return Ne(),P(),r}var ot=e(`__proto__,__v_isRef,__isVue`),st=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!==`arguments`&&e!==`caller`).map(e=>Symbol[e]).filter(_));function ct(e){_(e)||(e=String(e));let t=R(this);return F(t,`has`,e),t.hasOwnProperty(e)}var lt=class{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){if(t===`__v_skip`)return e.__v_skip;let r=this._isReadonly,i=this._isShallow;if(t===`__v_isReactive`)return!r;if(t===`__v_isReadonly`)return r;if(t===`__v_isShallow`)return i;if(t===`__v_raw`)return n===(r?i?Dt:Et:i?Tt:wt).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;let a=d(e);if(!r){let e;if(a&&(e=$e[t]))return e;if(t===`hasOwnProperty`)return ct}let o=Reflect.get(e,t,z(e)?e:n);if((_(t)?st.has(t):ot(t))||(r||F(e,`get`,t),i))return o;if(z(o)){let e=a&&w(t)?o:o.value;return r&&v(e)?jt(e):e}return v(o)?r?jt(o):kt(o):o}},ut=class extends lt{constructor(e=!1){super(!1,e)}set(e,t,n,r){let i=e[t],a=d(e)&&w(t);if(!this._isShallow){let e=Pt(i);if(!L(n)&&!Pt(n)&&(i=R(i),n=R(n)),!a&&z(i)&&!z(n))return e||(i.value=n),!0}let o=a?Number(t)e,gt=e=>Reflect.getPrototypeOf(e);function _t(e,t,n){return function(...r){let i=this.__v_raw,a=R(i),o=f(a),c=e===`entries`||e===Symbol.iterator&&o,l=e===`keys`&&o,u=i[e](...r),d=n?ht:t?Rt:Lt;return!t&&F(a,`iterate`,l?Je:qe),s(Object.create(u),{next(){let{value:e,done:t}=u.next();return t?{value:e,done:t}:{value:c?[d(e[0]),d(e[1])]:d(e),done:t}}})}}function vt(e){return function(...t){return e===`delete`?!1:e===`clear`?void 0:this}}function yt(e,t){let n={get(n){let r=this.__v_raw,i=R(r),a=R(n);e||(D(n,a)&&F(i,`get`,n),F(i,`get`,a));let{has:o}=gt(i),s=t?ht:e?Rt:Lt;if(o.call(i,n))return s(r.get(n));if(o.call(i,a))return s(r.get(a));r!==i&&r.get(n)},get size(){let t=this.__v_raw;return!e&&F(R(t),`iterate`,qe),t.size},has(t){let n=this.__v_raw,r=R(n),i=R(t);return e||(D(t,i)&&F(r,`has`,t),F(r,`has`,i)),t===i?n.has(t):n.has(t)||n.has(i)},forEach(n,r){let i=this,a=i.__v_raw,o=R(a),s=t?ht:e?Rt:Lt;return!e&&F(o,`iterate`,qe),a.forEach((e,t)=>n.call(r,s(e),s(t),i))}};return s(n,e?{add:vt(`add`),set:vt(`set`),delete:vt(`delete`),clear:vt(`clear`)}:{add(e){let n=R(this),r=gt(n),i=R(e),a=!t&&!L(e)&&!Pt(e)?i:e;return r.has.call(n,a)||D(e,a)&&r.has.call(n,e)||D(i,a)&&r.has.call(n,i)||(n.add(a),Xe(n,`add`,a,a)),this},set(e,n){!t&&!L(n)&&!Pt(n)&&(n=R(n));let r=R(this),{has:i,get:a}=gt(r),o=i.call(r,e);o||=(e=R(e),i.call(r,e));let s=a.call(r,e);return r.set(e,n),o?D(n,s)&&Xe(r,`set`,e,n,s):Xe(r,`add`,e,n),this},delete(e){let t=R(this),{has:n,get:r}=gt(t),i=n.call(t,e);i||=(e=R(e),n.call(t,e));let a=r?r.call(t,e):void 0,o=t.delete(e);return i&&Xe(t,`delete`,e,void 0,a),o},clear(){let e=R(this),t=e.size!==0,n=e.clear();return t&&Xe(e,`clear`,void 0,void 0,void 0),n}}),[`keys`,`values`,`entries`,Symbol.iterator].forEach(r=>{n[r]=_t(r,e,t)}),n}function bt(e,t){let n=yt(e,t);return(t,r,i)=>r===`__v_isReactive`?!e:r===`__v_isReadonly`?e:r===`__v_raw`?t:Reflect.get(u(n,r)&&r in t?n:t,r,i)}var xt={get:bt(!1,!1)},St={get:bt(!1,!0)},Ct={get:bt(!0,!1)},wt=new WeakMap,Tt=new WeakMap,Et=new WeakMap,Dt=new WeakMap;function Ot(e){switch(e){case`Object`:case`Array`:return 1;case`Map`:case`Set`:case`WeakMap`:case`WeakSet`:return 2;default:return 0}}function kt(e){return Pt(e)?e:Mt(e,!1,ft,xt,wt)}function At(e){return Mt(e,!1,mt,St,Tt)}function jt(e){return Mt(e,!0,pt,Ct,Et)}function Mt(e,t,n,r,i){if(!v(e)||e.__v_raw&&!(t&&e.__v_isReactive)||e.__v_skip||!Object.isExtensible(e))return e;let a=i.get(e);if(a)return a;let o=Ot(S(e));if(o===0)return e;let s=new Proxy(e,o===2?r:n);return i.set(e,s),s}function Nt(e){return Pt(e)?Nt(e.__v_raw):!!(e&&e.__v_isReactive)}function Pt(e){return!!(e&&e.__v_isReadonly)}function L(e){return!!(e&&e.__v_isShallow)}function Ft(e){return e?!!e.__v_raw:!1}function R(e){let t=e&&e.__v_raw;return t?R(t):e}function It(e){return!u(e,`__v_skip`)&&Object.isExtensible(e)&&O(e,`__v_skip`,!0),e}var Lt=e=>v(e)?kt(e):e,Rt=e=>v(e)?jt(e):e;function z(e){return e?e.__v_isRef===!0:!1}function zt(e){return Bt(e,!1)}function Bt(e,t){return z(e)?e:new Vt(e,t)}var Vt=class{constructor(e,t){this.dep=new We,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=t?e:R(e),this._value=t?e:Lt(e),this.__v_isShallow=t}get value(){return this.dep.track(),this._value}set value(e){let t=this._rawValue,n=this.__v_isShallow||L(e)||Pt(e);e=n?e:R(e),D(e,t)&&(this._rawValue=e,this._value=n?e:Lt(e),this.dep.trigger())}};function Ht(e){return z(e)?e.value:e}var Ut={get:(e,t,n)=>t===`__v_raw`?e:Ht(Reflect.get(e,t,n)),set:(e,t,n,r)=>{let i=e[t];return z(i)&&!z(n)?(i.value=n,!0):Reflect.set(e,t,n,r)}};function Wt(e){return Nt(e)?e:new Proxy(e,Ut)}var Gt=class{constructor(e,t,n){this.fn=e,this.setter=t,this._value=void 0,this.dep=new We(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=He-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!t,this.isSSR=n}notify(){if(this.flags|=16,!(this.flags&8)&&j!==this)return je(this,!0),!0}get value(){let e=this.dep.track();return Le(this),e&&(e.version=this.dep.version),this._value}set value(e){this.setter&&this.setter(e)}};function Kt(e,t,n=!1){let r,i;return h(e)?r=e:(r=e.get,i=e.set),new Gt(r,i,n)}var qt={},Jt=new WeakMap,Yt=void 0;function Xt(e,t=!1,n=Yt){if(n){let t=Jt.get(n);t||Jt.set(n,t=[]),t.push(e)}}function Zt(e,n,i=t){let{immediate:a,deep:o,once:s,scheduler:l,augmentJob:u,call:f}=i,p=e=>o?e:L(e)||o===!1||o===0?Qt(e,1):Qt(e),m,g,_,v,y=!1,b=!1;if(z(e)?(g=()=>e.value,y=L(e)):Nt(e)?(g=()=>p(e),y=!0):d(e)?(b=!0,y=e.some(e=>Nt(e)||L(e)),g=()=>e.map(e=>{if(z(e))return e.value;if(Nt(e))return p(e);if(h(e))return f?f(e,2):e()})):g=h(e)?n?f?()=>f(e,2):e:()=>{if(_){N();try{_()}finally{P()}}let t=Yt;Yt=m;try{return f?f(e,3,[v]):e(v)}finally{Yt=t}}:r,n&&o){let e=g,t=o===!0?1/0:o;g=()=>Qt(e(),t)}let x=Te(),S=()=>{m.stop(),x&&x.active&&c(x.effects,m)};if(s&&n){let e=n;n=(...t)=>{let n=e(...t);return S(),n}}let C=b?Array(e.length).fill(qt):qt,w=e=>{if(!(!(m.flags&1)||!m.dirty&&!e)){if(n){let t=m.run();if(e||o||y||(b?t.some((e,t)=>D(e,C[t])):D(t,C))){_&&_();let e=Yt;Yt=m;try{let e=[t,C===qt?void 0:b&&C[0]===qt?[]:C,v];C=t,f?f(n,3,e):n(...e)}finally{Yt=e}}}else m.run()}};return u&&u(w),m=new De(g),m.scheduler=l?()=>l(w,!1):w,v=e=>Xt(e,!1,m),_=m.onStop=()=>{let e=Jt.get(m);if(e){if(f)f(e,4);else for(let t of e)t();Jt.delete(m)}},n?a?w(!0):C=m.run():l?l(w.bind(null,!0),!0):m.run(),S.pause=m.pause.bind(m),S.resume=m.resume.bind(m),S.stop=S,S}function Qt(e,t=1/0,n){if(t<=0||!v(e)||e.__v_skip||(n||=new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,z(e))Qt(e.value,t,n);else if(d(e))for(let r=0;r{Qt(e,t,n)});else if(C(e)){for(let r in e)Qt(e[r],t,n);for(let r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&Qt(e[r],t,n)}return e}function $t(e,t,n,r){try{return r?e(...r):e()}catch(e){en(e,t,n)}}function B(e,t,n,r){if(h(e)){let i=$t(e,t,n,r);return i&&y(i)&&i.catch(e=>{en(e,t,n)}),i}if(d(e)){let i=[];for(let a=0;a>>1,i=V[r],a=hn(i);a=hn(n)?V.push(e):V.splice(ln(t),0,e),e.flags|=1,dn()}}function dn(){sn||=on.then(gn)}function fn(e){d(e)?nn.push(...e):rn&&e.id===-1?rn.splice(an+1,0,e):e.flags&1||(nn.push(e),e.flags|=1),dn()}function pn(e,t,n=H+1){for(;nhn(e)-hn(t));if(nn.length=0,rn){rn.push(...e);return}for(rn=e,an=0;ane.id==null?e.flags&2?-1:1/0:e.id;function gn(e){try{for(H=0;H{r._d&&Si(-1);let i=vn(t),a=vi.length,o;try{o=e(...n)}finally{for(let e=vi.length;e>a;e--)bi();vn(i),r._d&&Si(1)}return o};return r._n=!0,r._c=!0,r._d=!0,r}function bn(e,t,n,r){let i=e.dirs,a=t&&t.dirs;for(let o=0;o1)return n&&h(t)?t.call(r&&r.proxy):t}}var Cn=Symbol.for(`v-scx`),wn=()=>Sn(Cn);function Tn(e,t,n){return En(e,t,n)}function En(e,n,i=t){let{immediate:a,deep:o,flush:c,once:l}=i,u=s({},i),d=n&&a||!n&&c!==`post`,f;if(Gi){if(c===`sync`){let e=wn();f=e.__watcherHandles||=[]}else if(!d){let e=()=>{};return e.stop=r,e.resume=r,e.pause=r,e}}let p=Q;u.call=(e,t,n)=>B(e,p,t,n);let m=!1;c===`post`?u.scheduler=e=>{G(e,p&&p.suspense)}:c!==`sync`&&(m=!0,u.scheduler=(e,t)=>{t?e():un(e)}),u.augmentJob=e=>{n&&(e.flags|=4),m&&(e.flags|=2,p&&(e.id=p.uid,e.i=p))};let h=Zt(e,n,u);return Gi&&(f?f.push(h):d&&h()),h}function Dn(e,t,n){let r=this.proxy,i=g(e)?e.includes(`.`)?On(r,e):()=>r[e]:e.bind(r,r),a;h(t)?a=t:(a=t.handler,n=t);let o=Hi(this),s=En(i,a.bind(r),n);return o(),s}function On(e,t){let n=t.split(`.`);return()=>{let t=e;for(let e=0;ee.__isTeleport,jn=Symbol(`_leaveCb`);function Mn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Mn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Nn(e,t){return h(e)?s({name:e.name},t,{setup:e}):e}function Pn(e){e.ids=[e.ids[0]+e.ids[2]+++`-`,0,0]}function Fn(e,t){let n;return!!((n=Object.getOwnPropertyDescriptor(e,t))&&!n.configurable)}var In=new WeakMap;function Ln(e,n,r,a,o=!1){if(d(e)){e.forEach((e,t)=>Ln(e,n&&(d(n)?n[t]:n),r,a,o));return}if(zn(a)&&!o){a.shapeFlag&512&&a.type.__asyncResolved&&a.component.subTree.component&&Ln(e,n,r,a.component.subTree);return}let s=a.shapeFlag&4?ea(a.component):a.el,l=o?null:s,{i:f,r:p}=e,m=n&&n.r,_=f.refs===t?f.refs={}:f.refs,v=f.setupState,y=R(v),b=v===t?i:e=>!Fn(_,e)&&u(y,e),x=(e,t)=>!(t&&Fn(_,t));if(m!=null&&m!==p){if(Rn(n),g(m))_[m]=null,b(m)&&(v[m]=null);else if(z(m)){let e=n;x(m,e.k)&&(m.value=null),e.k&&(_[e.k]=null)}}if(h(p))$t(p,f,12,[l,_]);else{let t=g(p),n=z(p);if(t||n){let i=()=>{if(e.f){let n=t?b(p)?v[p]:_[p]:x(p)||!e.k?p.value:_[e.k];if(o)d(n)&&c(n,s);else if(d(n))n.includes(s)||n.push(s);else if(t)_[p]=[s],b(p)&&(v[p]=_[p]);else{let t=[s];x(p,e.k)&&(p.value=t),e.k&&(_[e.k]=t)}}else t?(_[p]=l,b(p)&&(v[p]=l)):n&&(x(p,e.k)&&(p.value=l),e.k&&(_[e.k]=l))};if(l){let t=()=>{i(),In.delete(e)};t.id=-1,In.set(e,t),G(t,r)}else Rn(e),i()}}}function Rn(e){let t=In.get(e);t&&(t.flags|=8,In.delete(e))}le().requestIdleCallback,le().cancelIdleCallback;var zn=e=>!!e.type.__asyncLoader,Bn=e=>e.type.__isKeepAlive;function Vn(e,t){Un(e,`a`,t)}function Hn(e,t){Un(e,`da`,t)}function Un(e,t,n=Q){let r=e.__wdc||=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()};if(Gn(t,r,n),n){let e=n.parent;for(;e&&e.parent;)Bn(e.parent.vnode)&&Wn(r,t,n,e),e=e.parent}}function Wn(e,t,n,r){let i=Gn(t,e,r,!0);Qn(()=>{c(r[t],i)},n)}function Gn(e,t,n=Q,r=!1){if(n){let i=n[e]||(n[e]=[]),a=t.__weh||=(...r)=>{N();let i=Hi(n),a=B(t,n,e,r);return i(),P(),a};return r?i.unshift(a):i.push(a),a}}var Kn=e=>(t,n=Q)=>{(!Gi||e===`sp`)&&Gn(e,(...e)=>t(...e),n)},qn=Kn(`bm`),Jn=Kn(`m`),Yn=Kn(`bu`),Xn=Kn(`u`),Zn=Kn(`bum`),Qn=Kn(`um`),$n=Kn(`sp`),er=Kn(`rtg`),tr=Kn(`rtc`);function nr(e,t=Q){Gn(`ec`,e,t)}var rr=Symbol.for(`v-ndc`),ir=e=>e?Wi(e)?ea(e):ir(e.parent):null,ar=s(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>ir(e.parent),$root:e=>ir(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>mr(e),$forceUpdate:e=>e.f||=()=>{un(e.update)},$nextTick:e=>e.n||=cn.bind(e.proxy),$watch:e=>Dn.bind(e)}),or=(e,n)=>e!==t&&!e.__isScriptSetup&&u(e,n),sr={get({_:e},n){if(n===`__v_skip`)return!0;let{ctx:r,setupState:i,data:a,props:o,accessCache:s,type:c,appContext:l}=e;if(n[0]!==`$`){let e=s[n];if(e!==void 0)switch(e){case 1:return i[n];case 2:return a[n];case 4:return r[n];case 3:return o[n]}else if(or(i,n))return s[n]=1,i[n];else if(a!==t&&u(a,n))return s[n]=2,a[n];else if(u(o,n))return s[n]=3,o[n];else if(r!==t&&u(r,n))return s[n]=4,r[n];else lr&&(s[n]=0)}let d=ar[n],f,p;if(d)return n===`$attrs`&&F(e.attrs,`get`,``),d(e);if((f=c.__cssModules)&&(f=f[n]))return f;if(r!==t&&u(r,n))return s[n]=4,r[n];if(p=l.config.globalProperties,u(p,n))return p[n]},set({_:e},n,r){let{data:i,setupState:a,ctx:o}=e;return or(a,n)?(a[n]=r,!0):i!==t&&u(i,n)?(i[n]=r,!0):u(e.props,n)||n[0]===`$`&&n.slice(1)in e?!1:(o[n]=r,!0)},has({_:{data:e,setupState:n,accessCache:r,ctx:i,appContext:a,props:o,type:s}},c){let l;return!!(r[c]||e!==t&&c[0]!==`$`&&u(e,c)||or(n,c)||u(o,c)||u(i,c)||u(ar,c)||u(a.config.globalProperties,c)||(l=s.__cssModules)&&l[c])},defineProperty(e,t,n){return n.get==null?u(n,`value`)&&this.set(e,t,n.value,null):e._.accessCache[t]=0,Reflect.defineProperty(e,t,n)}};function cr(e){return d(e)?e.reduce((e,t)=>(e[t]=null,e),{}):e}var lr=!0;function ur(e){let t=mr(e),n=e.proxy,i=e.ctx;lr=!1,t.beforeCreate&&fr(t.beforeCreate,e,`bc`);let{data:a,computed:o,methods:s,watch:c,provide:l,inject:u,created:f,beforeMount:p,mounted:m,beforeUpdate:g,updated:_,activated:y,deactivated:b,beforeDestroy:x,beforeUnmount:S,destroyed:C,unmounted:w,render:ee,renderTracked:te,renderTriggered:ne,errorCaptured:T,serverPrefetch:re,expose:E,inheritAttrs:ie,components:ae,directives:D,filters:oe}=t;if(u&&dr(u,i,null),s)for(let e in s){let t=s[e];h(t)&&(i[e]=t.bind(n))}if(a){let t=a.call(n,n);v(t)&&(e.data=kt(t))}if(lr=!0,o)for(let e in o){let t=o[e],a=na({get:h(t)?t.bind(n,n):h(t.get)?t.get.bind(n,n):r,set:!h(t)&&h(t.set)?t.set.bind(n):r});Object.defineProperty(i,e,{enumerable:!0,configurable:!0,get:()=>a.value,set:e=>a.value=e})}if(c)for(let e in c)pr(c[e],i,n,e);if(l){let e=h(l)?l.call(n):l;Reflect.ownKeys(e).forEach(t=>{xn(t,e[t])})}f&&fr(f,e,`c`);function O(e,t){d(t)?t.forEach(t=>e(t.bind(n))):t&&e(t.bind(n))}if(O(qn,p),O(Jn,m),O(Yn,g),O(Xn,_),O(Vn,y),O(Hn,b),O(nr,T),O(tr,te),O(er,ne),O(Zn,S),O(Qn,w),O($n,re),d(E)){if(E.length){let t=e.exposed||={};E.forEach(e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t,enumerable:!0})})}else e.exposed||={}}ee&&e.render===r&&(e.render=ee),ie!=null&&(e.inheritAttrs=ie),ae&&(e.components=ae),D&&(e.directives=D),re&&Pn(e)}function dr(e,t,n=r){d(e)&&(e=yr(e));for(let n in e){let r=e[n],i;i=v(r)?`default`in r?Sn(r.from||n,r.default,!0):Sn(r.from||n):Sn(r),z(i)?Object.defineProperty(t,n,{enumerable:!0,configurable:!0,get:()=>i.value,set:e=>i.value=e}):t[n]=i}}function fr(e,t,n){B(d(e)?e.map(e=>e.bind(t.proxy)):e.bind(t.proxy),t,n)}function pr(e,t,n,r){let i=r.includes(`.`)?On(n,r):()=>n[r];if(g(e)){let n=t[e];h(n)&&Tn(i,n)}else if(h(e))Tn(i,e.bind(n));else if(v(e)){if(d(e))e.forEach(e=>pr(e,t,n,r));else{let r=h(e.handler)?e.handler.bind(n):t[e.handler];h(r)&&Tn(i,r,e)}}}function mr(e){let t=e.type,{mixins:n,extends:r}=t,{mixins:i,optionsCache:a,config:{optionMergeStrategies:o}}=e.appContext,s=a.get(t),c;return s?c=s:!i.length&&!n&&!r?c=t:(c={},i.length&&i.forEach(e=>hr(c,e,o,!0)),hr(c,t,o)),v(t)&&a.set(t,c),c}function hr(e,t,n,r=!1){let{mixins:i,extends:a}=t;a&&hr(e,a,n,!0),i&&i.forEach(t=>hr(e,t,n,!0));for(let i in t)if(!(r&&i===`expose`)){let r=gr[i]||n&&n[i];e[i]=r?r(e[i],t[i]):t[i]}return e}var gr={data:_r,props:xr,emits:xr,methods:br,computed:br,beforeCreate:W,created:W,beforeMount:W,mounted:W,beforeUpdate:W,updated:W,beforeDestroy:W,beforeUnmount:W,destroyed:W,unmounted:W,activated:W,deactivated:W,errorCaptured:W,serverPrefetch:W,components:br,directives:br,watch:Sr,provide:_r,inject:vr};function _r(e,t){return t?e?function(){return s(h(e)?e.call(this,this):e,h(t)?t.call(this,this):t)}:t:e}function vr(e,t){return br(yr(e),yr(t))}function yr(e){if(d(e)){let t={};for(let n=0;nt===`modelValue`||t===`model-value`?e.modelModifiers:e[`${t}Modifiers`]||e[`${T(t)}Modifiers`]||e[`${E(t)}Modifiers`];function Or(e,n,...r){if(e.isUnmounted)return;let i=e.vnode.props||t,a=r,o=n.startsWith(`update:`),s=o&&Dr(i,n.slice(7));s&&(s.trim&&(a=r.map(e=>g(e)?e.trim():e)),s.number&&(a=r.map(se)));let c,l=i[c=ae(n)]||i[c=ae(T(n))];!l&&o&&(l=i[c=ae(E(n))]),l&&B(l,e,6,a);let u=i[c+`Once`];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[c])return;e.emitted[c]=!0,B(u,e,6,a)}}var kr=new WeakMap;function Ar(e,t,n=!1){let r=n?kr:t.emitsCache,i=r.get(e);if(i!==void 0)return i;let a=e.emits,o={},c=!1;if(!h(e)){let r=e=>{let n=Ar(e,t,!0);n&&(c=!0,s(o,n))};!n&&t.mixins.length&&t.mixins.forEach(r),e.extends&&r(e.extends),e.mixins&&e.mixins.forEach(r)}return!a&&!c?(v(e)&&r.set(e,null),null):(d(a)?a.forEach(e=>o[e]=null):s(o,a),v(e)&&r.set(e,o),o)}function jr(e,t){return!e||!a(t)?!1:(t=t.slice(2),t=t===`Once`?t:t.replace(/Once$/,``),u(e,t[0].toLowerCase()+t.slice(1))||u(e,E(t))||u(e,t))}function Mr(e){let{type:t,vnode:n,proxy:r,withProxy:i,propsOptions:[a],slots:s,attrs:c,emit:l,render:u,renderCache:d,props:f,data:p,setupState:m,ctx:h,inheritAttrs:g}=e,_=vn(e),v,y;try{if(n.shapeFlag&4){let e=i||r,t=e;v=X(u.call(t,e,d,f,m,p,h)),y=c}else{let e=t;v=X(e.length>1?e(f,{attrs:c,slots:s,emit:l}):e(f,null)),y=t.props?c:Nr(c)}}catch(t){vi.length=0,en(t,e,1),v=Y(gi)}let b=v;if(y&&g!==!1){let e=Object.keys(y),{shapeFlag:t}=b;e.length&&t&7&&(a&&e.some(o)&&(y=Pr(y,a)),b=ji(b,y,!1,!0))}return n.dirs&&(b=ji(b,null,!1,!0),b.dirs=b.dirs?b.dirs.concat(n.dirs):n.dirs),n.transition&&Mn(b,n.transition),v=b,vn(_),v}var Nr=e=>{let t;for(let n in e)(n===`class`||n===`style`||a(n))&&((t||={})[n]=e[n]);return t},Pr=(e,t)=>{let n={};for(let r in e)(!o(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n};function Fr(e,t,n){let{props:r,children:i,component:a}=e,{props:o,children:s,patchFlag:c}=t,l=a.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&c>=0){if(c&1024)return!0;if(c&16)return r?Ir(r,o,l):!!o;if(c&8){let e=t.dynamicProps;for(let t=0;tObject.create(zr),Vr=e=>Object.getPrototypeOf(e)===zr;function Hr(e,t,n,r=!1){let i={},a=Br();e.propsDefaults=Object.create(null),Wr(e,t,i,a);for(let t in e.propsOptions[0])t in i||(i[t]=void 0);e.props=n?r?i:At(i):e.type.props?i:a,e.attrs=a}function Ur(e,t,n,r){let{props:i,attrs:a,vnode:{patchFlag:o}}=e,s=R(i),[c]=e.propsOptions,l=!1;if((r||o>0)&&!(o&16)){if(o&8){let n=e.vnode.dynamicProps;for(let r=0;r{p=!0;let[t,n]=qr(e,r,!0);s(l,t),n&&f.push(...n)};!i&&r.mixins.length&&r.mixins.forEach(t),e.extends&&t(e.extends),e.mixins&&e.mixins.forEach(t)}if(!c&&!p)return v(e)&&a.set(e,n),n;if(d(c))for(let e=0;ee===`_`||e===`_ctx`||e===`$stable`,Xr=e=>d(e)?e.map(X):[X(e)],Zr=(e,t,n)=>{if(t._n)return t;let r=yn((...e)=>Xr(t(...e)),n);return r._c=!1,r},Qr=(e,t,n)=>{let r=e._ctx;for(let n in e){if(Yr(n))continue;let i=e[n];if(h(i))t[n]=Zr(n,i,r);else if(i!=null){let e=Xr(i);t[n]=()=>e}}},$r=(e,t)=>{let n=Xr(t);e.slots.default=()=>n},ei=(e,t,n)=>{for(let r in t)(n||!Yr(r))&&(e[r]=t[r])},ti=(e,t,n)=>{let r=e.slots=Br();if(e.vnode.shapeFlag&32){let e=t._;e?(ei(r,t,n),n&&O(r,`_`,e,!0)):Qr(t,r)}else t&&$r(e,t)},ni=(e,n,r)=>{let{vnode:i,slots:a}=e,o=!0,s=t;if(i.shapeFlag&32){let e=n._;e?r&&e===1?o=!1:ei(a,n,r):(o=!n.$stable,Qr(n,a)),s=n}else n&&($r(e,n),s={default:1});if(o)for(let e in a)!Yr(e)&&s[e]==null&&delete a[e]},G=mi;function ri(e){return ii(e)}function ii(e,i){let a=le();a.__VUE__=!0;let{insert:o,remove:s,patchProp:c,createElement:l,createText:u,createComment:d,setText:f,setElementText:p,parentNode:m,nextSibling:h,setScopeId:g=r,insertStaticContent:_}=e,v=(e,t,n,r=null,i=null,a=null,o=void 0,s=null,c=!!t.dynamicChildren)=>{if(e===t)return;e&&!Ei(e,t)&&(r=ye(e),k(e,i,a,!0),e=null),t.patchFlag===-2&&(c=!1,t.dynamicChildren=null);let{type:l,ref:u,shapeFlag:d}=t;switch(l){case hi:y(e,t,n,r);break;case gi:b(e,t,n,r);break;case _i:e??x(t,n,r,o);break;case K:ae(e,t,n,r,i,a,o,s,c);break;default:d&1?w(e,t,n,r,i,a,o,s,c):d&6?D(e,t,n,r,i,a,o,s,c):(d&64||d&128)&&l.process(e,t,n,r,i,a,o,s,c,Se)}u!=null&&i?Ln(u,e&&e.ref,a,t||e,!t):u==null&&e&&e.ref!=null&&Ln(e.ref,null,a,e,!0)},y=(e,t,n,r)=>{if(e==null)o(t.el=u(t.children),n,r);else{let n=t.el=e.el;t.children!==e.children&&f(n,t.children)}},b=(e,t,n,r)=>{e==null?o(t.el=d(t.children||``),n,r):t.el=e.el},x=(e,t,n,r)=>{[e.el,e.anchor]=_(e.children,t,n,r,e.el,e.anchor)},S=({el:e,anchor:t},n,r)=>{let i;for(;e&&e!==t;)i=h(e),o(e,n,r),e=i;o(t,n,r)},C=({el:e,anchor:t})=>{let n;for(;e&&e!==t;)n=h(e),s(e),e=n;s(t)},w=(e,t,n,r,i,a,o,s,c)=>{if(t.type===`svg`?o=`svg`:t.type===`math`&&(o=`mathml`),e==null)te(t,n,r,i,a,o,s,c);else{let n=e.el&&e.el._isVueCE?e.el:null;try{n&&n._beginPatch(),re(e,t,i,a,o,s,c)}finally{n&&n._endPatch()}}},te=(e,t,n,r,i,a,s,u)=>{let d,f,{props:m,shapeFlag:h,transition:g,dirs:_}=e;if(d=e.el=l(e.type,a,m&&m.is,m),h&8?p(d,e.children):h&16&&T(e.children,d,null,r,i,ai(e,a),s,u),_&&bn(e,null,r,`created`),ne(d,e,e.scopeId,s,r),m){for(let e in m)e!==`value`&&!ee(e)&&c(d,e,null,m[e],a,r);`value`in m&&c(d,`value`,null,m.value,a),(f=m.onVnodeBeforeMount)&&Z(f,r,e)}_&&bn(e,null,r,`beforeMount`);let v=si(i,g);v&&g.beforeEnter(d),o(d,t,n),((f=m&&m.onVnodeMounted)||v||_)&&G(()=>{try{f&&Z(f,r,e),v&&g.enter(d),_&&bn(e,null,r,`mounted`)}finally{}},i)},ne=(e,t,n,r,i)=>{if(n&&g(e,n),r)for(let t=0;t{for(let l=c;l{let l=n.el=e.el,{patchFlag:u,dynamicChildren:d,dirs:f}=n;u|=e.patchFlag&16;let m=e.props||t,h=n.props||t,g;if(r&&oi(r,!1),(g=h.onVnodeBeforeUpdate)&&Z(g,r,n,e),f&&bn(n,e,r,`beforeUpdate`),r&&oi(r,!0),d&&(!e.dynamicChildren||e.dynamicChildren.length!==d.length)&&(u=0,s=!1,d=null),(m.innerHTML&&h.innerHTML==null||m.textContent&&h.textContent==null)&&p(l,``),d?E(e.dynamicChildren,d,l,r,i,ai(n,a),o):s||de(e,n,l,null,r,i,ai(n,a),o,!1),u>0){if(u&16)ie(l,m,h,r,a);else if(u&2&&m.class!==h.class&&c(l,`class`,null,h.class,a),u&4&&c(l,`style`,m.style,h.style,a),u&8){let e=n.dynamicProps;for(let t=0;t{g&&Z(g,r,n,e),f&&bn(n,e,r,`updated`)},i)},E=(e,t,n,r,i,a,o)=>{for(let s=0;s{if(n!==r){if(n!==t)for(let t in n)!ee(t)&&!(t in r)&&c(e,t,n[t],null,a,i);for(let t in r){if(ee(t))continue;let o=r[t],s=n[t];o!==s&&t!==`value`&&c(e,t,s,o,a,i)}`value`in r&&c(e,`value`,n.value,r.value,a)}},ae=(e,t,n,r,i,a,s,c,l)=>{let d=t.el=e?e.el:u(``),f=t.anchor=e?e.anchor:u(``),{patchFlag:p,dynamicChildren:m,slotScopeIds:h}=t;h&&(c=c?c.concat(h):h),e==null?(o(d,n,r),o(f,n,r),T(t.children||[],n,f,i,a,s,c,l)):p>0&&p&64&&m&&e.dynamicChildren&&e.dynamicChildren.length===m.length?(E(e.dynamicChildren,m,n,i,a,s,c),(t.key!=null||i&&t===i.subTree)&&ci(e,t,!0)):de(e,t,n,f,i,a,s,c,l)},D=(e,t,n,r,i,a,o,s,c)=>{t.slotScopeIds=s,e==null?t.shapeFlag&512?i.ctx.activate(t,n,r,o,c):O(t,n,r,i,a,o,c):se(e,t,c)},O=(e,t,n,r,i,a,o)=>{let s=e.component=Ri(e,r,i);if(Bn(e)&&(s.ctx.renderer=Se),Ki(s,!1,o),s.asyncDep){if(i&&i.registerDep(s,ce,o),!e.el){let r=s.subTree=Y(gi);b(null,r,t,n),e.placeholder=r.el}}else ce(s,e,t,n,i,a,o)},se=(e,t,n)=>{let r=t.component=e.component;if(Fr(e,t,n)){if(r.asyncDep&&!r.asyncResolved){ue(r,t,n);return}r.next=t,r.update()}else t.el=e.el,r.vnode=t},ce=(e,t,n,r,i,a,o)=>{let s=()=>{if(e.isMounted){let{next:t,bu:n,u:r,parent:s,vnode:c}=e;{let n=ui(e);if(n){t&&(t.el=c.el,ue(e,t,o)),n.asyncDep.then(()=>{G(()=>{e.isUnmounted||l()},i)});return}}let u=t,d;oi(e,!1),t?(t.el=c.el,ue(e,t,o)):t=c,n&&oe(n),(d=t.props&&t.props.onVnodeBeforeUpdate)&&Z(d,s,t,c),oi(e,!0);let f=Mr(e),p=e.subTree;e.subTree=f,v(p,f,m(p.el),ye(p),e,i,a),t.el=f.el,u===null&&Rr(e,f.el),r&&G(r,i),(d=t.props&&t.props.onVnodeUpdated)&&G(()=>Z(d,s,t,c),i)}else{let o,{el:s,props:c}=t,{bm:l,m:u,parent:d,root:f,type:p}=e,m=zn(t);if(oi(e,!1),l&&oe(l),!m&&(o=c&&c.onVnodeBeforeMount)&&Z(o,d,t),oi(e,!0),s&&A){let t=()=>{e.subTree=Mr(e),A(s,e.subTree,e,i,null)};m&&p.__asyncHydrate?p.__asyncHydrate(s,e,t):t()}else{f.ce&&f.ce._hasShadowRoot()&&f.ce._injectChildStyle(p,e.parent?e.parent.type:void 0);let o=e.subTree=Mr(e);v(null,o,n,r,e,i,a),t.el=o.el}if(u&&G(u,i),!m&&(o=c&&c.onVnodeMounted)){let e=t;G(()=>Z(o,d,e),i)}(t.shapeFlag&256||d&&zn(d.vnode)&&d.vnode.shapeFlag&256)&&e.a&&G(e.a,i),e.isMounted=!0,t=n=r=null}};e.scope.on();let c=e.effect=new De(s);e.scope.off();let l=e.update=c.run.bind(c),u=e.job=c.runIfDirty.bind(c);u.i=e,u.id=e.uid,c.scheduler=()=>un(u),oi(e,!0),l()},ue=(e,t,n)=>{t.component=e;let r=e.vnode.props;e.vnode=t,e.next=null,Ur(e,t.props,r,n),ni(e,t.children,n),N(),pn(e),P()},de=(e,t,n,r,i,a,o,s,c=!1)=>{let l=e&&e.children,u=e?e.shapeFlag:0,d=t.children,{patchFlag:f,shapeFlag:m}=t;if(f>0){if(f&128){pe(l,d,n,r,i,a,o,s,c);return}if(f&256){fe(l,d,n,r,i,a,o,s,c);return}}m&8?(u&16&&ve(l,i,a),d!==l&&p(n,d)):u&16?m&16?pe(l,d,n,r,i,a,o,s,c):ve(l,i,a,!0):(u&8&&p(n,``),m&16&&T(d,n,r,i,a,o,s,c))},fe=(e,t,r,i,a,o,s,c,l)=>{e||=n,t||=n;let u=e.length,d=t.length,f=Math.min(u,d),p;for(p=0;pd?ve(e,a,o,!0,!1,f):T(t,r,i,a,o,s,c,l,f)},pe=(e,t,r,i,a,o,s,c,l)=>{let u=0,d=t.length,f=e.length-1,p=d-1;for(;u<=f&&u<=p;){let n=e[u],i=t[u]=l?Ni(t[u]):X(t[u]);if(Ei(n,i))v(n,i,r,null,a,o,s,c,l);else break;u++}for(;u<=f&&u<=p;){let n=e[f],i=t[p]=l?Ni(t[p]):X(t[p]);if(Ei(n,i))v(n,i,r,null,a,o,s,c,l);else break;f--,p--}if(u>f){if(u<=p){let e=p+1,n=ep)for(;u<=f;)k(e[u],a,o,!0),u++;else{let m=u,h=u,g=new Map;for(u=h;u<=p;u++){let e=t[u]=l?Ni(t[u]):X(t[u]);e.key!=null&&g.set(e.key,u)}let _,y=0,b=p-h+1,x=!1,S=0,C=Array(b);for(u=0;u=b){k(n,a,o,!0);continue}let i;if(n.key!=null)i=g.get(n.key);else for(_=h;_<=p;_++)if(C[_-h]===0&&Ei(n,t[_])){i=_;break}i===void 0?k(n,a,o,!0):(C[i-h]=u+1,i>=S?S=i:x=!0,v(n,t[i],r,null,a,o,s,c,l),y++)}let w=x?li(C):n;for(_=w.length-1,u=b-1;u>=0;u--){let e=h+u,n=t[e],f=t[e+1],p=e+1{let{el:a,type:c,transition:l,children:u,shapeFlag:d}=e;if(d&6){me(e.component.subTree,t,n,r);return}if(d&128){e.suspense.move(t,n,r);return}if(d&64){c.move(e,t,n,Se);return}if(c===K){o(a,t,n);for(let e=0;el.enter(a),i));else{let{leave:r,delayLeave:i,afterLeave:c}=l,u=()=>{e.ctx.isUnmounted?s(a):o(a,t,n)},d=()=>{let e=a._isLeaving||!!a[jn];a._isLeaving&&a[jn](!0),l.persisted&&!e?u():r(a,()=>{u(),c&&c()})};i?i(a,u,d):d()}}else o(a,t,n)},k=(e,t,n,r=!1,i=!1)=>{let{type:a,props:o,ref:s,children:c,dynamicChildren:l,shapeFlag:u,patchFlag:d,dirs:f,cacheIndex:p,memo:m}=e;if(d===-2&&(i=!1),s!=null&&(N(),Ln(s,null,n,e,!0),P()),p!=null&&(t.renderCache[p]=void 0),u&256){t.ctx.deactivate(e);return}let h=u&1&&f,g=!zn(e),_;if(g&&(_=o&&o.onVnodeBeforeUnmount)&&Z(_,t,e),u&6)_e(e.component,n,r);else{if(u&128){e.suspense.unmount(n,r);return}h&&bn(e,null,t,`beforeUnmount`),u&64?e.type.remove(e,t,n,Se,r):l&&!l.hasOnce&&(a!==K||d>0&&d&64)?ve(l,t,n,!1,!0):(a===K&&d&384||!i&&u&16)&&ve(c,t,n),r&&he(e)}let v=m!=null&&p==null;(g&&(_=o&&o.onVnodeUnmounted)||h||v)&&G(()=>{_&&Z(_,t,e),h&&bn(e,null,t,`unmounted`),v&&(e.el=null)},n)},he=e=>{let{type:t,el:n,anchor:r,transition:i}=e;if(t===K){ge(n,r);return}if(t===_i){C(e);return}let a=()=>{s(n),i&&!i.persisted&&i.afterLeave&&i.afterLeave()};if(e.shapeFlag&1&&i&&!i.persisted){let{leave:t,delayLeave:r}=i,o=()=>t(n,a);r?r(e.el,a,o):o()}else a()},ge=(e,t)=>{let n;for(;e!==t;)n=h(e),s(e),e=n;s(t)},_e=(e,t,n)=>{let{bum:r,scope:i,job:a,subTree:o,um:s,m:c,a:l}=e;di(c),di(l),r&&oe(r),i.stop(),a&&(a.flags|=8,k(o,e,t,n)),s&&G(s,t),G(()=>{e.isUnmounted=!0},t)},ve=(e,t,n,r=!1,i=!1,a=0)=>{for(let o=a;o{if(e.shapeFlag&6)return ye(e.component.subTree);if(e.shapeFlag&128)return e.suspense.next();let t=h(e.anchor||e.el),n=t&&t[kn];return n?h(n):t},be=!1,xe=(e,t,n)=>{let r;e==null?t._vnode&&(k(t._vnode,null,null,!0),r=t._vnode.component):v(t._vnode||null,e,t,null,null,null,n),t._vnode=e,be||=(be=!0,pn(r),mn(),!1)},Se={p:v,um:k,m:me,r:he,mt:O,mc:T,pc:de,pbc:E,n:ye,o:e},Ce,A;return i&&([Ce,A]=i(Se)),{render:xe,hydrate:Ce,createApp:Tr(xe,Ce)}}function ai({type:e,props:t},n){return n===`svg`&&e===`foreignObject`||n===`mathml`&&e===`annotation-xml`&&t&&t.encoding&&t.encoding.includes(`html`)?void 0:n}function oi({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function si(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function ci(e,t,n=!1){let r=e.children,i=t.children;if(d(r)&&d(i))for(let e=0;e>1,e[n[s]]0&&(t[r]=n[a-1]),n[a]=r)}}for(a=n.length,o=n[a-1];a-->0;)n[a]=o,o=t[o];return n}function ui(e){let t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:ui(t)}function di(e){if(e)for(let t=0;te.__isSuspense;function mi(e,t){t&&t.pendingBranch?d(e)?t.effects.push(...e):t.effects.push(e):fn(e)}var K=Symbol.for(`v-fgt`),hi=Symbol.for(`v-txt`),gi=Symbol.for(`v-cmt`),_i=Symbol.for(`v-stc`),vi=[],q=null;function yi(e=!1){vi.push(q=e?null:[])}function bi(){vi.pop(),q=vi[vi.length-1]||null}var xi=1;function Si(e,t=!1){xi+=e,e<0&&q&&t&&(q.hasOnce=!0)}function Ci(e){return e.dynamicChildren=xi>0?q||n:null,bi(),xi>0&&q&&q.push(e),e}function wi(e,t,n,r,i,a){return Ci(J(e,t,n,r,i,a,!0))}function Ti(e){return e?e.__v_isVNode===!0:!1}function Ei(e,t){return e.type===t.type&&e.key===t.key}var Di=({key:e})=>e??null,Oi=({ref:e,ref_key:t,ref_for:n})=>(typeof e==`number`&&(e=``+e),e==null?null:g(e)||z(e)||h(e)?{i:U,r:e,k:t,f:!!n}:e);function J(e,t=null,n=null,r=0,i=null,a=e===K?0:1,o=!1,s=!1){let c={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Di(t),ref:t&&Oi(t),scopeId:_n,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:a,patchFlag:r,dynamicProps:i,dynamicChildren:null,appContext:null,ctx:U};return s?(Pi(c,n),a&128&&e.normalize(c)):n&&(c.shapeFlag|=g(n)?8:16),xi>0&&!o&&q&&(c.patchFlag>0||a&6)&&c.patchFlag!==32&&q.push(c),c}var Y=ki;function ki(e,t=null,n=null,r=0,i=null,a=!1){if((!e||e===rr)&&(e=gi),Ti(e)){let r=ji(e,t,!0);return n&&Pi(r,n),xi>0&&!a&&q&&(r.shapeFlag&6?q[q.indexOf(e)]=r:q.push(r)),r.patchFlag=-2,r}if(ta(e)&&(e=e.__vccOpts),t){t=Ai(t);let{class:e,style:n}=t;e&&!g(e)&&(t.class=k(e)),v(n)&&(Ft(n)&&!d(n)&&(n=s({},n)),t.style=ue(n))}let o=g(e)?1:pi(e)?128:An(e)?64:v(e)?4:h(e)?2:0;return J(e,t,n,r,i,o,a,!0)}function Ai(e){return e?Ft(e)||Vr(e)?s({},e):e:null}function ji(e,t,n=!1,r=!1){let{props:i,ref:a,patchFlag:o,children:s,transition:c}=e,l=t?Fi(i||{},t):i,u={__v_isVNode:!0,__v_skip:!0,type:e.type,props:l,key:l&&Di(l),ref:t&&t.ref?n&&a?d(a)?a.concat(Oi(t)):[a,Oi(t)]:Oi(t):a,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:s,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==K?o===-1?16:o|16:o,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:c,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&ji(e.ssContent),ssFallback:e.ssFallback&&ji(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return c&&r&&Mn(u,c.clone(u)),u}function Mi(e=` `,t=0){return Y(hi,null,e,t)}function X(e){return e==null||typeof e==`boolean`?Y(gi):d(e)?Y(K,null,e.slice()):Ti(e)?Ni(e):Y(hi,null,String(e))}function Ni(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:ji(e)}function Pi(e,t){let n=0,{shapeFlag:r}=e;if(t==null)t=null;else if(d(t))n=16;else if(typeof t==`object`){if(r&65){let n=t.default;n&&(n._c&&(n._d=!1),Pi(e,n()),n._c&&(n._d=!0));return}{n=32;let r=t._;!r&&!Vr(t)?t._ctx=U:r===3&&U&&(U.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}}else if(h(t)){if(r&65){Pi(e,{default:t});return}t={default:t,_ctx:U},n=32}else t=String(t),r&64?(n=16,t=[Mi(t)]):n=8;e.children=t,e.shapeFlag|=n}function Fi(...e){let t={};for(let n=0;nQ||U,Bi,Vi;{let e=le(),t=(t,n)=>{let r;return(r=e[t])||(r=e[t]=[]),r.push(n),e=>{r.length>1?r.forEach(t=>t(e)):r[0](e)}};Bi=t(`__VUE_INSTANCE_SETTERS__`,e=>Q=e),Vi=t(`__VUE_SSR_SETTERS__`,e=>Gi=e)}var Hi=e=>{let t=Q;return Bi(e),e.scope.on(),()=>{e.scope.off(),Bi(t)}},Ui=()=>{Q&&Q.scope.off(),Bi(null)};function Wi(e){return e.vnode.shapeFlag&4}var Gi=!1;function Ki(e,t=!1,n=!1){t&&Vi(t);let{props:r,children:i}=e.vnode,a=Wi(e);Hr(e,r,a,t),ti(e,i,n||t);let o=a?qi(e,t):void 0;return t&&Vi(!1),o}function qi(e,t){let n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,sr);let{setup:r}=n;if(r){N();let n=e.setupContext=r.length>1?$i(e):null,i=Hi(e),a=$t(r,e,0,[e.props,n]),o=y(a);if(P(),i(),(o||e.sp)&&!zn(e)&&Pn(e),o){if(a.then(Ui,Ui),t)return a.then(n=>{Ji(e,n,t)}).catch(t=>{en(t,e,0)});e.asyncDep=a}else Ji(e,a,t)}else Zi(e,t)}function Ji(e,t,n){h(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:v(t)&&(e.setupState=Wt(t)),Zi(e,n)}var Yi,Xi;function Zi(e,t,n){let i=e.type;if(!e.render){if(!t&&Yi&&!i.render){let t=i.template||mr(e).template;if(t){let{isCustomElement:n,compilerOptions:r}=e.appContext.config,{delimiters:a,compilerOptions:o}=i;i.render=Yi(t,s(s({isCustomElement:n,delimiters:a},r),o))}}e.render=i.render||r,Xi&&Xi(e)}{let t=Hi(e);N();try{ur(e)}finally{P(),t()}}}var Qi={get(e,t){return F(e,`get`,``),e[t]}};function $i(e){return{attrs:new Proxy(e.attrs,Qi),slots:e.slots,emit:e.emit,expose:t=>{e.exposed=t||{}}}}function ea(e){return e.exposed?e.exposeProxy||=new Proxy(Wt(It(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in ar)return ar[n](e)},has(e,t){return t in e||t in ar}}):e.proxy}function ta(e){return h(e)&&`__vccOpts`in e}var na=(e,t)=>Kt(e,t,Gi),ra=`3.5.40`,ia=void 0,aa=typeof window<`u`&&window.trustedTypes;if(aa)try{ia=aa.createPolicy(`vue`,{createHTML:e=>e})}catch{}var oa=ia?e=>ia.createHTML(e):e=>e,sa=`http://www.w3.org/2000/svg`,ca=`http://www.w3.org/1998/Math/MathML`,$=typeof document<`u`?document:null,la=$&&$.createElement(`template`),ua={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i=t===`svg`?$.createElementNS(sa,e):t===`mathml`?$.createElementNS(ca,e):n?$.createElement(e,{is:n}):$.createElement(e);return e===`select`&&r&&r.multiple!=null&&i.setAttribute(`multiple`,r.multiple),i},createText:e=>$.createTextNode(e),createComment:e=>$.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>$.querySelector(e),setScopeId(e,t){e.setAttribute(t,``)},insertStaticContent(e,t,n,r,i,a){let o=n?n.previousSibling:t.lastChild;if(i&&(i===a||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),!(i===a||!(i=i.nextSibling)););else{la.innerHTML=oa(r===`svg`?`${e}`:r===`mathml`?`${e}`:e);let i=la.content;if(r===`svg`||r===`mathml`){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},da=Symbol(`_vtc`);function fa(e,t,n){let r=e[da];r&&(t=(t?[t,...r]:[...r]).join(` `)),t==null?e.removeAttribute(`class`):n?e.setAttribute(`class`,t):e.className=t}var pa=Symbol(`_vod`),ma=Symbol(`_vsh`),ha=Symbol(``),ga=/(?:^|;)\s*display\s*:/;function _a(e,t,n){let r=e.style,i=g(n),a=!1;if(n&&!i){if(t){if(g(t))for(let e of t.split(`;`)){let t=e.slice(0,e.indexOf(`:`)).trim();n[t]??ya(r,t,``)}else for(let e in t)n[e]??ya(r,e,``)}for(let i in n){i===`display`&&(a=!0);let o=n[i];o==null?ya(r,i,``):Ca(e,i,!g(t)&&t?t[i]:void 0,o)||ya(r,i,o)}}else if(i){if(t!==n){let e=r[ha];e&&(n+=`;`+e),r.cssText=n,a=ga.test(n)}}else t&&e.removeAttribute(`style`);pa in e&&(e[pa]=a?r.display:``,e[ma]&&(r.display=`none`))}var va=/\s*!important$/;function ya(e,t,n){if(d(n))n.forEach(n=>ya(e,t,n));else if(n??=``,t.startsWith(`--`))e.setProperty(t,n);else{let r=Sa(e,t);va.test(n)?e.setProperty(E(r),n.replace(va,``),`important`):e[r]=n}}var ba=[`Webkit`,`Moz`,`ms`],xa={};function Sa(e,t){let n=xa[t];if(n)return n;let r=T(t);if(r!==`filter`&&r in e)return xa[t]=r;r=ie(r);for(let n=0;nPa||=(Fa.then(()=>Pa=0),Date.now());function La(e,t){let n=e=>{if(!e._vts)e._vts=Date.now();else if(e._vts<=n.attached)return;let r=n.value;if(d(r)){let n=e.stopImmediatePropagation;e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0};let i=r.slice(),a=[e];for(let n=0;ne.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,za=(e,t,n,r,i,s)=>{let c=i===`svg`;t===`class`?fa(e,r,c):t===`style`?_a(e,n,r):a(t)?o(t)||Aa(e,t,n,r,s):(t[0]===`.`?(t=t.slice(1),!0):t[0]===`^`?(t=t.slice(1),!1):Ba(e,t,r,c))?(Ea(e,t,r),!e.tagName.includes(`-`)&&(t===`value`||t===`checked`||t===`selected`)&&Ta(e,t,r,c,s,t!==`value`)):e._isVueCE&&(Va(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!g(r)))?Ea(e,T(t),r,s,t):(t===`true-value`?e._trueValue=r:t===`false-value`&&(e._falseValue=r),Ta(e,t,r,c))};function Ba(e,t,n,r){if(r)return!!(t===`innerHTML`||t===`textContent`||t in e&&Ra(t)&&h(n));if(t===`spellcheck`||t===`draggable`||t===`translate`||t===`autocorrect`||t===`sandbox`&&e.tagName===`IFRAME`||t===`form`||t===`list`&&e.tagName===`INPUT`||t===`type`&&e.tagName===`TEXTAREA`)return!1;if(t===`width`||t===`height`){let t=e.tagName;if(t===`IMG`||t===`VIDEO`||t===`CANVAS`||t===`SOURCE`)return!1}return Ra(t)&&g(n)?!1:t in e}function Va(e,t){let n=e._def.props;if(!n)return!1;let r=T(t);return Array.isArray(n)?n.some(e=>T(e)===r):Object.keys(n).some(e=>T(e)===r)}var Ha=s({patchProp:za},ua),Ua;function Wa(){return Ua||=ri(Ha)}var Ga=((...e)=>{let t=Wa().createApp(...e),{mount:n}=t;return t.mount=e=>{let r=qa(e);if(!r)return;let i=t._component;!h(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent=``);let a=n(r,!1,Ka(r));return r instanceof Element&&(r.removeAttribute(`v-cloak`),r.setAttribute(`data-v-app`,``)),a},t});function Ka(e){if(e instanceof SVGElement)return`svg`;if(typeof MathMLElement==`function`&&e instanceof MathMLElement)return`mathml`}function qa(e){return g(e)?document.querySelector(e):e}var Ja={key:0},Ya={key:1},Xa=Nn({__name:`Fullscreen`,setup(e){let t=zt(!1);function n(){document.fullscreenElement?document.exitFullscreen&&document.exitFullscreen().then(()=>{t.value=!1}).catch(e=>{console.error(`Error attempting to exit full-screen mode: ${e.message} (${e.name})`)}):document.body.requestFullscreen().then(()=>{t.value=!0}).catch(e=>{console.error(`Error attempting to enable full-screen mode: ${e.message} (${e.name})`)})}return(e,r)=>(yi(),wi(`button`,{onClick:n},[t.value?(yi(),wi(`span`,Ya,`Exit Fullscreen`)):(yi(),wi(`span`,Ja,`Enter Fullscreen`))]))}}),Za=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},Qa=Za(Xa,[[`__scopeId`,`data-v-47ada44e`]]),$a=Za(Nn({__name:`TitleChange`,setup(e){function t(){let e=document.querySelector(`title`);e&&(e.textContent=Math.random().toString(36))}return(e,n)=>(yi(),wi(`button`,{onClick:t},[...n[0]||=[J(`span`,null,`Set random title`,-1)]]))}}),[[`__scopeId`,`data-v-02a37b8b`]]),eo={},to={target:`_blank`,href:`https://www.transgenderinfo.be/`};function no(e,t){return yi(),wi(`a`,to,[...t[0]||=[J(`span`,null,`Go to Somewhere`,-1)]])}var ro=Za(eo,[[`render`,no],[`__scopeId`,`data-v-e591ea9b`]]),io={class:`card`},ao=Za(Nn({__name:`HelloWorld`,props:{msg:{}},setup(e){return(t,n)=>(yi(),wi(K,null,[J(`h1`,null,xe(e.msg),1),J(`div`,io,[Y(Qa),Y($a),Y(ro)]),n[0]||=J(`p`,null,[Mi(` Check out `),J(`a`,{href:`https://vuejs.org/guide/quick-start.html#local`,target:`_blank`},`create-vue`),Mi(`, the official Vue + Vite starter `)],-1),n[1]||=J(`p`,null,[Mi(` Learn more about IDE Support for Vue in the `),J(`a`,{href:`https://vuejs.org/guide/scaling-up/tooling.html#ide-support`,target:`_blank`},`Vue Docs Scaling up Guide`),Mi(`. `)],-1),n[2]||=J(`p`,{class:`read-the-docs`},`Click on the Vite and Vue logos to learn more`,-1)],64))}}),[[`__scopeId`,`data-v-7e6b4dcb`]]);Ga(Za(Nn({__name:`App`,setup(e){return(e,t)=>(yi(),wi(K,null,[t[0]||=J(`div`,null,[J(`a`,{href:`https://vite.dev`,target:`_blank`},[J(`img`,{src:`/vite.svg`,class:`logo`,alt:`Vite logo`})]),J(`a`,{href:`https://vuejs.org/`,target:`_blank`},[J(`img`,{src:`data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20aria-hidden='true'%20role='img'%20class='iconify%20iconify--logos'%20width='37.07'%20height='36'%20preserveAspectRatio='xMidYMid%20meet'%20viewBox='0%200%20256%20198'%3e%3cpath%20fill='%2341B883'%20d='M204.8%200H256L128%20220.8L0%200h97.92L128%2051.2L157.44%200h47.36Z'%3e%3c/path%3e%3cpath%20fill='%2341B883'%20d='m0%200l128%20220.8L256%200h-51.2L128%20132.48L50.56%200H0Z'%3e%3c/path%3e%3cpath%20fill='%2335495E'%20d='M50.56%200L128%20133.12L204.8%200h-47.36L128%2051.2L97.92%200H50.56Z'%3e%3c/path%3e%3c/svg%3e`,class:`logo vue`,alt:`Vue logo`})])],-1),Y(ao,{msg:`Vite + Vue`})],64))}}),[[`__scopeId`,`data-v-4393f9f3`]])).mount(`#app`); \ No newline at end of file diff --git a/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/assets/index-D-FX-CIJ.css b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/assets/index-D-FX-CIJ.css new file mode 100644 index 000000000..b6c29fd85 --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/assets/index-D-FX-CIJ.css @@ -0,0 +1 @@ +:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;color:#ffffffde;font-synthesis:none;text-rendering:optimizelegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#242424;font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;font-weight:400;line-height:1.5}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}a{color:#646cff;-webkit-text-decoration:inherit;text-decoration:inherit;font-weight:500}a:hover{color:#535bf2}body{place-items:center;min-width:320px;min-height:100vh;margin:0;display:flex}h1{font-size:3.2em;line-height:1.1}button{cursor:pointer;background-color:#1a1a1a;border:1px solid #0000;border-radius:8px;padding:.6em 1.2em;font-family:inherit;font-size:1em;font-weight:500;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}.card{padding:2em}#app{text-align:center;max-width:1280px;margin:0 auto;padding:2rem}@media (prefers-color-scheme:light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}button[data-v-47ada44e],button[data-v-02a37b8b],button[data-v-e591ea9b]{color:#000;background-color:#90ee90;border:2px solid #006400;border-radius:5px;margin:1rem;padding:10px}.read-the-docs[data-v-7e6b4dcb]{color:#888}.logo[data-v-4393f9f3]{will-change:filter;height:6em;padding:1.5em;transition:filter .3s}.logo[data-v-4393f9f3]:hover{filter:drop-shadow(0 0 2em #646cffaa)}.logo.vue[data-v-4393f9f3]:hover{filter:drop-shadow(0 0 2em #42b883aa)} diff --git a/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/index.html b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/index.html new file mode 100644 index 000000000..92800edf8 --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/index.html @@ -0,0 +1,14 @@ + + + + + + + Vite + Vue + TS + + + + +
+ + diff --git a/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/vite.svg b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/vite.svg new file mode 100644 index 000000000..6447244f1 --- /dev/null +++ b/examples/WebApp/InfiniFrameExample.WebApp.Vue/wwwroot/vite.svg @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/examples/Directory.Build.props b/examples/WebApp/InfiniFrameExample.WebApp/InfiniFrameExample.WebApp.csproj similarity index 50% rename from examples/Directory.Build.props rename to examples/WebApp/InfiniFrameExample.WebApp/InfiniFrameExample.WebApp.csproj index abb8d2e96..bc97e9487 100644 --- a/examples/Directory.Build.props +++ b/examples/WebApp/InfiniFrameExample.WebApp/InfiniFrameExample.WebApp.csproj @@ -1,22 +1,31 @@ - - + - Exe - net10.0 + Exe 14.0 - enable enable false true - ../../assets/favicon.ico + ../../../assets/favicon.ico - + + + + + wwwroot/favicon.ico Always + + + + Always + + + + diff --git a/examples/InfiniFrameExample.WebApp/Program.cs b/examples/WebApp/InfiniFrameExample.WebApp/Program.cs similarity index 100% rename from examples/InfiniFrameExample.WebApp/Program.cs rename to examples/WebApp/InfiniFrameExample.WebApp/Program.cs diff --git a/src/InfiniFrame.BlazorWebView/FileProviders/Static/ManifestCandidate.cs b/src/InfiniFrame.BlazorWebView/FileProviders/Static/ManifestCandidate.cs index b7f3100d7..52ecf03fc 100644 --- a/src/InfiniFrame.BlazorWebView/FileProviders/Static/ManifestCandidate.cs +++ b/src/InfiniFrame.BlazorWebView/FileProviders/Static/ManifestCandidate.cs @@ -7,5 +7,6 @@ namespace InfiniFrame.BlazorWebView.FileProviders.Static; // --------------------------------------------------------------------------------------------------------------------- internal sealed record ManifestCandidate( string ManifestPath, - int BaseScore -); \ No newline at end of file + int BaseScore, + Stream? ResourceStream = null +); diff --git a/src/InfiniFrame.BlazorWebView/FileProviders/Static/PackModeFileProvider.cs b/src/InfiniFrame.BlazorWebView/FileProviders/Static/PackModeFileProvider.cs new file mode 100644 index 000000000..587069236 --- /dev/null +++ b/src/InfiniFrame.BlazorWebView/FileProviders/Static/PackModeFileProvider.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; +using System.Reflection; + +namespace InfiniFrame.BlazorWebView.FileProviders.Static; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// A file provider for single-file packed deployments that serves static web assets from +/// embedded resources and falls back to a physical wwwroot directory. +/// Bypasses the manifest-based resolution used by . +/// +internal sealed class PackModeFileProvider : IFileProvider { + private readonly CompositeFileProvider _composite; + + public PackModeFileProvider(Assembly entryAssembly, string baseDirectory) { + var providers = new List(); + + // Embedded resources with "publish." prefix (StaticWebAsset items from NuGet packages) + providers.Add(new EmbeddedFileProvider(entryAssembly, "publish")); + + // Embedded resources with "{assemblyName}.wwwroot." prefix (project wwwroot files) + string? assemblyName = entryAssembly.GetName().Name; + if (!string.IsNullOrEmpty(assemblyName)) { + providers.Add(new EmbeddedFileProvider(entryAssembly, $"{assemblyName}.wwwroot")); + } + + // Physical wwwroot directory fallback (framework assets like _framework/blazor.webview.js). + // In single-file self-extracting mode, BaseDirectory points to the temp extraction dir + // but the real wwwroot is alongside the exe. Check both locations. + string? exeDir = Path.GetDirectoryName(Environment.ProcessPath); + string[] searchPaths = exeDir is not null && exeDir != baseDirectory + ? [Path.Join(baseDirectory, "wwwroot"), Path.Join(exeDir, "wwwroot")] + : [Path.Join(baseDirectory, "wwwroot")]; + + foreach (string wwwrootPath in searchPaths) { + if (Directory.Exists(wwwrootPath)) { + providers.Add(new PhysicalFileProvider(wwwrootPath)); + break; + } + } + + _composite = new CompositeFileProvider(providers); + } + + public IFileInfo GetFileInfo(string subpath) => _composite.GetFileInfo(subpath); + + public IDirectoryContents GetDirectoryContents(string subpath) => _composite.GetDirectoryContents(subpath); + + public IChangeToken Watch(string filter) => NullChangeToken.Singleton; + + /// + /// Creates a if the entry assembly contains embedded + /// resources with the "publish." prefix, indicating a packed deployment. + /// + public static PackModeFileProvider? TryCreate(string baseDirectory) { + Assembly? entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly is null) return null; + + string[] resourceNames; + try { + resourceNames = entryAssembly.GetManifestResourceNames(); + } + catch { + return null; + } + + bool hasPublishResources = resourceNames.Any(r => r.StartsWith("publish.", StringComparison.Ordinal)); + if (!hasPublishResources) return null; + + return new PackModeFileProvider(entryAssembly, baseDirectory); + } +} diff --git a/src/InfiniFrame.BlazorWebView/FileProviders/Static/StaticWebAssetsRuntimeFileProvider.cs b/src/InfiniFrame.BlazorWebView/FileProviders/Static/StaticWebAssetsRuntimeFileProvider.cs index f3bcd7587..688ffaac4 100644 --- a/src/InfiniFrame.BlazorWebView/FileProviders/Static/StaticWebAssetsRuntimeFileProvider.cs +++ b/src/InfiniFrame.BlazorWebView/FileProviders/Static/StaticWebAssetsRuntimeFileProvider.cs @@ -13,20 +13,27 @@ namespace InfiniFrame.BlazorWebView.FileProviders.Static; // --------------------------------------------------------------------------------------------------------------------- // Code // --------------------------------------------------------------------------------------------------------------------- -internal sealed class StaticWebAssetsRuntimeFileProvider(string[] contentRoots, StaticWebAssetNode root) : IFileProvider { +internal sealed class StaticWebAssetsRuntimeFileProvider(string baseDirectory, string[] contentRoots, StaticWebAssetNode root, Assembly? embeddedAssembly = null) : IFileProvider { private const RegexOptions PatternRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase; private readonly ConcurrentDictionary _patternRegexCache = new(StringComparer.Ordinal); private IFileProvider[] ContentRootProviders { get; } = contentRoots - .Select(static rootPath => { + .Select(IFileProvider (rootPath) => { string normalizedRoot = rootPath; if (!Path.IsPathRooted(normalizedRoot)) { normalizedRoot = Path.GetFullPath(normalizedRoot); } - return Directory.Exists(normalizedRoot) - ? (IFileProvider)new PhysicalFileProvider(normalizedRoot) - : new NullFileProvider(); + if (!Directory.Exists(normalizedRoot) && Path.IsPathRooted(rootPath)) { + string? fallback = TryResolveRelativeContentRoot(baseDirectory, rootPath); + if (fallback is not null) { + normalizedRoot = fallback; + } + } + + if (Directory.Exists(normalizedRoot)) return new PhysicalFileProvider(normalizedRoot); + if (embeddedAssembly is not null) return new EmbeddedFileProvider(embeddedAssembly, "publish"); + return new NullFileProvider(); }) .ToArray(); @@ -115,15 +122,17 @@ public IDirectoryContents GetDirectoryContents(string subpath) { // ----------------------------------------------------------------------------------------------------------------- // Constructors // ----------------------------------------------------------------------------------------------------------------- - public static IFileProvider? TryCreate(string baseDirectory) { + public static IFileProvider? TryCreate(string baseDirectory, Assembly? embeddedAssembly = null) { if (string.IsNullOrWhiteSpace(baseDirectory)) return null; - ManifestCandidate[] candidates = GetManifestCandidates(baseDirectory).ToArray(); + ManifestCandidate[] candidates = GetManifestCandidates(baseDirectory) + .Concat(GetManifestCandidatesFromResources(embeddedAssembly)) + .ToArray(); if (candidates.Length == 0) return null; ScoredManifestCandidate? bestCandidate = null; foreach (ManifestCandidate candidate in candidates) { - if (!TryLoadManifest(candidate.ManifestPath, out StaticWebAssetManifest? manifest)) continue; + if (!TryLoadManifest(candidate.ManifestPath, candidate.ResourceStream, out StaticWebAssetManifest? manifest)) continue; if (manifest?.ContentRoots is null || manifest.ContentRoots.Length == 0 || manifest.Root is null) continue; int score = candidate.BaseScore; @@ -148,7 +157,7 @@ public IDirectoryContents GetDirectoryContents(string subpath) { : Path.GetFullPath(Path.Join(baseDirectory, contentRoot))) .ToArray(); - return new StaticWebAssetsRuntimeFileProvider(contentRoots, bestCandidate.Manifest.Root!); + return new StaticWebAssetsRuntimeFileProvider(baseDirectory, contentRoots, bestCandidate.Manifest.Root!, embeddedAssembly); } catch (ArgumentException) { return null; @@ -198,10 +207,18 @@ private static IEnumerable GetManifestCandidates(string baseD } } - private static bool TryLoadManifest(string manifestPath, out StaticWebAssetManifest? manifest) { + private static bool TryLoadManifest(string manifestPath, Stream? resourceStream, out StaticWebAssetManifest? manifest) { manifest = null; try { - string json = File.ReadAllText(manifestPath); + string json; + if (resourceStream is not null) { + using var reader = new StreamReader(resourceStream); + json = reader.ReadToEnd(); + } + else { + json = File.ReadAllText(manifestPath); + } + manifest = JsonSerializer.Deserialize(json, StaticWebAssetsManifestJsonContext.Default.StaticWebAssetManifest); return manifest is not null; } @@ -210,6 +227,60 @@ private static bool TryLoadManifest(string manifestPath, out StaticWebAssetManif } } + private static IEnumerable GetManifestCandidatesFromResources(Assembly? embeddedAssembly) { + if (embeddedAssembly is null) yield break; + + string? entryAssemblyName = Assembly.GetEntryAssembly()?.GetName().Name; + string friendlyName = Path.GetFileNameWithoutExtension(AppDomain.CurrentDomain.FriendlyName); + string? processName = Environment.ProcessPath is { Length: > 0 } + ? Path.GetFileNameWithoutExtension(Environment.ProcessPath) + : null; + + string[] resourceNames; + try { + resourceNames = embeddedAssembly.GetManifestResourceNames(); + } + catch { + yield break; + } + + foreach (string resourceName in resourceNames) { + if (!resourceName.EndsWith(".staticwebassets.runtime.json", StringComparison.OrdinalIgnoreCase)) continue; + + // Match disk behavior: strip ".staticwebassets.runtime.json" suffix to get the manifest name + string manifestName = resourceName[..^".staticwebassets.runtime.json".Length]; + + int baseScore = 0; + + if (!string.IsNullOrWhiteSpace(entryAssemblyName) + && string.Equals(manifestName, entryAssemblyName, StringComparison.OrdinalIgnoreCase)) { + baseScore += 1000; + } + + if (!string.IsNullOrWhiteSpace(friendlyName) + && string.Equals(manifestName, friendlyName, StringComparison.OrdinalIgnoreCase)) { + baseScore += 500; + } + + if (!string.IsNullOrWhiteSpace(processName) + && string.Equals(manifestName, processName, StringComparison.OrdinalIgnoreCase)) { + baseScore += 250; + } + + Stream? stream = null; + try { + stream = embeddedAssembly.GetManifestResourceStream(resourceName); + } + catch { + // Skip resources that can't be opened + } + + if (stream is not null) { + yield return new ManifestCandidate(resourceName, baseScore, stream); + } + } + } + private static bool ContainsTopLevelNode(StaticWebAssetNode root, string name) { if (root.Children is null || root.Children.Count == 0) return false; @@ -322,4 +393,29 @@ private static string NormalizeSubPath(string? subPath) { .TrimStart('/', '\\') .Replace('\\', '/'); } -} \ No newline at end of file + + private static string? TryResolveRelativeContentRoot(string baseDirectory, string originalPath) { + string fileName = Path.GetFileName(originalPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (string.IsNullOrWhiteSpace(fileName)) return null; + + string searchPattern = fileName; + string? directory = baseDirectory; + while (!string.IsNullOrEmpty(directory)) { + string candidate = Path.Combine(directory, searchPattern); + if (Directory.Exists(candidate)) return candidate; + + string[] children = []; + try { + children = Directory.GetDirectories(directory, searchPattern, SearchOption.TopDirectoryOnly); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + + if (children.Length > 0) return children[0]; + + directory = Path.GetDirectoryName(directory); + } + + return null; + } +} diff --git a/src/InfiniFrame.BlazorWebView/InfiniFrame.BlazorWebView.csproj b/src/InfiniFrame.BlazorWebView/InfiniFrame.BlazorWebView.csproj index 44fb77607..845b7c9f6 100644 --- a/src/InfiniFrame.BlazorWebView/InfiniFrame.BlazorWebView.csproj +++ b/src/InfiniFrame.BlazorWebView/InfiniFrame.BlazorWebView.csproj @@ -4,6 +4,10 @@ Library + + + + diff --git a/src/InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilder.cs b/src/InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilder.cs index 0c3707085..b3a2debd8 100644 --- a/src/InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilder.cs +++ b/src/InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilder.cs @@ -1,6 +1,7 @@ // --------------------------------------------------------------------------------------------------------------------- // Imports // --------------------------------------------------------------------------------------------------------------------- +using System.Reflection; using InfiniFrame.BlazorWebView.FileProviders.Static; using InfiniFrame.Security; using InfiniFrame.StaticAssets; @@ -96,9 +97,14 @@ private static IFileProvider ConfigureFileProvider(IFileProvider? fileProvider) if (fileProvider is not null) return fileProvider; string baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + + // Check if we're in single-file pack mode (embedded "publish.*" resources) + PackModeFileProvider? packModeProvider = PackModeFileProvider.TryCreate(baseDirectory); + if (packModeProvider is not null) return packModeProvider; + var providers = new List(); - IFileProvider? staticWebAssetsProvider = StaticWebAssetsRuntimeFileProvider.TryCreate(baseDirectory); + IFileProvider? staticWebAssetsProvider = StaticWebAssetsRuntimeFileProvider.TryCreate(baseDirectory, Assembly.GetEntryAssembly()); if (staticWebAssetsProvider is not null) providers.Add(staticWebAssetsProvider); string defaultWwwrootPath = Path.Join(baseDirectory, "wwwroot"); diff --git a/src/InfiniFrame.BlazorWebView/InfiniFrameHttpHandler.cs b/src/InfiniFrame.BlazorWebView/InfiniFrameHttpHandler.cs index fc142710e..b26d5f246 100644 --- a/src/InfiniFrame.BlazorWebView/InfiniFrameHttpHandler.cs +++ b/src/InfiniFrame.BlazorWebView/InfiniFrameHttpHandler.cs @@ -22,6 +22,7 @@ public class InfiniFrameHttpHandler : DelegatingHandler { /// The WebView manager used to handle custom scheme requests. /// The inner handler for unhandled HTTP requests. Defaults to . public InfiniFrameHttpHandler(IInfiniFrameWebViewManager manager, HttpMessageHandler? innerHandler = null) { + ArgumentNullException.ThrowIfNull(manager); _manager = manager; //the last (inner) handler in the pipeline should be a "real" handler. diff --git a/src/InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContext.cs b/src/InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContext.cs index 3f13fffff..c6d33459f 100644 --- a/src/InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContext.cs +++ b/src/InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContext.cs @@ -154,7 +154,7 @@ public override void Post(SendOrPostCallback d, object? state) { /// a deadlock cycle can form. /// /// - /// This is expected to be rare in practice — Blazor's renderer uses , not + /// This is expected to be rare in practice, Blazor's renderer uses , not /// . However, if a component synchronously awaits a result that triggers /// re-entrant dispatch under heavy load, a deadlock is possible. Callers should prefer /// or where possible. @@ -357,4 +357,4 @@ private static async Task CompleteAsync(CallbackTaskCompletionSource { expect(instance.window.features.decorations).toBeDefined(); }); + it("preserves existing window when features are already set", () => { + const existingWindow = {features: {decorations: {}}}; + const instance = new InfiniFrame({window: existingWindow as any}); + + expect(instance.window).toBe(existingWindow); + }); + it("does not define a legacy window.__infiniframe host", async () => { const setSpy = vi.spyOn(Object, "defineProperty"); diff --git a/src/InfiniFrame.Js/TypeScript/InfiniFrameHostMessaging.test.ts b/src/InfiniFrame.Js/TypeScript/InfiniFrameHostMessaging.test.ts index c7525d759..3caae7439 100644 --- a/src/InfiniFrame.Js/TypeScript/InfiniFrameHostMessaging.test.ts +++ b/src/InfiniFrame.Js/TypeScript/InfiniFrameHostMessaging.test.ts @@ -108,6 +108,18 @@ describe("InfiniFrameHostMessaging", () => { expect(registrations[0][1]).toBe(blankTargetHandler); }); + it("registers fullscreen change handler only once", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + const registerMessage = JSON.stringify({id: ReceiveFromHostMessageIds.registerFullscreenChange, command: "Post", version: 2}); + + getReceiveCallback()(registerMessage); + getReceiveCallback()(registerMessage); + + const fullscreenRegistrations = addEventListenerSpy.mock.calls.filter(call => call[0] === "fullscreenchange"); + expect(fullscreenRegistrations.length).toBe(1); + }); + it("registers title observer on registerTitleChange message", async () => { const title = document.createElement("title"); title.textContent = "My Title"; @@ -129,12 +141,279 @@ describe("InfiniFrameHostMessaging", () => { const closeMessages = postData.mock.calls .map(call => call[0]) .filter( - message => typeof message === "object" - && message !== null + message => typeof message === "object" + && message !== null && (message as { id?: string }).id === SendToHostMessageIds.windowClose ); expect(closeMessages.length).toBe(1); window.close = originalClose; }); + + it("sends readyAck and marks handshake as acknowledged", async () => { + const {messaging, getReceiveCallback} = await setupHostMessaging(); + + expect(messaging.isReady).toBe(false); + + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.readyAck, command: "Post", version: 2})); + + expect(messaging.isReady).toBe(true); + await expect(messaging.ready).resolves.toBeUndefined(); + }); + + it("readyAck only resolves once", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.readyAck, command: "Post", version: 2})); + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.readyAck, command: "Post", version: 2})); + + // Should not throw + }); + + it("unregisterMessageReceivedHandler removes handler", async () => { + const {messaging, getReceiveCallback} = await setupHostMessaging(); + const handler = vi.fn(); + messaging.assignMessageReceivedHandler("test:event", handler); + messaging.unregisterMessageReceivedHandler("test:event"); + + getReceiveCallback()(JSON.stringify({id: "test:event", command: "Post", data: "payload", version: 2})); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("ignores messages with no registered handler", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + getReceiveCallback()(JSON.stringify({id: "unregistered:event", command: "Post", data: "payload", version: 2})); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("ignores invalid messages (non-string)", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + getReceiveCallback()(123 as any); + + warnSpy.mockRestore(); + }); + + it("ignores empty messages", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + getReceiveCallback()(""); + }); + + it("ignores messages with parse errors", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + getReceiveCallback()("not-valid-json{{{"); + }); + + it("sends webMessageAckResponse for acknowledged messages", async () => { + const {messaging, getReceiveCallback, postData} = await setupHostMessaging(); + messaging.assignMessageReceivedHandler("custom:event", vi.fn()); + + getReceiveCallback()(JSON.stringify({ + id: ReceiveFromHostMessageIds.webMessageAckRequest, + command: "Post", + data: JSON.stringify({OperationId: "op-1", Message: JSON.stringify({id: "custom:event", command: "Post", data: "hello", version: 2})}), + version: 2 + })); + + const ackResponses = postData.mock.calls + .map((call: any[]) => call[0]) + .filter((msg: any) => typeof msg === "object" && msg?.id === SendToHostMessageIds.webMessageAckResponse); + expect(ackResponses.length).toBe(1); + }); + + it("ignores webMessageAckRequest with missing OperationId", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + getReceiveCallback()(JSON.stringify({ + id: ReceiveFromHostMessageIds.webMessageAckRequest, + command: "Post", + data: JSON.stringify({Message: "hello"}), + version: 2 + })); + }); + + it("ignores webMessageAckRequest with non-string Message", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + getReceiveCallback()(JSON.stringify({ + id: ReceiveFromHostMessageIds.webMessageAckRequest, + command: "Post", + data: JSON.stringify({OperationId: "op-1", Message: 123}), + version: 2 + })); + }); + + it("routes javascript eval requests", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + + // eval requests route through handleJavaScriptEvalRequest which needs window.infiniframe.messaging + // This is the real InfiniFrameHostMessaging instance, so eval sends response via postData + getReceiveCallback()(JSON.stringify({ + id: "__infiniframe:javascript:eval", + command: "Post", + data: JSON.stringify({requestId: "req-1", script: "1+1"}), + version: 2 + })); + + // The eval handler calls handleJavaScriptEvalRequest which calls messaging.sendMessageToHost + // Since messaging IS the real instance, it calls postData with eval:result + }); + + it("routes javascript eval responses", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + + getReceiveCallback()(JSON.stringify({ + id: "__infiniframe:javascript:eval:response", + command: "Post", + data: JSON.stringify({requestId: "req-1", result: "42"}), + version: 2 + })); + }); + + it("ignores javascript eval response with no payload", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + getReceiveCallback()(JSON.stringify({ + id: "__infiniframe:javascript:eval:response", + command: "Post", + version: 2 + })); + }); + + it("ignores javascript eval request with no payload", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + getReceiveCallback()(JSON.stringify({ + id: "__infiniframe:javascript:eval", + command: "Post", + version: 2 + })); + }); + + it("getMessageFromHostAsync throws when getDataAsync not available", async () => { + const {messaging} = await setupHostMessaging(); + // Access the real host object that was stored during construction + const host = testWindow.infiniframe?.host as any; + delete host?.getDataAsync; + + await expect(messaging.getMessageFromHostAsync("test")).rejects.toThrow(); + }); + + it("sendMessageToHost warns when host bridge not initialized", async () => { + const {messaging} = await setupHostMessaging(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + (testWindow.infiniframe.host as any).postData = undefined; + + messaging.sendMessageToHost("test" as any); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("assignWebMessageReceiver warns when host bridge not available", async () => { + // @ts-ignore + testWindow.infiniframe = {host: undefined}; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const module = await import("././InfiniFrameHostMessaging"); + new module.default(); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("registerTitleChange with no existing title element creates head observer", async () => { + // Remove any existing title elements + document.querySelectorAll("title").forEach(el => el.remove()); + + const {getReceiveCallback, titleObserverObserve} = await setupHostMessaging(); + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.registerTitleChange, command: "Post", version: 2})); + + // Should not throw even with no title element + expect(titleObserverObserve).not.toHaveBeenCalled(); + }); + + it("registerTitleChange is idempotent", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.registerTitleChange, command: "Post", version: 2})); + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.registerTitleChange, command: "Post", version: 2})); + + // Should only observe once + }); + + it("registerFullscreenChange sends fullscreenEnter when fullscreenElement exists", async () => { + const {getReceiveCallback, postData} = await setupHostMessaging(); + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.registerFullscreenChange, command: "Post", version: 2})); + + // Find the fullscreenchange handler + const fullscreenHandler = addEventListenerSpy.mock.calls.find( + (call: any[]) => call[0] === "fullscreenchange" + )?.[1] as (e: Event) => void; + + if (fullscreenHandler) { + // Mock fullscreenElement to be truthy + Object.defineProperty(document, "fullscreenElement", {value: document.body, configurable: true}); + fullscreenHandler(new Event("fullscreenchange")); + + const fullscreenMessages = postData.mock.calls + .map((call: any[]) => call[0]) + .filter((msg: any) => typeof msg === "object" && msg?.id === SendToHostMessageIds.fullscreenEnter); + expect(fullscreenMessages.length).toBe(1); + + // Reset + Object.defineProperty(document, "fullscreenElement", {value: null, configurable: true}); + } + }); + + it("registerFullscreenChange sends fullscreenExit when no fullscreenElement", async () => { + const {getReceiveCallback, postData} = await setupHostMessaging(); + const addEventListenerSpy = vi.spyOn(document, "addEventListener"); + + getReceiveCallback()(JSON.stringify({id: ReceiveFromHostMessageIds.registerFullscreenChange, command: "Post", version: 2})); + + const fullscreenHandler = addEventListenerSpy.mock.calls.find( + (call: any[]) => call[0] === "fullscreenchange" + )?.[1] as (e: Event) => void; + + if (fullscreenHandler) { + Object.defineProperty(document, "fullscreenElement", {value: null, configurable: true}); + fullscreenHandler(new Event("fullscreenchange")); + + const fullscreenMessages = postData.mock.calls + .map((call: any[]) => call[0]) + .filter((msg: any) => typeof msg === "object" && msg?.id === SendToHostMessageIds.fullscreenExit); + expect(fullscreenMessages.length).toBe(1); + } + }); + + it("webMessageAckRequest with malformed JSON does not throw", async () => { + const {getReceiveCallback} = await setupHostMessaging(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + getReceiveCallback()(JSON.stringify({ + id: ReceiveFromHostMessageIds.webMessageAckRequest, + command: "Post", + data: "not-json", + version: 2 + })); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("handleInteropMessage returns false for non-string messages", async () => { + const {messaging} = await setupHostMessaging(); + // The handleInteropMessage is private, but we can test it indirectly + // through the receive callback with a non-string message + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const {getReceiveCallback} = await setupHostMessaging(); + getReceiveCallback()(123 as any); + warnSpy.mockRestore(); + }); }); diff --git a/src/InfiniFrame.Js/TypeScript/InfiniFrameUtils.test.ts b/src/InfiniFrame.Js/TypeScript/InfiniFrameUtils.test.ts index 7677749bf..59ef8a793 100644 --- a/src/InfiniFrame.Js/TypeScript/InfiniFrameUtils.test.ts +++ b/src/InfiniFrame.Js/TypeScript/InfiniFrameUtils.test.ts @@ -8,36 +8,76 @@ import {InfiniFrameUtils} from "./InfiniFrameUtils"; // Code // --------------------------------------------------------------------------------------------------------------------- describe("InfiniFrameUtils", () => { - it("forwards setPointerCapture to element", () => { - const utils = new InfiniFrameUtils(); + describe("setPointerCapture", () => { + it("forwards to element when not already captured", () => { + const utils = new InfiniFrameUtils(); + const setPointerCapture = vi.fn(); + const hasPointerCapture = vi.fn(() => false); + const element = {setPointerCapture, hasPointerCapture} as unknown as Element; - const setPointerCapture = vi.fn(); - const hasPointerCapture = vi.fn(() => false); - - const element = { - setPointerCapture, - hasPointerCapture - } as unknown as Element; + utils.setPointerCapture(element, 10); - utils.setPointerCapture(element, 10); + expect(setPointerCapture).toHaveBeenCalledWith(10); + }); - expect(setPointerCapture).toHaveBeenCalledWith(10); + it("skips when element is null", () => { + const utils = new InfiniFrameUtils(); + utils.setPointerCapture(null as any, 10); + }); + + it("skips when pointerId is null", () => { + const utils = new InfiniFrameUtils(); + const element = {setPointerCapture: vi.fn(), hasPointerCapture: vi.fn()} as unknown as Element; + utils.setPointerCapture(element, null as any); + expect(element.setPointerCapture).not.toHaveBeenCalled(); + }); + + it("skips when already captured", () => { + const utils = new InfiniFrameUtils(); + const setPointerCapture = vi.fn(); + const hasPointerCapture = vi.fn(() => true); + const element = {setPointerCapture, hasPointerCapture} as unknown as Element; + + utils.setPointerCapture(element, 10); + + expect(setPointerCapture).not.toHaveBeenCalled(); + }); }); - it("forwards releasePointerCapture to element", () => { - const utils = new InfiniFrameUtils(); + describe("releasePointerCapture", () => { + it("forwards to element when captured", () => { + const utils = new InfiniFrameUtils(); + const releasePointerCapture = vi.fn(); + const hasPointerCapture = vi.fn(() => true); + const element = {releasePointerCapture, hasPointerCapture} as unknown as Element; + + utils.releasePointerCapture(element, 10); + + expect(hasPointerCapture).toHaveBeenCalledWith(10); + expect(releasePointerCapture).toHaveBeenCalledWith(10); + }); + + it("skips when element is null", () => { + const utils = new InfiniFrameUtils(); + utils.releasePointerCapture(null as any, 10); + }); - const releasePointerCapture = vi.fn(); - const hasPointerCapture = vi.fn(() => true); + it("skips when pointerId is null", () => { + const utils = new InfiniFrameUtils(); + const element = {releasePointerCapture: vi.fn(), hasPointerCapture: vi.fn()} as unknown as Element; + utils.releasePointerCapture(element, null as any); + expect(element.releasePointerCapture).not.toHaveBeenCalled(); + }); - const element = { - releasePointerCapture, - hasPointerCapture - } as unknown as Element; + it("skips when not captured", () => { + const utils = new InfiniFrameUtils(); + const releasePointerCapture = vi.fn(); + const hasPointerCapture = vi.fn(() => false); + const element = {releasePointerCapture, hasPointerCapture} as unknown as Element; - utils.releasePointerCapture(element, 10); + utils.releasePointerCapture(element, 10); - expect(hasPointerCapture).toHaveBeenCalledWith(10); - expect(releasePointerCapture).toHaveBeenCalledWith(10); + expect(releasePointerCapture).not.toHaveBeenCalled(); + }); }); -}); \ No newline at end of file +}); diff --git a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.test.ts b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.test.ts index b17b4897f..c61addcee 100644 --- a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.test.ts +++ b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.test.ts @@ -3,7 +3,7 @@ // --------------------------------------------------------------------------------------------------------------------- import {beforeEach, describe, expect, it, vi} from "vitest"; import type {InfiniFrameSetup} from "../../Contracts"; -import {installNativeInteropBridge} from "./NativeInteropBridge"; +import {installNativeInteropBridge, resetNativeInteropBridgeState} from "./NativeInteropBridge"; // --------------------------------------------------------------------------------------------------------------------- // Code @@ -13,66 +13,463 @@ describe("NativeInteropBridge", () => { beforeEach(() => { setup = createSetup(); - delete window.infiniframe; - delete window.chrome; + delete (window as any).infiniframe; + delete (window as any).chrome; + delete (window as any).webkit; + resetNativeInteropBridgeState(); vi.restoreAllMocks(); }); - it("normalizes object envelopes to string for existing postData handlers", () => { - const existingPostData = vi.fn(); - window.infiniframe = { - host: { - postData: existingPostData, - receiveCallback: vi.fn() - }, - messaging: undefined!, - window: undefined!, - utils: undefined! - }; - - installNativeInteropBridge(setup); - window.infiniframe.host!.postData({id: "ping", command: "Post", data: "hello", version: 2}); - - expect(existingPostData).toHaveBeenCalledTimes(1); - expect(existingPostData.mock.calls[0][0]).toBe("{\"id\":\"ping\",\"command\":\"Post\",\"data\":\"hello\",\"version\":2}"); + describe("initialization guard", () => { + it("does nothing if already initialized", () => { + setup.nativeInteropBridgeInitialized = true; + installNativeInteropBridge(setup); + expect((window as any).infiniframe).toBeUndefined(); + }); + + it("sets nativeInteropBridgeInitialized to true", () => { + installNativeInteropBridge(setup); + expect(setup.nativeInteropBridgeInitialized).toBe(true); + }); + + it("creates window.infiniframe if missing", () => { + installNativeInteropBridge(setup); + expect(window.infiniframe).toBeDefined(); + }); + + it("preserves existing window.infiniframe properties", () => { + (window as any).infiniframe = {existing: true}; + installNativeInteropBridge(setup); + expect((window as any).infiniframe.existing).toBe(true); + }); }); - it("falls back to object payload when existing postData rejects string payloads", () => { - const existingPostData = vi.fn((payload: unknown) => { - if (typeof payload === "string") throw new Error("String payloads not supported."); - }); - window.infiniframe = { - host: { - postData: existingPostData, - receiveCallback: vi.fn() - }, - messaging: undefined!, - window: undefined!, - utils: undefined! - }; - - installNativeInteropBridge(setup); - window.infiniframe.host!.postData({id: "ping", command: "Post", data: "hello", version: 2}); - - expect(existingPostData).toHaveBeenCalledTimes(2); - expect(typeof existingPostData.mock.calls[0][0]).toBe("string"); - expect(existingPostData.mock.calls[1][0]).toEqual({id: "ping", command: "Post", data: "hello", version: 2}); + describe("postData - string payload", () => { + it("dispatches string payload via existing postData", () => { + const existingPostData = vi.fn(); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData("hello world"); + + expect(existingPostData).toHaveBeenCalledWith("hello world"); + }); + + it("ignores empty string payload", () => { + const existingPostData = vi.fn(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData(" "); + + expect(existingPostData).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith("Ignoring empty host bridge payload."); + warnSpy.mockRestore(); + }); + + it("falls back to chrome.webview.postMessage when no existing bridge", () => { + const postData = vi.fn(); + window.chrome = {webview: {postMessage: postData, addEventListener: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData("test message"); + + expect(postData).toHaveBeenCalledWith("test message"); + }); + + it("falls back to webKit when no chrome.webview", () => { + const postData = vi.fn(); + window.webkit = {messageHandlers: {infiniFrameInterop: {postMessage: postData}}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData("test message"); + + expect(postData).toHaveBeenCalledWith("test message"); + }); + + it("warns when no platform transport available", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.infiniframe = {host: {receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData("test"); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("falls back to platform when existing postData throws on string", () => { + const existingPostData = vi.fn((payload: unknown) => { + if (typeof payload === "string") throw new Error("No strings"); + }); + const chromePost = vi.fn(); + window.chrome = {webview: {postMessage: chromePost, addEventListener: vi.fn()}} as any; + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData("hello"); + + expect(chromePost).toHaveBeenCalledWith("hello"); + }); }); - it("uses platform transport when no existing bridge callback exists", () => { - const postData = vi.fn(); - window.chrome = { - webview: { - postMessage: postData, - addEventListener: vi.fn() - } - }; + describe("postData - envelope payload", () => { + it("normalizes object envelopes to string for existing postData handlers", () => { + const existingPostData = vi.fn(); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData({id: "ping", command: "Post", data: "hello", version: 2}); + + expect(existingPostData).toHaveBeenCalledTimes(1); + expect(existingPostData.mock.calls[0][0]).toBe("{\"id\":\"ping\",\"command\":\"Post\",\"data\":\"hello\",\"version\":2}"); + }); + + it("falls back to object payload when existing postData rejects string", () => { + const existingPostData = vi.fn((payload: unknown) => { + if (typeof payload === "string") throw new Error("String payloads not supported."); + }); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData({id: "ping", command: "Post", data: "hello", version: 2}); + + expect(existingPostData).toHaveBeenCalledTimes(2); + expect(typeof existingPostData.mock.calls[0][0]).toBe("string"); + expect(existingPostData.mock.calls[1][0]).toEqual({id: "ping", command: "Post", data: "hello", version: 2}); + }); + + it("uses platform transport when no existing bridge callback", () => { + const postData = vi.fn(); + window.chrome = {webview: {postMessage: postData, addEventListener: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData({id: "ping", command: "Post", data: "hello", version: 2}); + + expect(postData).toHaveBeenCalledTimes(1); + }); + + it("ignores envelope with empty id", () => { + const existingPostData = vi.fn(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData({id: "", command: "Post"} as any); + + expect(existingPostData).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("ignores null/non-object envelope", () => { + const existingPostData = vi.fn(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData(null as any); + + expect(existingPostData).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("preserves channel field in normalized envelope", () => { + const existingPostData = vi.fn(); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData({id: "test", command: "Post", channel: "myChannel", version: 2}); + + const parsed = JSON.parse(existingPostData.mock.calls[0][0]); + expect(parsed.channel).toBe("myChannel"); + }); + + it("ignores empty channel string", () => { + const existingPostData = vi.fn(); + window.infiniframe = {host: {postData: existingPostData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + window.infiniframe.host!.postData({id: "test", command: "Post", channel: " ", version: 2}); + + const parsed = JSON.parse(existingPostData.mock.calls[0][0]); + expect(parsed.channel).toBeUndefined(); + }); + }); + + describe("receiveCallback", () => { + it("registers existing receive callback", () => { + const existingReceive = vi.fn(); + window.infiniframe = {host: {postData: vi.fn(), receiveCallback: existingReceive}} as any; + + installNativeInteropBridge(setup); + const cb = vi.fn(); + window.infiniframe.host!.receiveCallback(cb); + + expect(existingReceive).toHaveBeenCalled(); + }); + }); + + describe("getDataAsync", () => { + it("returns promise rejection for invalid payload", async () => { + window.infiniframe = {host: {postData: vi.fn(), receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + await expect(window.infiniframe.host!.getDataAsync("")).rejects.toThrow("invalid"); + }); + + it("returns promise rejection for empty string", async () => { + window.infiniframe = {host: {postData: vi.fn(), receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + await expect(window.infiniframe.host!.getDataAsync(" ")).rejects.toThrow("invalid"); + }); - installNativeInteropBridge(setup); - window.infiniframe.host!.postData({id: "ping", command: "Post", data: "hello", version: 2}); + it("delegates to existing getDataAsync when available (sync result)", async () => { + const existingGetData = vi.fn(() => "sync-result"); + window.infiniframe = {host: {postData: vi.fn(), receiveCallback: vi.fn(), getDataAsync: existingGetData}} as any; - expect(postData).toHaveBeenCalledTimes(1); - expect(postData.mock.calls[0][0]).toBe("{\"id\":\"ping\",\"command\":\"Post\",\"data\":\"hello\",\"version\":2}"); + installNativeInteropBridge(setup); + const result = await window.infiniframe.host!.getDataAsync("test-message"); + + expect(result).toBe("sync-result"); + }); + + it("delegates to existing getDataAsync when available (promise result)", async () => { + const existingGetData = vi.fn(() => Promise.resolve("async-result")); + window.infiniframe = {host: {postData: vi.fn(), receiveCallback: vi.fn(), getDataAsync: existingGetData}} as any; + + installNativeInteropBridge(setup); + const result = await window.infiniframe.host!.getDataAsync("test-message"); + + expect(result).toBe("async-result"); + }); + + it("falls back when existing getDataAsync throws", async () => { + const existingGetData = vi.fn(() => { throw new Error("bridge failed"); }); + const chromePost = vi.fn(); + window.chrome = {webview: {postMessage: chromePost, addEventListener: vi.fn()}} as any; + window.infiniframe = {host: {receiveCallback: vi.fn(), getDataAsync: existingGetData}} as any; + + installNativeInteropBridge(setup); + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync("test"); + vi.advanceTimersByTime(11000); + + await expect(promise).rejects.toThrow(); + vi.useRealTimers(); + }); + + it("sends get request envelope via postData", async () => { + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync({id: "test-envelope", version: 2}); + vi.advanceTimersByTime(11000); + + expect(postData).toHaveBeenCalled(); + const envelope = JSON.parse(postData.mock.calls[0][0]); + expect(envelope.command).toBe("Get"); + expect(envelope.requestId).toBeDefined(); + + await expect(promise).rejects.toThrow(); + vi.useRealTimers(); + }); + + it("parses JSON string as envelope for get request", async () => { + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync('{"id":"test","version":2}'); + vi.advanceTimersByTime(11000); + + const envelope = JSON.parse(postData.mock.calls[0][0]); + expect(envelope.command).toBe("Get"); + expect(envelope.id).toBe("test"); + + await expect(promise).rejects.toThrow(); + vi.useRealTimers(); + }); + + it("treats plain string as message id for get request", async () => { + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync("my-message-id"); + vi.advanceTimersByTime(11000); + + const envelope = JSON.parse(postData.mock.calls[0][0]); + expect(envelope.id).toBe("my-message-id"); + expect(envelope.command).toBe("Get"); + + await expect(promise).rejects.toThrow(); + vi.useRealTimers(); + }); + + it("times out when no response received", async () => { + window.infiniframe = {host: {postData: vi.fn(), receiveCallback: vi.fn()}} as any; + + installNativeInteropBridge(setup); + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync("test"); + vi.advanceTimersByTime(11000); + + await expect(promise).rejects.toThrow("Timed out"); + vi.useRealTimers(); + }); + + it("resolves when response matches requestId", async () => { + let receiveCallbackFn: ((msg: string) => void) | null = null; + const receiveCallback = vi.fn((cb: (msg: string) => void) => { receiveCallbackFn = cb; }); + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback}} as any; + + installNativeInteropBridge(setup); + + const promise = window.infiniframe.host!.getDataAsync("test"); + + const envelope = JSON.parse(postData.mock.calls[0][0]); + const requestId = envelope.requestId; + + const response = JSON.stringify({ + id: "__infiniframe:get:response", + command: "Post", + data: JSON.stringify({requestId, success: true, data: "result-data"}), + version: 2 + }); + receiveCallbackFn!(response); + + const result = await promise; + expect(result).toBe("result-data"); + }); + + it("rejects when response indicates failure", async () => { + let receiveCallbackFn: ((msg: string) => void) | null = null; + const receiveCallback = vi.fn((cb: (msg: string) => void) => { receiveCallbackFn = cb; }); + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback}} as any; + + installNativeInteropBridge(setup); + + const promise = window.infiniframe.host!.getDataAsync("test"); + + const envelope = JSON.parse(postData.mock.calls[0][0]); + const requestId = envelope.requestId; + + const response = JSON.stringify({ + id: "__infiniframe:get:response", + command: "Post", + data: JSON.stringify({requestId, success: false, error: "host error"}), + version: 2 + }); + receiveCallbackFn!(response); + + await expect(promise).rejects.toThrow("host error"); + }); + + it("ignores response with wrong requestId", async () => { + let receiveCallbackFn: ((msg: string) => void) | null = null; + const receiveCallback = vi.fn((cb: (msg: string) => void) => { receiveCallbackFn = cb; }); + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback}} as any; + + installNativeInteropBridge(setup); + + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync("test"); + + const response = JSON.stringify({ + id: "__infiniframe:get:response", + command: "Post", + data: JSON.stringify({requestId: "wrong-id", success: true, data: "data"}), + version: 2 + }); + receiveCallbackFn!(response); + + vi.advanceTimersByTime(11000); + await expect(promise).rejects.toThrow("Timed out"); + vi.useRealTimers(); + }); + + it("ignores response with invalid JSON payload", async () => { + let receiveCallbackFn: ((msg: string) => void) | null = null; + const receiveCallback = vi.fn((cb: (msg: string) => void) => { receiveCallbackFn = cb; }); + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback}} as any; + + installNativeInteropBridge(setup); + + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync("test"); + + const response = JSON.stringify({ + id: "__infiniframe:get:response", + command: "Post", + data: "not-valid-json{{{", + version: 2 + }); + receiveCallbackFn!(response); + + vi.advanceTimersByTime(11000); + await expect(promise).rejects.toThrow("Timed out"); + vi.useRealTimers(); + }); + + it("ignores response with missing data field", async () => { + let receiveCallbackFn: ((msg: string) => void) | null = null; + const receiveCallback = vi.fn((cb: (msg: string) => void) => { receiveCallbackFn = cb; }); + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback}} as any; + + installNativeInteropBridge(setup); + + const promise = window.infiniframe.host!.getDataAsync("test"); + + const envelope = JSON.parse(postData.mock.calls[0][0]); + const requestId = envelope.requestId; + + const response = JSON.stringify({ + id: "__infiniframe:get:response", + command: "Post", + data: JSON.stringify({requestId, success: true}), + version: 2 + }); + receiveCallbackFn!(response); + + const result = await promise; + expect(result).toBe(""); + }); + + it("rejects when payload has wrong shape", async () => { + let receiveCallbackFn: ((msg: string) => void) | null = null; + const receiveCallback = vi.fn((cb: (msg: string) => void) => { receiveCallbackFn = cb; }); + const postData = vi.fn(); + window.infiniframe = {host: {postData, receiveCallback}} as any; + + installNativeInteropBridge(setup); + + vi.useFakeTimers(); + const promise = window.infiniframe.host!.getDataAsync("test"); + + const response = JSON.stringify({ + id: "__infiniframe:get:response", + command: "Post", + data: JSON.stringify({notRequestId: true}), + version: 2 + }); + receiveCallbackFn!(response); + + vi.advanceTimersByTime(11000); + await expect(promise).rejects.toThrow("Timed out"); + vi.useRealTimers(); + }); }); }); diff --git a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.ts b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.ts index bb0552106..117da2656 100644 --- a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.ts +++ b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/NativeInteropBridge.ts @@ -1,7 +1,7 @@ // --------------------------------------------------------------------------------------------------------------------- // Imports // --------------------------------------------------------------------------------------------------------------------- -import type {InfiniFrameHostBridge, InfiniFrameSetup, InteropEnvelopeV1} from "../../Contracts"; +import type {InfiniFrameHostBridge, InfiniFrameSetup, InteropEnvelopeCommand, InteropEnvelopeV1} from "../../Contracts"; import { InteropEnvelopeVersion, InteropGetCommand, @@ -18,6 +18,11 @@ const GetMessageTimeoutMs = 10_000; const receiveCallbacks = new Set<(message: string) => void>(); let receiveBridgeAttached = false; +export function resetNativeInteropBridgeState(): void { + receiveCallbacks.clear(); + receiveBridgeAttached = false; +} + export function installNativeInteropBridge(setup: InfiniFrameSetup): void { if (setup.nativeInteropBridgeInitialized) return; setup.nativeInteropBridgeInitialized = true; @@ -209,8 +214,8 @@ function createRequestId(): string { function normalizeEnvelope( envelope: InteropEnvelopeV1, - command = envelope.command ?? InteropPostCommand, - requestId = envelope.requestId + command?: InteropEnvelopeCommand, + requestId?: string ): InteropEnvelopeV1 | null { if (!envelope || typeof envelope !== "object") { console.warn("Host bridge payload must be an envelope object."); @@ -225,8 +230,8 @@ function normalizeEnvelope( const normalized: InteropEnvelopeV1 = { id: envelope.id, - command, - requestId, + command: command ?? envelope.command ?? InteropPostCommand, + requestId: requestId ?? envelope.requestId, data: envelope.data, version: InteropEnvelopeVersion }; diff --git a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorExternalBridge.test.ts b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorExternalBridge.test.ts index 114215250..3d33596d9 100644 --- a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorExternalBridge.test.ts +++ b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorExternalBridge.test.ts @@ -14,8 +14,8 @@ describe("blazorExternalBridge", () => { beforeEach(() => { setup = createSetup(); delete window.infiniframe; - delete window.__blazorCallbacks; - delete window.__blazorDispatchHooked; + delete (window as any).__blazorCallbacks; + delete (window as any).__blazorDispatchHooked; vi.restoreAllMocks(); }); @@ -107,6 +107,115 @@ describe("blazorExternalBridge", () => { expect(receiveCallback).toHaveBeenCalledTimes(1); }); + + it("warns when host bridge postData is not available", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.infiniframe = { + host: { + postData: undefined as any, + receiveCallback: vi.fn() + }, + messaging: undefined!, + window: undefined!, + utils: undefined! + }; + + initWindowExternalBridge(setup); + + const external = window.external as InfiniFrameExternal; + external.sendMessage!("test"); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("warns when host bridge is not available", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + window.infiniframe = { + host: undefined as any, + messaging: undefined!, + window: undefined!, + utils: undefined! + }; + + initWindowExternalBridge(setup); + + const external = window.external as InfiniFrameExternal; + external.sendMessage!("test"); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("tolerates throwing Blazor callbacks", () => { + let hostCallback: BlazorCallback | null = null; + window.infiniframe = { + host: { + postData: vi.fn(), + receiveCallback: vi.fn(callback => { + hostCallback = callback; + }) + }, + messaging: undefined!, + window: undefined!, + utils: undefined! + }; + + initWindowExternalBridge(setup); + + const throwingCallback = vi.fn(() => { throw new Error("callback error"); }); + const normalCallback = vi.fn(); + const external = window.external as InfiniFrameExternal; + external.receiveMessage!(throwingCallback); + external.receiveMessage!(normalCallback); + + // Should not throw even though first callback throws + hostCallback!("host-message"); + + expect(throwingCallback).toHaveBeenCalled(); + expect(normalCallback).toHaveBeenCalledWith("host-message"); + }); + + it("uses existing window.external when available", () => { + const existingExternal = {sendMessage: vi.fn()} as any; + Object.defineProperty(window, "external", { + configurable: true, + value: existingExternal, + writable: true + }); + + window.infiniframe = { + host: { + postData: vi.fn(), + receiveCallback: vi.fn() + }, + messaging: undefined!, + window: undefined!, + utils: undefined! + }; + + initWindowExternalBridge(setup); + + expect(window.external).toBe(existingExternal); + }); + + it("does nothing if already initialized", () => { + setup.windowExternalBridgeInitialized = true; + const receiveCallback = vi.fn(); + window.infiniframe = { + host: { + postData: vi.fn(), + receiveCallback + }, + messaging: undefined!, + window: undefined!, + utils: undefined! + }; + + initWindowExternalBridge(setup); + + expect(receiveCallback).not.toHaveBeenCalled(); + }); }); function createSetup(): InfiniFrameSetup { diff --git a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorFetchPatch.test.ts b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorFetchPatch.test.ts index f1a1b46ed..54e1f6176 100644 --- a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorFetchPatch.test.ts +++ b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/blazorFetchPatch.test.ts @@ -42,6 +42,101 @@ describe("blazorFetchPatch", () => { expect(fetch).toHaveBeenCalledWith("https://localhost/app.js", undefined); expect(await response.text()).toBe("original"); }); + + it("handles http://localhost blazor.modules.json", async () => { + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + const response = await window.fetch("http://localhost/_framework/blazor.modules.json"); + + expect(fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + }); + + it("handles app://localhost blazor.modules.json", async () => { + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + const response = await window.fetch("app://localhost/_framework/blazor.modules.json"); + + expect(fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + }); + + it("handles trailing slash variants", async () => { + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + const response = await window.fetch("https://localhost/_framework/blazor.modules.json/"); + + expect(fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + }); + + it("handles Request object input", async () => { + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + const request = new Request("https://localhost/_framework/blazor.modules.json"); + const response = await window.fetch(request); + + expect(fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + }); + + it("handles URL object input", async () => { + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + const url = new URL("https://localhost/_framework/blazor.modules.json"); + const response = await window.fetch(url); + + expect(fetch).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + }); + + it("passes init options to original fetch", async () => { + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + await window.fetch("https://localhost/api/data", {method: "POST"}); + + expect(fetch).toHaveBeenCalledWith("https://localhost/api/data", {method: "POST"}); + }); + + it("does nothing if already initialized", () => { + setup.blazorModulesFetchPatchInitialized = true; + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + expect(window.fetch).toBe(fetch); + }); + + it("falls through on invalid URL", async () => { + const fetch = vi.fn(() => Promise.resolve(new Response("original"))); + window.fetch = fetch; + + initBlazorModulesFetchPatch(setup); + + // An invalid relative URL that will cause new URL() to throw + const response = await window.fetch(""); + + expect(fetch).toHaveBeenCalled(); + }); }); function createSetup(): InfiniFrameSetup { diff --git a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/customElements.test.ts b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/customElements.test.ts index 5b579ea17..b8d2a5268 100644 --- a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/customElements.test.ts +++ b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/customElements.test.ts @@ -19,10 +19,11 @@ describe("customElements", () => { beforeEach(() => { setup = createSetup(); document.body.innerHTML = ""; - delete window.Blazor; + Object.defineProperty(window, "Blazor", {configurable: true, value: undefined, writable: true}); delete window.registerBlazorCustomElement; vi.useRealTimers(); vi.restoreAllMocks(); + vi.resetModules(); }); it("registers Blazor custom elements and converts attributes to parameters", async () => { @@ -107,6 +108,303 @@ describe("customElements", () => { [{name: "OtherValue"}] ); }); + + it("registerBlazorCustomElement returns early if Blazor.rootComponents not available", () => { + window.Blazor = {} as any; + initCustomElements(setup); + + // Should not throw + window.registerBlazorCustomElement!("test-element", [{name: "Value"}]); + }); + + it("registerBlazorCustomElement returns early if customElements.define not available", () => { + window.Blazor = {rootComponents: {add: vi.fn()}} as any; + initCustomElements(setup); + + // In jsdom, customElements.define exists, so this branch may not be hit + // But we can verify the element is defined + window.registerBlazorCustomElement!("test-element-no-define", [{name: "Value"}]); + }); + + it("registerBlazorCustomElement returns early if element already defined", () => { + window.Blazor = {rootComponents: {add: vi.fn()}} as any; + initCustomElements(setup); + + window.registerBlazorCustomElement!("test-element-defined", [{name: "Value"}]); + // Registering same name again should not throw + window.registerBlazorCustomElement!("test-element-defined", [{name: "Value"}]); + }); + + it("handles numeric type conversions (int, float, double, decimal)", async () => { + const add = vi.fn(() => Promise.resolve({setParameters: vi.fn(() => Promise.resolve()), dispose: vi.fn()})); + const identifier = `infiniframe-test-numeric-${++elementCounter}`; + + window.Blazor = {rootComponents: {add}}; + initCustomElements(setup); + window.registerBlazorCustomElement!(identifier, [ + {name: "IntVal", type: "int"}, + {name: "FloatVal", type: "float"}, + {name: "DoubleVal", type: "double"}, + {name: "DecimalVal", type: "decimal"} + ]); + + const element = document.createElement(identifier); + element.setAttribute("int-val", "42"); + element.setAttribute("float-val", "3.14"); + element.setAttribute("double-val", "2.718"); + element.setAttribute("decimal-val", "99.99"); + document.body.appendChild(element); + await tick(); + + expect(add).toHaveBeenCalledWith(element, identifier, { + IntVal: 42, + FloatVal: 3.14, + DoubleVal: 2.718, + DecimalVal: 99.99 + }); + }); + + it("handles non-numeric NaN values as strings", async () => { + const add = vi.fn(() => Promise.resolve({setParameters: vi.fn(() => Promise.resolve()), dispose: vi.fn()})); + const identifier = `infiniframe-test-nan-${++elementCounter}`; + + window.Blazor = {rootComponents: {add}}; + initCustomElements(setup); + window.registerBlazorCustomElement!(identifier, [ + {name: "Val", type: "number"} + ]); + + const element = document.createElement(identifier); + element.setAttribute("val", "not-a-number"); + document.body.appendChild(element); + await tick(); + + expect(add).toHaveBeenCalledWith(element, identifier, { + Val: "not-a-number" + }); + }); + + it("handles bool false value", async () => { + const add = vi.fn(() => Promise.resolve({setParameters: vi.fn(() => Promise.resolve()), dispose: vi.fn()})); + const identifier = `infiniframe-test-bool-${++elementCounter}`; + + window.Blazor = {rootComponents: {add}}; + initCustomElements(setup); + window.registerBlazorCustomElement!(identifier, [ + {name: "Flag", type: "boolean"} + ]); + + const element = document.createElement(identifier); + element.setAttribute("flag", "false"); + document.body.appendChild(element); + await tick(); + + expect(add).toHaveBeenCalledWith(element, identifier, { + Flag: false + }); + }); + + it("attributeChangedCallback ignores unchanged values", async () => { + const setParameters = vi.fn(() => Promise.resolve()); + const add = vi.fn(() => Promise.resolve({setParameters, dispose: vi.fn()})); + const identifier = `infiniframe-test-unchanged-${++elementCounter}`; + + window.Blazor = {rootComponents: {add}}; + initCustomElements(setup); + window.registerBlazorCustomElement!(identifier, [ + {name: "Value", type: "string"} + ]); + + const element = document.createElement(identifier); + element.setAttribute("value", "hello"); + document.body.appendChild(element); + await tick(); + + // Set same value again + element.setAttribute("value", "hello"); + await tick(); + + expect(setParameters).not.toHaveBeenCalled(); + }); + + it("attributeChangedCallback ignores unknown attributes", async () => { + const setParameters = vi.fn(() => Promise.resolve()); + const add = vi.fn(() => Promise.resolve({setParameters, dispose: vi.fn()})); + const identifier = `infiniframe-test-unknown-${++elementCounter}`; + + window.Blazor = {rootComponents: {add}}; + initCustomElements(setup); + window.registerBlazorCustomElement!(identifier, [ + {name: "Known", type: "string"} + ]); + + const element = document.createElement(identifier); + element.setAttribute("known", "value"); + document.body.appendChild(element); + await tick(); + + // Set unknown attribute + element.setAttribute("unknown-attr", "value"); + await tick(); + + expect(setParameters).not.toHaveBeenCalled(); + }); + + it("connectedCallback disposes if disconnected before promise resolves", async () => { + let resolveAdd: (value: any) => void; + const addPromise = new Promise(resolve => { resolveAdd = resolve; }); + const dispose = vi.fn(() => Promise.resolve()); + const add = vi.fn(() => addPromise); + const identifier = `infiniframe-test-disconnect-${++elementCounter}`; + + window.Blazor = {rootComponents: {add}}; + initCustomElements(setup); + window.registerBlazorCustomElement!(identifier, []); + + const element = document.createElement(identifier); + document.body.appendChild(element); + await tick(); + + // Disconnect before the promise resolves + element.remove(); + await tick(); + + // Now resolve the add promise + resolveAdd!({dispose}); + await tick(); + + expect(dispose).toHaveBeenCalled(); + }); + + it("initBlazorCustomElementsPatch returns early if already initialized", () => { + setup.blazorCustomElementsPatchInitialized = true; + const attachWebRendererInterop = vi.fn(); + window.Blazor = {_internal: {attachWebRendererInterop}}; + + initBlazorCustomElementsPatch(setup); + + expect(attachWebRendererInterop).not.toHaveBeenCalled(); + }); + + it("flushAutoRegister calls registerBlazorCustomElement if available", () => { + vi.useFakeTimers(); + const register = vi.fn(); + const attachWebRendererInterop = vi.fn(); + window.registerBlazorCustomElement = register; + window.Blazor = {_internal: {attachWebRendererInterop}}; + + initBlazorCustomElementsPatch(setup); + + window.Blazor._internal!.attachWebRendererInterop!( + {}, {}, + {"auto-element": [{name: "Val"}]}, + {} + ); + + vi.runAllTimers(); + + expect(register).toHaveBeenCalledWith("auto-element", [{name: "Val"}]); + vi.useRealTimers(); + }); + + it("flushAutoRegister does nothing if registerBlazorCustomElement not available", () => { + vi.useFakeTimers(); + const attachWebRendererInterop = vi.fn(); + window.registerBlazorCustomElement = undefined as any; + window.Blazor = {_internal: {attachWebRendererInterop}}; + + initBlazorCustomElementsPatch(setup); + + window.Blazor._internal!.attachWebRendererInterop!( + {}, {}, + {"auto-element": [{name: "Val"}]}, + {} + ); + + vi.runAllTimers(); + vi.useRealTimers(); + }); + + it("autoRegister handles empty defs and initMap", () => { + vi.useFakeTimers(); + const register = vi.fn(); + const attachWebRendererInterop = vi.fn(); + window.registerBlazorCustomElement = register; + window.Blazor = {_internal: {attachWebRendererInterop}}; + + initBlazorCustomElementsPatch(setup); + + window.Blazor._internal!.attachWebRendererInterop!( + {}, {}, + undefined as any, + undefined as any + ); + + vi.runAllTimers(); + + expect(register).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("autoRegister handles errors in registerBlazorCustomElement gracefully", () => { + vi.useFakeTimers(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const register = vi.fn(() => { throw new Error("registration failed"); }); + const attachWebRendererInterop = vi.fn(); + window.registerBlazorCustomElement = register; + window.Blazor = {_internal: {attachWebRendererInterop}}; + + initBlazorCustomElementsPatch(setup); + + window.Blazor._internal!.attachWebRendererInterop!( + {}, {}, + {"failing-element": [{name: "Value"}]}, + {} + ); + + vi.runAllTimers(); + + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + vi.useRealTimers(); + }); + + it("registerBlazorCustomElement skips non-EventCallback params", () => { + window.Blazor = {rootComponents: {add: vi.fn()}}; + window.customElements = {define: vi.fn(), get: vi.fn(() => undefined)} as any; + + initCustomElements(setup); + window.registerBlazorCustomElement!("test-element", [ + {name: "Title", type: "string"}, + {name: "OnClick", type: "EventCallback"}, + {name: "Count", type: "int"} + ]); + + expect(window.customElements.define).toHaveBeenCalled(); + }); + + it("registerBlazorCustomElement skips undefined name params", () => { + window.Blazor = {rootComponents: {add: vi.fn()}}; + window.customElements = {define: vi.fn(), get: vi.fn(() => undefined)} as any; + + initCustomElements(setup); + window.registerBlazorCustomElement!("test-element", [ + {name: undefined as any, type: "string"}, + {name: "Valid", type: "string"} + ]); + + expect(window.customElements.define).toHaveBeenCalled(); + }); + + it("registerBlazorCustomElement skips already-defined elements", () => { + window.Blazor = {rootComponents: {add: vi.fn()}}; + window.customElements = {define: vi.fn(), get: vi.fn(() => ({}))} as any; + + initCustomElements(setup); + window.registerBlazorCustomElement!("existing-element", [{name: "Value", type: "string"}]); + + expect(window.customElements.define).not.toHaveBeenCalled(); + }); }); function createSetup(): InfiniFrameSetup { diff --git a/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/setupGuard.test.ts b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/setupGuard.test.ts new file mode 100644 index 000000000..49453d6ac --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Interop/NativeInterop/setupGuard.test.ts @@ -0,0 +1,73 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +import {describe, it, expect, beforeEach} from "vitest"; +import {getSetupGuard} from "./setupGuard"; + +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +describe("getSetupGuard", () => { + beforeEach(() => { + delete (window as any).infiniframe; + }); + + it("should initialize window.infiniframe if missing", () => { + // Arrange + + // Act + const guard = getSetupGuard(); + + // Assert + expect(window.infiniframe).toBeDefined(); + expect(guard).toBeDefined(); + }); + + it("should initialize setup object with all flags false", () => { + // Arrange + + // Act + const guard = getSetupGuard(); + + // Assert + expect(guard.nativeInteropBridgeInitialized).toBe(false); + expect(guard.windowExternalBridgeInitialized).toBe(false); + expect(guard.blazorModulesFetchPatchInitialized).toBe(false); + expect(guard.blazorCustomElementsPatchInitialized).toBe(false); + expect(guard.customElementsInitialized).toBe(false); + }); + + it("should return same reference on subsequent calls", () => { + // Arrange + + // Act + const guard1 = getSetupGuard(); + const guard2 = getSetupGuard(); + + // Assert + expect(guard1).toBe(guard2); + }); + + it("should preserve existing setup values", () => { + // Arrange + window.infiniframe = {setup: {nativeInteropBridgeInitialized: true}} as any; + + // Act + const guard = getSetupGuard(); + + // Assert + expect(guard.nativeInteropBridgeInitialized).toBe(true); + }); + + it("should preserve existing window.infiniframe properties", () => { + // Arrange + const existing = {custom: "value"}; + (window as any).infiniframe = existing; + + // Act + getSetupGuard(); + + // Assert + expect((window as any).infiniframe.custom).toBe("value"); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/BrowserInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/BrowserInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..3f80f6a02 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/BrowserInfiniFrameWindowFeature.test.ts @@ -0,0 +1,200 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("BrowserInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + messaging = setupFeature(); + vi.doMock("../InfiniFrameHostMessaging", () => ({default: class { constructor() {} }})); + const mod = await import("./BrowserInfiniFrameWindowFeature"); + feature = new mod.BrowserInfiniFrameWindowFeature(); + (window as any).infiniframe.messaging = messaging; + }); + + it("constructs without error", () => { expect(feature).toBeDefined(); }); + it("registers message handlers on construction", () => { expect(messaging.assignMessageReceivedHandler).toHaveBeenCalled(); }); + it("isContextMenuEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isContextMenuEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isMediaAutoplayEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isMediaAutoplayEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getUserAgentAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("test-agent")); + await feature.getUserAgentAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("enableContextMenu posts command", () => { feature.enableContextMenu(false); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("enableMediaAutoplay posts command", () => { feature.enableMediaAutoplay(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setUserAgent posts command", () => { feature.setUserAgent("custom-agent"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("win32SetWebView2Path posts command", () => { feature.win32SetWebView2Path("C:/path"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("clearBrowserAutoFill posts command", () => { feature.clearBrowserAutoFill(); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("keydown guard blocks ctrl+key browser shortcuts", () => { + const event = new KeyboardEvent("keydown", {key: "t", ctrlKey: true, bubbles: true, cancelable: true}); + document.dispatchEvent(event); + }); + it("keydown guard blocks F11 key", () => { + const event = new KeyboardEvent("keydown", {key: "F11", bubbles: true, cancelable: true}); + document.dispatchEvent(event); + }); + it("contextmenu guard blocks right-click when disabled", () => { + const event = new Event("contextmenu", {bubbles: true, cancelable: true}); + document.dispatchEvent(event); + }); + it("wheel guard blocks ctrl+wheel zoom", () => { + const event = new WheelEvent("wheel", {ctrlKey: true, deltaY: 100, bubbles: true, cancelable: true}); + document.dispatchEvent(event); + }); + it("isFileSystemAccessEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isFileSystemAccessEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isWebSecurityEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isWebSecurityEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isJavascriptClipboardAccessEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isJavascriptClipboardAccessEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isMediaStreamEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isMediaStreamEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isIgnoreCertificateErrorsEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isIgnoreCertificateErrorsEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getGrantBrowserPermissionsAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.getGrantBrowserPermissionsAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isSmoothScrollingEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isSmoothScrollingEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getBrowserControlInitParametersAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("--flag")); + await feature.getBrowserControlInitParametersAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("enableContextMenu posts with default true", () => { feature.enableContextMenu(); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("enableMediaAutoplay posts with default true", () => { feature.enableMediaAutoplay(); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("message handler updates contextMenuEnabled on valid payload", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setContextMenuEnabled") + )?.[1]; + expect(handler).toBeDefined(); + handler!(JSON.stringify({enabled: false})); + }); + it("message handler ignores null payload for contextMenu", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setContextMenuEnabled") + )?.[1]; + handler!(null); + }); + it("message handler ignores malformed JSON for contextMenu", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setContextMenuEnabled") + )?.[1]; + handler!("not-json"); + }); + it("message handler updates zoomEnabled on valid payload", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setZoomEnabled") + )?.[1]; + handler!(JSON.stringify({enabled: false})); + }); + it("message handler ignores null payload for zoom", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setZoomEnabled") + )?.[1]; + handler!(null); + }); + it("message handler ignores malformed JSON for zoom", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setZoomEnabled") + )?.[1]; + handler!("not-json"); + }); + it("message handler updates browserShortcutsEnabled on valid payload", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setBrowserShortcutsEnabled") + )?.[1]; + handler!(JSON.stringify({enabled: false})); + }); + it("message handler ignores null payload for browserShortcuts", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setBrowserShortcutsEnabled") + )?.[1]; + handler!(null); + }); + it("message handler ignores malformed JSON for browserShortcuts", () => { + const handler = messaging.assignMessageReceivedHandler.mock.calls.find( + (call: any[]) => typeof call[0] === "string" && call[0].includes("setBrowserShortcutsEnabled") + )?.[1]; + handler!("not-json"); + }); + it("keydown guard blocks ctrl+shift+i", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "i", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+n", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "n", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+w", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "w", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+r", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "r", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+p", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "p", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+u", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "u", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+j", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "j", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+l", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "l", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+o", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "o", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard blocks ctrl+h", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "h", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("keydown guard allows non-shortcut keys", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "a", bubbles: true, cancelable: true})); + }); + it("zoom guard blocks ctrl+plus", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "+", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("zoom guard blocks ctrl+minus", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "-", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("zoom guard blocks ctrl+equal", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "=", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("zoom guard blocks ctrl+0", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "0", ctrlKey: true, bubbles: true, cancelable: true})); + }); + it("zoom guard blocks F5", () => { + document.dispatchEvent(new KeyboardEvent("keydown", {key: "F5", bubbles: true, cancelable: true})); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/DebuggingInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/DebuggingInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..fa9fd5773 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/DebuggingInfiniFrameWindowFeature.test.ts @@ -0,0 +1,63 @@ +import {beforeEach, describe, expect, it, vi} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("DebuggingInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + messaging = setupFeature(); + vi.doMock("../InfiniFrameHostMessaging", () => ({default: class { constructor() {} }})); + const mod = await import("./DebuggingInfiniFrameWindowFeature"); + feature = new mod.DebuggingInfiniFrameWindowFeature(); + (window as any).infiniframe.messaging = messaging; + }); + + it("isDevToolsEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isDevToolsEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("supportsWebInspectorAttachAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.supportsWebInspectorAttachAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isWebInspectorEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isWebInspectorEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("supportsRemoteDebuggingEndpointAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.supportsRemoteDebuggingEndpointAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getRemoteDebuggingPortAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(9222)); + await feature.getRemoteDebuggingPortAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getCapabilitiesAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({supportsLocalDevTools: true})); + await feature.getCapabilitiesAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getDiagnosticsAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({platform: "test"})); + await feature.getDiagnosticsAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("tryGetRemoteDebuggingEndpointAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({success: true})); + await feature.tryGetRemoteDebuggingEndpointAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("tryProbeEndpointAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({reachable: true})); + await feature.tryProbeEndpointAsync("http://localhost:9222"); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("enableDevTools posts command", () => { feature.enableDevTools(false); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/DecorationsInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/DecorationsInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..44fa22a30 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/DecorationsInfiniFrameWindowFeature.test.ts @@ -0,0 +1,49 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("DecorationsInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./DecorationsInfiniFrameWindowFeature"); + feature = new mod.DecorationsInfiniFrameWindowFeature(); + }); + + it("isChromelessAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isChromelessAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isTransparentAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isTransparentAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("backgroundColorAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("#ffffff")); + await feature.backgroundColorAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getTitleAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("Test Title")); + await feature.getTitleAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getIconFilePathAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("/icon.png")); + await feature.getIconFilePathAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getLimitLinuxWindowTitleLengthAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.getLimitLinuxWindowTitleLengthAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("setTransparent posts command", () => { feature.setTransparent(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setBackgroundColor posts command", () => { feature.setBackgroundColor("#000000"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setTitle posts command", () => { feature.setTitle("New Title"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setIconFile posts command", () => { feature.setIconFile("/new-icon.png"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setLimitLinuxWindowTitleLength posts command", () => { feature.setLimitLinuxWindowTitleLength(false); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/FilePickerDialogsInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/FilePickerDialogsInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..a68041161 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/FilePickerDialogsInfiniFrameWindowFeature.test.ts @@ -0,0 +1,29 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("FilePickerDialogsInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./FilePickerDialogsInfiniFrameWindowFeature"); + feature = new mod.FilePickerDialogsInfiniFrameWindowFeature(); + }); + + it("showOpenFileAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("/selected/file.txt")); + await feature.showOpenFileAsync({filters: []}); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("showOpenFolderAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("/selected/folder")); + await feature.showOpenFolderAsync({filters: []}); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("showSaveFileAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("/save/path.txt")); + await feature.showSaveFileAsync({filters: []}); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/InvokeInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/InvokeInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..c23bb4f67 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/InvokeInfiniFrameWindowFeature.test.ts @@ -0,0 +1,13 @@ +import {describe, expect, it, vi} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("InvokeInfiniFrameWindowFeature", () => { + it("constructs without error", async () => { + vi.resetModules(); + setupFeature(); + vi.doMock("../InfiniFrameHostMessaging", () => ({default: class { constructor() {} }})); + const mod = await import("./InvokeInfiniFrameWindowFeature"); + const feature = new mod.InvokeInfiniFrameWindowFeature(); + expect(feature).toBeDefined(); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/JavaScriptInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/JavaScriptInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..5c787bd19 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/JavaScriptInfiniFrameWindowFeature.test.ts @@ -0,0 +1,78 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("JavaScriptInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./JavaScriptInfiniFrameWindowFeature"); + feature = new mod.JavaScriptInfiniFrameWindowFeature(); + }); + + it("evalAsync sends eval command and resolves on response", async () => { + const {handleJavaScriptEvalResponse} = await import("./JavaScriptInfiniFrameWindowFeature"); + messaging.sendMessageToHost.mockImplementation((_id: string, data: any) => { + const args = data.args || data; + const requestId = args.requestId; + handleJavaScriptEvalResponse({requestId, result: JSON.stringify("42")}); + }); + const result = await feature.evalAsync("1 + 1"); + expect(result).toBe("42"); + }); + + it("evalAsync rejects on error response", async () => { + const {handleJavaScriptEvalResponse} = await import("./JavaScriptInfiniFrameWindowFeature"); + messaging.sendMessageToHost.mockImplementation((_id: string, data: any) => { + const args = data.args || data; + const requestId = args.requestId; + handleJavaScriptEvalResponse({requestId, error: "Syntax error"}); + }); + await expect(feature.evalAsync("throw new Error('Syntax error')")).rejects.toThrow("Syntax error"); + }); + + it("handleJavaScriptEvalRequest executes script and sends result", async () => { + const {handleJavaScriptEvalRequest} = await import("./JavaScriptInfiniFrameWindowFeature"); + handleJavaScriptEvalRequest({requestId: "req-1", script: "1 + 2"}); + expect(messaging.sendMessageToHost).toHaveBeenCalledWith( + "__infiniframe:javascript:eval:result", + expect.objectContaining({requestId: "req-1", result: "3"}) + ); + }); + + it("handleJavaScriptEvalRequest sends error on exception", async () => { + const {handleJavaScriptEvalRequest} = await import("./JavaScriptInfiniFrameWindowFeature"); + handleJavaScriptEvalRequest({requestId: "req-2", script: "throw new Error('fail')"}); + expect(messaging.sendMessageToHost).toHaveBeenCalledWith( + "__infiniframe:javascript:eval:result", + expect.objectContaining({requestId: "req-2", error: expect.any(String)}) + ); + }); + + it("handleJavaScriptEvalRequest ignores invalid payload", async () => { + const {handleJavaScriptEvalRequest} = await import("./JavaScriptInfiniFrameWindowFeature"); + handleJavaScriptEvalRequest(null); + handleJavaScriptEvalRequest({}); + handleJavaScriptEvalRequest({requestId: "x"}); + handleJavaScriptEvalRequest({script: "y"}); + }); + + it("handleJavaScriptEvalResponse ignores invalid payload", async () => { + const {handleJavaScriptEvalResponse} = await import("./JavaScriptInfiniFrameWindowFeature"); + handleJavaScriptEvalResponse(null); + handleJavaScriptEvalResponse({}); + handleJavaScriptEvalResponse({requestId: "nonexistent"}); + }); + + it("handleJavaScriptEvalResponse resolves with null for null result", async () => { + const {handleJavaScriptEvalResponse} = await import("./JavaScriptInfiniFrameWindowFeature"); + messaging.sendMessageToHost.mockImplementation((_id: string, data: any) => { + const args = data.args || data; + const requestId = args.requestId; + handleJavaScriptEvalResponse({requestId, result: null}); + }); + const result = await feature.evalAsync("undefined"); + expect(result).toBeNull(); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/LifecycleInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/LifecycleInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..4fb06a75c --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/LifecycleInfiniFrameWindowFeature.test.ts @@ -0,0 +1,31 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("LifecycleInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./LifecycleInfiniFrameWindowFeature"); + feature = new mod.LifecycleInfiniFrameWindowFeature(); + }); + + it("constructs with lifecycle feature name", () => { expect(feature).toBeDefined(); }); + it("getStateAsync sends get request", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("Running")); + const result = await feature.getStateAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + expect(result).toBe("Running"); + }); + it("isClosedOrClosingAsync sends get request", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(false)); + const result = await feature.isClosedOrClosingAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + expect(result).toBe(false); + }); + it("close sends post command", () => { + feature.close(); + expect(messaging.sendMessageToHost).toHaveBeenCalled(); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/MonitorsInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/MonitorsInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..f116e94e7 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/MonitorsInfiniFrameWindowFeature.test.ts @@ -0,0 +1,29 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("MonitorsInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./MonitorsInfiniFrameWindowFeature"); + feature = new mod.MonitorsInfiniFrameWindowFeature(); + }); + + it("getMonitorsAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify([])); + await feature.getMonitorsAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getMainMonitorAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({})); + await feature.getMainMonitorAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getMainMonitorScreenDpiAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(96)); + await feature.getMainMonitorScreenDpiAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/NotificationsInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/NotificationsInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..209adf5d8 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/NotificationsInfiniFrameWindowFeature.test.ts @@ -0,0 +1,23 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("NotificationsInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./NotificationsInfiniFrameWindowFeature"); + feature = new mod.NotificationsInfiniFrameWindowFeature(); + }); + + it("showMessageAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("OK")); + await feature.showMessageAsync({title: "Test", message: "Hello"}); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("showNotification posts command", () => { + feature.showNotification({title: "Test", message: "Hello"}); + expect(messaging.sendMessageToHost).toHaveBeenCalled(); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/PageNavigationInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/PageNavigationInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..7c58e8729 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/PageNavigationInfiniFrameWindowFeature.test.ts @@ -0,0 +1,37 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("PageNavigationInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./PageNavigationInfiniFrameWindowFeature"); + feature = new mod.PageNavigationInfiniFrameWindowFeature(); + }); + + it("tryLoadUriAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.tryLoadUriAsync("https://example.com"); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("tryLoadPathAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.tryLoadPathAsync("/page.html"); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("loadUri posts command", () => { feature.loadUri("https://example.com"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("loadPath posts command", () => { feature.loadPath("/page.html"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("loadRawString posts command", () => { feature.loadRawString(""); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("getCurrentUrlAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("https://example.com")); + await feature.getCurrentUrlAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getCurrentUriAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify("app://localhost/page")); + await feature.getCurrentUriAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/PositionInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/PositionInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..093e85baf --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/PositionInfiniFrameWindowFeature.test.ts @@ -0,0 +1,37 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("PositionInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./PositionInfiniFrameWindowFeature"); + feature = new mod.PositionInfiniFrameWindowFeature(); + }); + + it("getLocationAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({left: 100, top: 200})); + await feature.getLocationAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getTopAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(200)); + await feature.getTopAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getLeftAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(100)); + await feature.getLeftAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("setLocation posts command", () => { feature.setLocation(100, 200); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setLeft posts command", () => { feature.setLeft(100); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setTop posts command", () => { feature.setTop(200); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("offset posts command", () => { feature.offset(10, 20); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("center posts command", () => { feature.center(); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("centerOnCurrentMonitor posts command", () => { feature.centerOnCurrentMonitor(); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("centerOnMonitor posts command", () => { feature.centerOnMonitor(0); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("moveWithinCurrentMonitorArea posts command", () => { feature.moveWithinCurrentMonitorArea(); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/SizeInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/SizeInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..0609b9c5b --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/SizeInfiniFrameWindowFeature.test.ts @@ -0,0 +1,51 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("SizeInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./SizeInfiniFrameWindowFeature"); + feature = new mod.SizeInfiniFrameWindowFeature(); + }); + + it("getSizeAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({width: 800, height: 600})); + await feature.getSizeAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getHeightAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(600)); + await feature.getHeightAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getWidthAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(800)); + await feature.getWidthAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getMaxSizeAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({width: 1920, height: 1080})); + await feature.getMaxSizeAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getMinSizeAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify({width: 200, height: 150})); + await feature.getMinSizeAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isResizableAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isResizableAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("setSize posts command", () => { feature.setSize(800, 600); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setHeight posts command", () => { feature.setHeight(600); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setWidth posts command", () => { feature.setWidth(800); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setMaxSize posts command", () => { feature.setMaxSize(1920, 1080); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setMinSize posts command", () => { feature.setMinSize(200, 150); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setResizable posts command", () => { feature.setResizable(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("resize posts command", () => { feature.resize(10, 20); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/StateInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/StateInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..b6939796e --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/StateInfiniFrameWindowFeature.test.ts @@ -0,0 +1,75 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("StateInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./StateInfiniFrameWindowFeature"); + feature = new mod.StateInfiniFrameWindowFeature(); + }); + + it("isFullScreenAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(false)); + await feature.isFullScreenAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isMaximizedAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isMaximizedAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isMinimizedAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(false)); + await feature.isMinimizedAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isTopMostAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(false)); + await feature.isTopMostAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isFocusedAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isFocusedAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getZoomFactorAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(1.0)); + await feature.getZoomFactorAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("isZoomEnabledAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(true)); + await feature.isZoomEnabledAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getCachedPreFullScreenBoundsAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(null)); + await feature.getCachedPreFullScreenBoundsAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("getCachedPreMaximizedBoundsAsync calls get", async () => { + messaging.getMessageFromHostAsync.mockResolvedValue(JSON.stringify(null)); + await feature.getCachedPreMaximizedBoundsAsync(); + expect(messaging.getMessageFromHostAsync).toHaveBeenCalled(); + }); + it("setCachedPreFullScreenBounds posts command", () => { + feature.setCachedPreFullScreenBounds({left: 0, top: 0, width: 800, height: 600}); + expect(messaging.sendMessageToHost).toHaveBeenCalled(); + }); + it("setCachedPreMaximizedBounds posts command", () => { + feature.setCachedPreMaximizedBounds({left: 0, top: 0, width: 800, height: 600}); + expect(messaging.sendMessageToHost).toHaveBeenCalled(); + }); + it("setMaximized posts command", () => { feature.setMaximized(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("toggleMaximized posts command", () => { feature.toggleMaximized(); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setMinimized posts command", () => { feature.setMinimized(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setFullScreen posts command", () => { feature.setFullScreen(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setFocused posts command", () => { feature.setFocused(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setZoomFactor posts command", () => { feature.setZoomFactor(1.5); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("enableZoom posts command", () => { feature.enableZoom(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + it("setTopMost posts command", () => { feature.setTopMost(true); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/WebMessagingInfiniFrameWindowFeature.test.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/WebMessagingInfiniFrameWindowFeature.test.ts new file mode 100644 index 000000000..937087059 --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/WebMessagingInfiniFrameWindowFeature.test.ts @@ -0,0 +1,15 @@ +import {beforeEach, describe, expect, it} from "vitest"; +import {setupFeature} from "./_testHelpers"; + +describe("WebMessagingInfiniFrameWindowFeature", () => { + let feature: any; + let messaging: ReturnType; + + beforeEach(async () => { + messaging = setupFeature(); + const mod = await import("./WebMessagingInfiniFrameWindowFeature"); + feature = new mod.WebMessagingInfiniFrameWindowFeature(); + }); + + it("sendWebMessage posts command", () => { feature.sendWebMessage("hello"); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); +}); diff --git a/src/InfiniFrame.Js/TypeScript/Window/Features/_testHelpers.ts b/src/InfiniFrame.Js/TypeScript/Window/Features/_testHelpers.ts new file mode 100644 index 000000000..1744a3d6e --- /dev/null +++ b/src/InfiniFrame.Js/TypeScript/Window/Features/_testHelpers.ts @@ -0,0 +1,21 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +import {vi} from "vitest"; + +export function createMessagingMock() { + return { + sendMessageToHost: vi.fn(), + getMessageFromHostAsync: vi.fn(), + getMessageFromHostRawAsync: vi.fn(), + assignMessageReceivedHandler: vi.fn(), + unregisterMessageReceivedHandler: vi.fn() + }; +} + +export function setupFeature() { + vi.restoreAllMocks(); + const messaging = createMessagingMock(); + (window as any).infiniframe = {messaging}; + return messaging; +} diff --git a/src/InfiniFrame.Js/TypeScript/Window/WindowChrome.test.ts b/src/InfiniFrame.Js/TypeScript/Window/WindowChrome.test.ts index c8ffcb1d4..4a680d7ea 100644 --- a/src/InfiniFrame.Js/TypeScript/Window/WindowChrome.test.ts +++ b/src/InfiniFrame.Js/TypeScript/Window/WindowChrome.test.ts @@ -92,29 +92,21 @@ describe("WindowChrome", () => { minimizeBtn.click(); expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - expect.objectContaining({ - command: expect.stringContaining("minimize") - }) + expect.objectContaining({command: expect.stringContaining("minimize")}) ); vi.clearAllMocks(); - maximizeBtn.click(); expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - expect.objectContaining({ - command: expect.stringContaining("toggleMaximize") - }) + expect.objectContaining({command: expect.stringContaining("toggleMaximize")}) ); vi.clearAllMocks(); - closeBtn.click(); expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - expect.objectContaining({ - command: expect.stringContaining(":close") - }) + expect.objectContaining({command: expect.stringContaining(":close")}) ); }); @@ -128,13 +120,7 @@ describe("WindowChrome", () => { expect(resizeRight.setPointerCapture).toHaveBeenCalledWith(1); vi.clearAllMocks(); - - const pointerMove = new PointerEvent("pointermove", { - bubbles: true, - pointerId: 1, - movementX: 5, - movementY: 3 - }); + const pointerMove = new PointerEvent("pointermove", {bubbles: true, pointerId: 1, movementX: 5, movementY: 3}); resizeRight.dispatchEvent(pointerMove); expect(messaging.sendMessageToHost).toHaveBeenCalledWith( @@ -165,9 +151,7 @@ describe("WindowChrome", () => { minimizeBtn.click(); expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - expect.objectContaining({ - command: expect.stringContaining("minimize") - }) + expect.objectContaining({command: expect.stringContaining("minimize")}) ); }); @@ -183,18 +167,11 @@ describe("WindowChrome", () => { it("maps data-infiniframe-resize values correctly", () => { const testCases = [ - ["top", "top"], - ["right", "right"], - ["bottom", "bottom"], - ["left", "left"], - ["top-left", "topLeft"], - ["top-right", "topRight"], - ["bottom-left", "bottomLeft"], - ["bottom-right", "bottomRight"], - ["topLeft", "topLeft"], - ["topRight", "topRight"], - ["bottomLeft", "bottomLeft"], - ["bottomRight", "bottomRight"] + ["top", "top"], ["right", "right"], ["bottom", "bottom"], ["left", "left"], + ["top-left", "topLeft"], ["top-right", "topRight"], + ["bottom-left", "bottomLeft"], ["bottom-right", "bottomRight"], + ["topLeft", "topLeft"], ["topRight", "topRight"], + ["bottomLeft", "bottomLeft"], ["bottomRight", "bottomRight"] ]; for (const [attrValue, expectedOrigin] of testCases) { @@ -207,24 +184,38 @@ describe("WindowChrome", () => { el.dispatchEvent(pointerDown); vi.clearAllMocks(); - const pointerMove = new PointerEvent("pointermove", { - bubbles: true, - pointerId: 1, - movementX: 10, - movementY: 5 - }); + const pointerMove = new PointerEvent("pointermove", {bubbles: true, pointerId: 1, movementX: 10, movementY: 5}); el.dispatchEvent(pointerMove); expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - expect.objectContaining({ - args: expect.objectContaining({origin: expectedOrigin}) - }) + expect.objectContaining({args: expect.objectContaining({origin: expectedOrigin})}) ); testChrome.unregister(); } }); + + it("warns on unknown data-infiniframe-resize value", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + createElement("div", {"data-infiniframe-resize": "unknown"}); + chrome.register({}); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it("ignores data-infiniframe-resize with empty value", () => { + createElement("div", {"data-infiniframe-resize": ""}); + chrome.register({}); + // Should not throw + }); + + it("ignores data-infiniframe-window-action with unknown action", () => { + createElement("button", {"data-infiniframe-window-action": "unknown"}); + chrome.register({}); + // Should not throw + }); }); describe("unregister", () => { @@ -242,7 +233,6 @@ describe("WindowChrome", () => { chrome.unregister(); vi.clearAllMocks(); - dragArea.dispatchEvent(new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0})); minimizeBtn.click(); resizeEl.dispatchEvent(new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0})); @@ -256,12 +246,15 @@ describe("WindowChrome", () => { chrome.unregister(); vi.clearAllMocks(); - chrome.register({dragRegion: "#titlebar"}); dragArea.dispatchEvent(new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0})); expect(messaging.sendMessageToHost).toHaveBeenCalled(); }); + + it("does nothing when not registered", () => { + chrome.unregister(); + }); }); describe("double-click maximize", () => { @@ -273,9 +266,7 @@ describe("WindowChrome", () => { expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - expect.objectContaining({ - command: expect.stringContaining("toggleMaximize") - }) + expect.objectContaining({command: expect.stringContaining("toggleMaximize")}) ); }); }); @@ -292,14 +283,102 @@ describe("WindowChrome", () => { }); }); + describe("pointer move", () => { + it("does nothing when not resizing", () => { + const dragArea = createElement("div", {id: "titlebar"}); + chrome.register({dragRegion: "#titlebar"}); + + const pointerMove = new PointerEvent("pointermove", {bubbles: true, pointerId: 1}); + dragArea.dispatchEvent(pointerMove); + }); + }); + + describe("pointer up", () => { + it("does nothing when not dragging or resizing", () => { + const dragArea = createElement("div", {id: "titlebar"}); + chrome.register({dragRegion: "#titlebar"}); + + const pointerUp = new PointerEvent("pointerup", {bubbles: true, pointerId: 1}); + dragArea.dispatchEvent(pointerUp); + }); + + it("ends drag on pointer up", () => { + const dragArea = createElement("div", {id: "titlebar"}); + chrome.register({dragRegion: "#titlebar"}); + + // Start drag + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + dragArea.dispatchEvent(pointerDown); + + // End drag + const pointerUp = new PointerEvent("pointerup", {bubbles: true, pointerId: 1}); + dragArea.dispatchEvent(pointerUp); + }); + }); + + describe("resize lost capture", () => { + it("cleans up on resize lost pointer capture", () => { + const resizeEl = createElement("div", {id: "resize-right"}); + chrome.register({resize: {right: "#resize-right"}}); + + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + resizeEl.dispatchEvent(pointerDown); + + // Simulate lostpointercapture + const lostCapture = new Event("lostpointercapture"); + resizeEl.dispatchEvent(lostCapture); + }); + }); + + describe("drag lost capture", () => { + it("cleans up on drag lost pointer capture", () => { + const dragArea = createElement("div", {id: "titlebar"}); + chrome.register({dragRegion: "#titlebar"}); + + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + dragArea.dispatchEvent(pointerDown); + + // Simulate lostpointercapture + const lostCapture = new Event("lostpointercapture"); + dragArea.dispatchEvent(lostCapture); + }); + }); + + describe("releasePointerCaptureIfHeld", () => { + it("releases capture when pointerId is non-zero and has capture", () => { + Element.prototype.hasPointerCapture = vi.fn().mockReturnValue(true); + const dragArea = createElement("div", {id: "titlebar"}); + chrome.register({dragRegion: "#titlebar"}); + + // Start drag to set lastPointerId + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + dragArea.dispatchEvent(pointerDown); + + // Double-click triggers releasePointerCaptureIfHeld + dragArea.dispatchEvent(new MouseEvent("dblclick", {bubbles: true})); + + expect(dragArea.releasePointerCapture).toHaveBeenCalled(); + }); + + it("does not release when lastPointerId is 0", () => { + const dragArea = createElement("div", {id: "titlebar"}); + chrome.register({dragRegion: "#titlebar"}); + + // Double-click without pointerDown first + dragArea.dispatchEvent(new MouseEvent("dblclick", {bubbles: true})); + + // Should not throw + }); + }); + describe("edge cases", () => { it("handles register called before DOM ready", () => { const originalReadyState = document.readyState; - Object.defineProperty(document, 'readyState', {value: 'loading', writable: true}); + Object.defineProperty(document, "readyState", {value: "loading", writable: true}); chrome.register({dragRegion: "#titlebar"}); - Object.defineProperty(document, 'readyState', {value: originalReadyState, writable: true}); + Object.defineProperty(document, "readyState", {value: originalReadyState, writable: true}); }); it("handles invalid selectors gracefully", () => { @@ -312,7 +391,6 @@ describe("WindowChrome", () => { }); expect(chrome).toBeDefined(); - consoleWarn.mockRestore(); }); @@ -328,6 +406,18 @@ describe("WindowChrome", () => { ); expect(restoreCalls).toHaveLength(1); }); + + it("handles empty config", () => { + chrome.register({}); + // Should not throw + }); + + it("setup does nothing when config is null after register", () => { + chrome.register({dragRegion: "#titlebar"}); + // Manually set config to null to test guard + (chrome as any).config = null; + (chrome as any).setup(); + }); }); describe("messaging not ready", () => { @@ -343,7 +433,6 @@ describe("WindowChrome", () => { expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining("messaging bridge not ready") ); - consoleWarn.mockRestore(); }); }); @@ -357,10 +446,7 @@ describe("WindowChrome", () => { expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - { - command: "__infiniframe:window:features:windowChrome:minimize", - args: undefined - } + {command: "__infiniframe:window:features:windowChrome:minimize", args: undefined} ); }); @@ -371,21 +457,92 @@ describe("WindowChrome", () => { resizeEl.dispatchEvent(new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0})); vi.clearAllMocks(); - - resizeEl.dispatchEvent(new PointerEvent("pointermove", { - bubbles: true, - pointerId: 1, - movementX: 15, - movementY: -8 - })); + resizeEl.dispatchEvent(new PointerEvent("pointermove", {bubbles: true, pointerId: 1, movementX: 15, movementY: -8})); expect(messaging.sendMessageToHost).toHaveBeenCalledWith( SendToHostMessageIds.windowFeatureRequest, - { - command: "__infiniframe:window:features:windowChrome:resize", - args: {widthOffset: 15, heightOffset: -8, origin: "bottom"} - } + {command: "__infiniframe:window:features:windowChrome:resize", args: {widthOffset: 15, heightOffset: -8, origin: "bottom"}} ); }); }); + + describe("mutation observer", () => { + it("responds to childList mutations", () => { + chrome.register({}); + + // Add an element with data attribute to trigger mutation observer + const el = document.createElement("div"); + el.setAttribute("data-infiniframe-drag-region", ""); + document.body.appendChild(el); + }); + + it("responds to attribute mutations on data-infiniframe-drag-region", () => { + const el = createElement("div", {id: "test"}); + chrome.register({}); + + el.setAttribute("data-infiniframe-drag-region", ""); + }); + }); + + describe("pointer events", () => { + it("pointerup ends drag when isDragging is true", () => { + const dragArea = createElement("div", {id: "titlebar"}); + chrome.register({dragRegion: "#titlebar"}); + + // Start drag + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + dragArea.dispatchEvent(pointerDown); + + // End drag + const pointerUp = new PointerEvent("pointerup", {bubbles: true, pointerId: 1}); + dragArea.dispatchEvent(pointerUp); + }); + + it("pointerup ends resize when isResizing is true", () => { + const resizeEl = createElement("div", {id: "resize-right"}); + chrome.register({resize: {right: "#resize-right"}}); + + // Start resize + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + resizeEl.dispatchEvent(pointerDown); + + // End resize + const pointerUp = new PointerEvent("pointerup", {bubbles: true, pointerId: 1}); + resizeEl.dispatchEvent(pointerUp); + }); + + it("pointermove triggers resize when isResizing is true", () => { + const resizeEl = createElement("div", {id: "resize-right"}); + chrome.register({resize: {right: "#resize-right"}}); + + // Start resize + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + resizeEl.dispatchEvent(pointerDown); + + // Move during resize + const pointerMove = new PointerEvent("pointermove", {bubbles: true, pointerId: 1, movementX: 5, movementY: 3}); + resizeEl.dispatchEvent(pointerMove); + + expect(messaging.sendMessageToHost).toHaveBeenCalled(); + }); + + it("pointermove does nothing when not resizing", () => { + const resizeEl = createElement("div", {id: "resize-right"}); + chrome.register({resize: {right: "#resize-right"}}); + + // Move without starting resize + const pointerMove = new PointerEvent("pointermove", {bubbles: true, pointerId: 1, movementX: 5, movementY: 3}); + resizeEl.dispatchEvent(pointerMove); + }); + + it("pointerdown on resize element starts resize", () => { + const resizeEl = createElement("div", {id: "resize-bottom"}); + chrome.register({resize: {bottom: "#resize-bottom"}}); + + const pointerDown = new PointerEvent("pointerdown", {bubbles: true, pointerId: 1, button: 0}); + resizeEl.dispatchEvent(pointerDown); + + expect(resizeEl.setPointerCapture).toHaveBeenCalled(); + }); + }); }); diff --git a/src/InfiniFrame.Js/package-lock.json b/src/InfiniFrame.Js/package-lock.json index 491df5090..6a0fdcd59 100644 --- a/src/InfiniFrame.Js/package-lock.json +++ b/src/InfiniFrame.Js/package-lock.json @@ -10,13 +10,13 @@ "license": "GNUv3", "devDependencies": { "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.10", - "concurrently": "^10.0.4", + "@vitest/coverage-v8": "^4.1.11", + "concurrently": "^10.0.5", "jsdom": "^30.0.1", "terser": "^5.50.0", "typescript": "^7.0.2", "vite": "^8.2.1", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } }, "node_modules/@asamuzakjp/css-color": { @@ -989,14 +989,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1010,8 +1010,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1020,16 +1020,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1038,13 +1038,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1065,9 +1065,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1078,13 +1078,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1092,14 +1092,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1108,9 +1108,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1118,13 +1118,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1256,9 +1256,9 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", - "integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==", + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", "dev": true, "license": "MIT", "dependencies": { @@ -1366,9 +1366,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2262,9 +2262,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -2289,9 +2289,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -2492,19 +2492,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2532,12 +2532,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/src/InfiniFrame.Js/package.json b/src/InfiniFrame.Js/package.json index acf43a8ac..2b9c365a7 100644 --- a/src/InfiniFrame.Js/package.json +++ b/src/InfiniFrame.Js/package.json @@ -15,12 +15,12 @@ "description": "", "devDependencies": { "@types/node": "^26.2.0", - "@vitest/coverage-v8": "^4.1.10", - "concurrently": "^10.0.4", + "@vitest/coverage-v8": "^4.1.11", + "concurrently": "^10.0.5", "jsdom": "^30.0.1", "terser": "^5.50.0", "typescript": "^7.0.2", "vite": "^8.2.1", - "vitest": "^4.1.10" + "vitest": "^4.1.11" } } diff --git a/src/InfiniFrame.Js/vitest.config.ts b/src/InfiniFrame.Js/vitest.config.ts index abcbba033..349a4639d 100644 --- a/src/InfiniFrame.Js/vitest.config.ts +++ b/src/InfiniFrame.Js/vitest.config.ts @@ -8,7 +8,19 @@ export default defineConfig({ restoreMocks: true, coverage: { provider: "v8", - reporter: ["text", "lcov"] + reporter: ["text", "lcov"], + include: ["TypeScript/**/*.ts"], + exclude: [ + "TypeScript/Contracts/**", + "TypeScript/Window/Features/index.ts", + "TypeScript/Utils/index.ts" + ], + thresholds: { + lines: 85, + branches: 65, + functions: 90, + statements: 84 + } } } }); diff --git a/src/InfiniFrame.NativeBridge/InfiniFrame.NativeBridge.csproj b/src/InfiniFrame.NativeBridge/InfiniFrame.NativeBridge.csproj index 3616358ee..18b5bce27 100644 --- a/src/InfiniFrame.NativeBridge/InfiniFrame.NativeBridge.csproj +++ b/src/InfiniFrame.NativeBridge/InfiniFrame.NativeBridge.csproj @@ -8,6 +8,10 @@ true + + + + diff --git a/src/InfiniFrame.NativeBridge/Native/src/Runtime/Platform/Mac/MacDiagnostics.mm b/src/InfiniFrame.NativeBridge/Native/src/Runtime/Platform/Mac/MacDiagnostics.mm index e7bccf9e1..8423f85d8 100644 --- a/src/InfiniFrame.NativeBridge/Native/src/Runtime/Platform/Mac/MacDiagnostics.mm +++ b/src/InfiniFrame.NativeBridge/Native/src/Runtime/Platform/Mac/MacDiagnostics.mm @@ -26,12 +26,17 @@ std::mutex nativeCallbackMutex; std::condition_variable nativeCallbackCondition; void WriteSignalMessage(const int signalNumber) noexcept { - static constexpr char prefix[] = "\n[InfiniFrame macOS fatal signal] native stack follows\n"; - (void)!write(STDERR_FILENO, prefix, sizeof(prefix) - 1); + std::fprintf(stderr, "\n[InfiniFrame macOS fatal signal] signal=%d native stack follows\n", signalNumber); void* frames[128]; const int frameCount = backtrace(frames, 128); - backtrace_symbols_fd(frames, frameCount, STDERR_FILENO); + char** symbols = backtrace_symbols(frames, frameCount); + if (symbols != nullptr) { + for (int i = 0; i < frameCount; ++i) + std::fprintf(stderr, " %s\n", symbols[i]); + std::free(symbols); + } + std::fflush(stderr); signal(signalNumber, SIG_DFL); raise(signalNumber); diff --git a/src/InfiniFrame.Shared/InfiniFrame.Shared.csproj b/src/InfiniFrame.Shared/InfiniFrame.Shared.csproj index 2844be16b..00100474b 100644 --- a/src/InfiniFrame.Shared/InfiniFrame.Shared.csproj +++ b/src/InfiniFrame.Shared/InfiniFrame.Shared.csproj @@ -6,6 +6,11 @@ true + + + + + diff --git a/src/InfiniFrame.Shared/Utilities/ColorUtility.cs b/src/InfiniFrame.Shared/Utilities/ColorUtility.cs new file mode 100644 index 000000000..b21671434 --- /dev/null +++ b/src/InfiniFrame.Shared/Utilities/ColorUtility.cs @@ -0,0 +1,60 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +namespace InfiniFrame.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// Pure utility methods for parsing and validating hex color strings. +/// +public static class ColorUtility { + + /// + /// Validates whether a color string is a valid hex color format. + /// + /// The color string to validate (e.g. "#RRGGBB", "#AARRGGBB", null, or "transparent"). + /// true if the color is valid; otherwise false. + public static bool IsValidBackgroundColor(string? color) { + if (color is null or "transparent") return true; + if (!color.StartsWith('#')) return false; + + string hex = color[1..]; + return hex.Length is 6 or 8 && hex.All(IsHexDigit); + } + + /// + /// Parses a hex color string into its RGBA components. + /// + public static void ParseBackgroundColor(string? color, out byte r, out byte g, out byte b, out byte a) { + if (color is null or "transparent") { + r = g = b = a = 0; + return; + } + + string hex = color.StartsWith('#') ? color[1..] : color; + + if (hex.Length == 8) { + a = (byte)(HexDigitValue(hex[0]) << 4 | HexDigitValue(hex[1])); + r = (byte)(HexDigitValue(hex[2]) << 4 | HexDigitValue(hex[3])); + g = (byte)(HexDigitValue(hex[4]) << 4 | HexDigitValue(hex[5])); + b = (byte)(HexDigitValue(hex[6]) << 4 | HexDigitValue(hex[7])); + } else { + r = (byte)(HexDigitValue(hex[0]) << 4 | HexDigitValue(hex[1])); + g = (byte)(HexDigitValue(hex[2]) << 4 | HexDigitValue(hex[3])); + b = (byte)(HexDigitValue(hex[4]) << 4 | HexDigitValue(hex[5])); + a = 255; + } + } + + internal static bool IsHexDigit(char c) => + c is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f'; + + internal static int HexDigitValue(char c) => + c switch { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'F' => c - 'A' + 10, + >= 'a' and <= 'f' => c - 'a' + 10, + _ => -1 + }; +} diff --git a/src/InfiniFrame.Shared/Utilities/CustomSchemeResponseValidator.cs b/src/InfiniFrame.Shared/Utilities/CustomSchemeResponseValidator.cs new file mode 100644 index 000000000..4624254b7 --- /dev/null +++ b/src/InfiniFrame.Shared/Utilities/CustomSchemeResponseValidator.cs @@ -0,0 +1,45 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +namespace InfiniFrame.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// Pure validation logic for custom scheme responses. +/// Extracted from +/// InfiniFrameEvents.CustomScheme +/// +/// for testability. +/// +public static class CustomSchemeResponseValidator { + + /// + /// Validates and normalizes a content type string for custom scheme responses. + /// + /// The normalized content type. + /// Thrown if the content type is invalid. + public static string ValidateContentType(string? contentType) { + string normalized = string.IsNullOrWhiteSpace(contentType) + ? "application/octet-stream" + : contentType; + + if (normalized.IndexOfAny(['\r', '\n', '\0', '\t']) >= 0) + throw new InvalidDataException("Custom scheme content type contains invalid control characters."); + + byte[] contentTypeBytes = System.Text.Encoding.UTF8.GetBytes(normalized); + if (contentTypeBytes.Length > 256) + throw new InvalidDataException("Custom scheme content type exceeds the 256-byte limit."); + + return normalized; + } + + /// + /// Validates that a response body length is within the allowed limit. + /// + /// Thrown if the body is too large. + public static void ValidateBodyLength(long? bodyLength) { + if (bodyLength is < 0 || (ulong)(bodyLength ?? 0) > 2 * 1024 * 1024) + throw new InvalidDataException("Custom scheme response exceeds the 2MB limit."); + } +} diff --git a/src/InfiniFrame.Shared/Utilities/EndpointStatusResolver.cs b/src/InfiniFrame.Shared/Utilities/EndpointStatusResolver.cs new file mode 100644 index 000000000..8636e3b76 --- /dev/null +++ b/src/InfiniFrame.Shared/Utilities/EndpointStatusResolver.cs @@ -0,0 +1,43 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Debugging; + +namespace InfiniFrame.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// Pure logic for determining the remote debugging endpoint status. +/// +public static class EndpointStatusResolver { + + /// + /// Determines the endpoint status from a set of conditions. + /// + public static InfiniFrameDebugEndpointStatus Resolve( + bool isPlatformSupported, + int? remoteDebuggingPort, + bool isWindowClosed, + bool hasEndpoint, + bool probeSucceeded, + string? probeReason + ) { + if (!isPlatformSupported) + return InfiniFrameDebugEndpointStatus.NotSupported; + + if (!remoteDebuggingPort.HasValue) + return InfiniFrameDebugEndpointStatus.Disabled; + + if (isWindowClosed || !hasEndpoint) + return InfiniFrameDebugEndpointStatus.Unavailable; + + if (probeSucceeded) + return InfiniFrameDebugEndpointStatus.Reachable; + + if (string.IsNullOrWhiteSpace(probeReason)) + return InfiniFrameDebugEndpointStatus.Configured; + + return InfiniFrameDebugEndpointStatus.Unreachable; + } +} diff --git a/src/InfiniFrame.Shared/Utilities/MenuItemTreeHelper.cs b/src/InfiniFrame.Shared/Utilities/MenuItemTreeHelper.cs new file mode 100644 index 000000000..56c475b04 --- /dev/null +++ b/src/InfiniFrame.Shared/Utilities/MenuItemTreeHelper.cs @@ -0,0 +1,38 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Collections.Immutable; + +namespace InfiniFrame.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// Pure logic for recursively updating immutable menu item trees. +/// +public static class MenuItemTreeHelper { + + /// + /// Recursively finds a menu item by ID and applies an updater function. + /// Returns a new immutable array with the updated item. + /// + public static ImmutableArray UpdateItem( + ImmutableArray items, + string menuItemId, + Func updater + ) { + ImmutableArray.Builder builder = items.ToBuilder(); + + for (int i = 0; i < builder.Count; i++) { + if (builder[i].Id == menuItemId) { + builder[i] = updater(builder[i]); + } else if (!builder[i].Children.IsDefaultOrEmpty) { + builder[i] = builder[i] with { + Children = UpdateItem(builder[i].Children, menuItemId, updater) + }; + } + } + + return builder.ToImmutable(); + } +} diff --git a/src/InfiniFrame.Shared/Utilities/MonitorOverlapCalculator.cs b/src/InfiniFrame.Shared/Utilities/MonitorOverlapCalculator.cs new file mode 100644 index 000000000..9ffd0cb26 --- /dev/null +++ b/src/InfiniFrame.Shared/Utilities/MonitorOverlapCalculator.cs @@ -0,0 +1,70 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Collections.Immutable; +using System.Drawing; + +namespace InfiniFrame.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// Pure geometry logic for monitor overlap and nearest-monitor computation. +/// +public static class MonitorOverlapCalculator { + + /// + /// Determines which monitor contains or is nearest to the specified window bounds. + /// Uses overlap fraction (primary) and Euclidean distance (fallback). + /// + public static bool TryFindBestMonitor(ImmutableArray monitors, Rectangle windowBounds, out int bestIndex) { + bestIndex = -1; + if (monitors.IsDefaultOrEmpty) return false; + + long windowArea = Math.Max(0, (long)windowBounds.Width); + windowArea *= Math.Max(0, windowBounds.Height); + + double bestWindowFraction = -1.0; + long bestOverlap = 0; + + for (int i = 0; i < monitors.Length; i++) { + InfiniMonitor m = monitors[i]; + + Rectangle intersection = Rectangle.Intersect(m.MonitorArea, windowBounds); + long overlap = 0; + if (intersection.Width > 0 && intersection.Height > 0) { + overlap = intersection.Width * (long)intersection.Height; + } + + double windowFraction = windowArea > 0 ? (double)overlap / windowArea : 0.0; + + bool isBetter = windowFraction > bestWindowFraction + || Math.Abs(windowFraction - bestWindowFraction) < double.Epsilon + && overlap > bestOverlap; + if (!isBetter) continue; + + bestWindowFraction = windowFraction; + bestOverlap = overlap; + bestIndex = i; + } + + if (bestIndex != -1 && bestOverlap > 0) return true; + + // Fallback: nearest monitor by center distance + var windowCenter = new Point(windowBounds.Left + windowBounds.Width / 2, windowBounds.Top + windowBounds.Height / 2); + double bestDistSq = double.MaxValue; + foreach (InfiniMonitor m in monitors) { + Rectangle r = m.MonitorArea; + var monitorCenter = new Point(r.Left + r.Width / 2, r.Top + r.Height / 2); + double dx = monitorCenter.X - windowCenter.X; + double dy = monitorCenter.Y - windowCenter.Y; + double distSq = dx * dx + dy * dy; + if (distSq >= bestDistSq) continue; + + bestDistSq = distSq; + bestIndex = Array.IndexOf(monitors.ToArray(), m); + } + + return true; + } +} diff --git a/src/InfiniFrame.Shared/Utilities/PositionCalculations.cs b/src/InfiniFrame.Shared/Utilities/PositionCalculations.cs new file mode 100644 index 000000000..557a5f528 --- /dev/null +++ b/src/InfiniFrame.Shared/Utilities/PositionCalculations.cs @@ -0,0 +1,48 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Drawing; + +namespace InfiniFrame.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// Pure calculation logic for window position operations. +/// +internal static class PositionCalculations { + + /// + /// Computes the centered position of a window within a monitor area. + /// + public static Point ComputeCenter(Rectangle monitorArea, int windowWidth, int windowHeight) + => new Point( + monitorArea.X + monitorArea.Width / 2 - windowWidth / 2, + monitorArea.Y + monitorArea.Height / 2 - windowHeight / 2 + ); + + /// + /// Clamps a window position so it remains fully within the monitor work area. + /// + public static (int Left, int Top) ClampToMonitorArea( + int left, int top, int windowWidth, int windowHeight, Rectangle workArea + ) { + int horizontalWindowEdge = left + windowWidth; + int verticalWindowEdge = top + windowHeight; + + int leftBound = workArea.X; + int topBound = workArea.Y; + int rightBound = workArea.X + workArea.Width; + int bottomBound = workArea.Y + workArea.Height; + + left = horizontalWindowEdge > rightBound + ? Math.Max(rightBound - windowWidth, leftBound) + : Math.Max(left, leftBound); + + top = verticalWindowEdge > bottomBound + ? Math.Max(bottomBound - windowHeight, topBound) + : Math.Max(top, topBound); + + return (left, top); + } +} diff --git a/src/InfiniFrame.Shared/Utilities/SizeCalculations.cs b/src/InfiniFrame.Shared/Utilities/SizeCalculations.cs new file mode 100644 index 000000000..f515b04b5 --- /dev/null +++ b/src/InfiniFrame.Shared/Utilities/SizeCalculations.cs @@ -0,0 +1,108 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Drawing; + +namespace InfiniFrame.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +/// +/// Pure calculation logic for window resize operations. +/// +internal static class SizeCalculations { + + /// + /// Computes the new window bounds after a resize from a given origin. + /// + public static (int X, int Y, int Width, int Height) ComputeResize( + int originalX, int originalY, int originalWidth, int originalHeight, + int widthOffset, int heightOffset, ResizeOrigin origin + ) { + int x = originalX; + int y = originalY; + int width = originalWidth; + int height = originalHeight; + + switch (origin) { + case ResizeOrigin.TopLeft: + x += widthOffset; + y += heightOffset; + width -= widthOffset; + height -= heightOffset; + break; + + case ResizeOrigin.Top: + y += heightOffset; + height -= heightOffset; + break; + + case ResizeOrigin.TopRight: + y += heightOffset; + width += widthOffset; + height -= heightOffset; + break; + + case ResizeOrigin.Right: + width += widthOffset; + break; + + case ResizeOrigin.BottomRight: + width += widthOffset; + height += heightOffset; + break; + + case ResizeOrigin.Bottom: + height += heightOffset; + break; + + case ResizeOrigin.BottomLeft: + x += widthOffset; + width -= widthOffset; + height += heightOffset; + break; + + case ResizeOrigin.Left: + x += widthOffset; + width -= widthOffset; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(origin), origin, null); + } + + return (x, y, width, height); + } + + /// + /// Clamps the computed resize bounds to min/max size constraints, + /// resetting position to original when clamped. + /// + public static (int X, int Y, int Width, int Height) ClampResize( + int x, int y, int width, int height, + int originalX, int originalY, + Size minSize, Size maxSize + ) { + if (width >= maxSize.Width) { + width = maxSize.Width; + x = originalX; + } + + if (height >= maxSize.Height) { + height = maxSize.Height; + y = originalY; + } + + if (width <= minSize.Width) { + width = minSize.Width; + x = originalX; + } + + if (height <= minSize.Height) { + height = minSize.Height; + y = originalY; + } + + return (x, y, width, height); + } +} diff --git a/src/InfiniFrame.SingleFile/InfiniFrame.SingleFile.csproj b/src/InfiniFrame.SingleFile/InfiniFrame.SingleFile.csproj new file mode 100644 index 000000000..ea8e92706 --- /dev/null +++ b/src/InfiniFrame.SingleFile/InfiniFrame.SingleFile.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + Exe + true + infiniframe-singlefile + InfiniLore.InfiniFrame.SingleFile + Single-file packaging tool for InfiniFrame applications. Embeds all static web assets, native libraries, and framework files into a single executable. + infiniframe;dotnet-tool;publish;single-file;blazor + + + + + + + + + + + + + + + + + diff --git a/src/InfiniFrame.SingleFile/InfiniFrame.SingleFile.targets b/src/InfiniFrame.SingleFile/InfiniFrame.SingleFile.targets new file mode 100644 index 000000000..a64562ed6 --- /dev/null +++ b/src/InfiniFrame.SingleFile/InfiniFrame.SingleFile.targets @@ -0,0 +1,198 @@ + + + + + + <_InfiniFramePackActive>false + + + <_InfiniFramePackActive>true + + + + $(DefineConstants);InfiniFramePack + + + + + + + + + + <_InfiniFrameSingleFileRid Condition="'$(InfiniFrameSingleFileRid)' != '' and '$(InfiniFrameSingleFileRid)' != 'auto'">$(InfiniFrameSingleFileRid) + <_InfiniFrameSingleFileRid Condition="'$(_InfiniFrameSingleFileRid)' == ''">$(RuntimeIdentifier) + <_InfiniFrameSingleFileSelfContained Condition="'$(InfiniFrameSingleFileSelfContained)' != ''">$(InfiniFrameSingleFileSelfContained) + <_InfiniFrameSingleFileSelfContained Condition="'$(InfiniFrameSingleFileSelfContained)' == ''">true + <_InfiniFrameSingleFileConfig Condition="'$(InfiniFrameSingleFileConfig)' != ''">$(InfiniFrameSingleFileConfig) + <_InfiniFrameSingleFileConfig Condition="'$(InfiniFrameSingleFileConfig)' == ''">$(Configuration) + <_InfiniFrameSingleFileStageDir>$(MSBuildProjectDirectory)\obj\InfiniFrame.SingleFile\stage + + + + + + + + + + + + + + + + + + <_InfiniFramePackPublishDir>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(OutputPath)', 'publish')) + + + <_InfiniFramePackSidecarFile Include="$(_InfiniFramePackPublishDir)\*.staticwebassets.endpoints.json" /> + <_InfiniFramePackSidecarFile Include="$(_InfiniFramePackPublishDir)\web.config" /> + + + + + + + + + + + + + <_InfiniFramePackAllWwwrootFiles Include="$(InfiniFramePackEmbedDir)\**\*" /> + + + + + + + + + + + <_InfiniFramePackSwaCandidate Include="@(StaticWebAsset)" + Condition="'%(StaticWebAsset.BasePath)' != '' and '%(StaticWebAsset.RelativePath)' != ''" /> + + + + <_InfiniFramePackSwaWithDots Include="@(_InfiniFramePackSwaCandidate)"> + $([System.String]::Copy('%(BasePath)/%(RelativePath)').Replace('/', '.').Replace('\', '.')) + + + + + + + + + + + + <_InfiniFramePackWwwrootFiles Include="$(MSBuildProjectDirectory)/wwwroot/**/*" + Exclude="@(EmbeddedResource)" /> + + + + + + + + + + + + + + <_InfiniFramePackNativeCandidate Include="@(ResolvedFileToPublish)" + Condition="'%(Filename)%(Extension)' == 'InfiniFrame.Native.dll'" /> + <_InfiniFramePackNativeCandidate Include="@(ResolvedFileToPublish)" + Condition="'%(Filename)%(Extension)' == 'WebView2Loader.dll'" /> + <_InfiniFramePackNativeCandidate Include="@(ResolvedFileToPublish)" + Condition="'%(Filename)%(Extension)' == 'InfiniFrame.Native.so'" /> + <_InfiniFramePackNativeCandidate Include="@(ResolvedFileToPublish)" + Condition="'%(Filename)%(Extension)' == 'InfiniFrame.Native.dylib'" /> + + + + + + + + + + + + + + + + + <_InfiniFramePackPublishDir>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(OutputPath)', 'publish')) + + + + + <_InfiniFramePackSidecarFile Include="$(_InfiniFramePackPublishDir)\*.staticwebassets.endpoints.json" /> + <_InfiniFramePackSidecarFile Include="$(_InfiniFramePackPublishDir)\web.config" /> + + + + + + + + + + + + diff --git a/src/InfiniFrame.SingleFile/Program.cs b/src/InfiniFrame.SingleFile/Program.cs new file mode 100644 index 000000000..d41eae8f8 --- /dev/null +++ b/src/InfiniFrame.SingleFile/Program.cs @@ -0,0 +1,179 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.CommandLine; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace InfiniFrame.SingleFile; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public static class Program { + public static async Task Main(string[] args) { + string rid; + string? framework; + string configuration; + bool selfContained; + string? output; + bool verbose; + + var ridOption = new Option( + name: "--rid", + aliases: ["-r"]) { + Description = "Runtime identifier (e.g. win-x64, linux-arm64, osx-x64). Use 'auto' to detect.", + DefaultValueFactory = _ => "auto" + }; + + var frameworkOption = new Option( + name: "--framework", + aliases: ["-f"]) { + Description = "Target framework. Auto-detected from project if not specified." + }; + + var configOption = new Option( + name: "--configuration", + aliases: ["-c"]) { + Description = "Build configuration.", + DefaultValueFactory = _ => "Release" + }; + + var selfContainedOption = new Option( + name: "--self-contained") { + Description = "Produce a self-contained single-file executable.", + DefaultValueFactory = _ => true + }; + + var outputOption = new Option( + name: "--output", + aliases: ["-o"]) { + Description = "Output directory. Defaults to bin////publish." + }; + + var verboseOption = new Option( + name: "--verbose", + aliases: ["-v"]) { + Description = "Show detailed build output." + }; + + var projectArg = new Argument( + name: "project") { + Description = "Path to the .csproj file." + }; + + var rootCommand = new RootCommand("InfiniFrame SingleFile - Package InfiniFrame applications as single-file executables") { + projectArg, + ridOption, + frameworkOption, + configOption, + selfContainedOption, + outputOption, + verboseOption + }; + + rootCommand.SetAction(async (parseResult, cancellationToken) => { + FileInfo? project = parseResult.GetValue(projectArg); + rid = parseResult.GetValue(ridOption)!; + framework = parseResult.GetValue(frameworkOption); + configuration = parseResult.GetValue(configOption)!; + selfContained = parseResult.GetValue(selfContainedOption); + output = parseResult.GetValue(outputOption); + verbose = parseResult.GetValue(verboseOption); + + if (project is null || !project.Exists) { + await Console.Error.WriteLineAsync($"Project file not found: {project?.FullName ?? "(null)"}"); + return 1; + } + + if (rid == "auto") { + rid = DetectRid(); + Console.WriteLine($"Auto-detected RID: {rid}"); + } + + Console.WriteLine($"Publishing {project.Name} as single-file for {rid}..."); + + // Just invoke the MSBuild target, it handles the two-pass logic internally + var list = new List { + "publish", + project.FullName, + "-t:InfiniFrameSingleFile", + "-r", rid, + "-c", configuration, + "-p:InfiniFrameSingleFileActive=true", + "-p:InfiniFrameSingleFileRid=" + rid, + "-p:InfiniFrameSingleFileSelfContained=" + selfContained.ToString().ToLowerInvariant(), + verbose ? "-v:normal" : "-v:minimal" + }; + + if (!string.IsNullOrWhiteSpace(framework)) list.AddRange(["-f", framework]); + if (!string.IsNullOrWhiteSpace(output)) list.AddRange(["-o", output]); + + var psi = new ProcessStartInfo("dotnet") { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = System.Text.Encoding.UTF8, + StandardErrorEncoding = System.Text.Encoding.UTF8 + }; + + foreach (string arg in list) psi.ArgumentList.Add(arg); + + using var process = new Process(); + process.StartInfo = psi; + process.OutputDataReceived += (_, e) => { + if (e.Data is not null) Console.WriteLine(e.Data); + }; + process.ErrorDataReceived += (_, e) => { + if (e.Data is not null) Console.Error.WriteLine(e.Data); + }; + + if (!process.Start()) { + await Console.Error.WriteLineAsync("Failed to start dotnet publish."); + return 1; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode == 0) Console.WriteLine("Pack completed successfully."); + return process.ExitCode; + }); + + int result = await rootCommand.Parse(args).InvokeAsync(); + return result; + } + + private static string DetectRid() { + string os; + if (OperatingSystem.IsWindows()) + os = "win"; + else if (OperatingSystem.IsLinux()) + os = "linux"; + else if (OperatingSystem.IsMacOS()) + os = "osx"; + else + throw new PlatformNotSupportedException("Unsupported OS."); + + string arch; + switch (RuntimeInformation.OSArchitecture) { + case Architecture.X64: + arch = "x64"; + break; + case Architecture.Arm64: + arch = "arm64"; + break; + case Architecture.X86: + case Architecture.Arm: + case Architecture.Wasm: + case Architecture.S390x: + case Architecture.LoongArch64: + case Architecture.Armv6: + case Architecture.Ppc64le: + case Architecture.RiscV64: + default: throw new PlatformNotSupportedException($"Unsupported architecture: {RuntimeInformation.OSArchitecture}"); + } + + return $"{os}-{arch}"; + } +} diff --git a/src/InfiniFrame.Tools.Pack/CommandLine.cs b/src/InfiniFrame.Tools.Pack/CommandLine.cs deleted file mode 100644 index 60ba98d14..000000000 --- a/src/InfiniFrame.Tools.Pack/CommandLine.cs +++ /dev/null @@ -1,182 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Services; -using Microsoft.Extensions.Logging; -using System.Globalization; - -namespace InfiniFrame.Tools.Pack; -// ----------------------------------------------------------------------------------------------------------------- -// Methods -// ----------------------------------------------------------------------------------------------------------------- -internal sealed class CommandLine { - private readonly ILogger _logger; - - public CommandLine(ILogger logger) { - _logger = logger; - } - - /// - /// Parses command-line arguments into a normalized model or a usage response. - /// - /// Raw command-line arguments. - /// A parse result that indicates whether usage should be shown or publish options are ready. - /// - /// Thrown when the command is unknown, required arguments are missing, or unsupported options are provided. - /// - /// - /// Thrown when --self-contained receives a value that is not a valid boolean. - /// - public ParseResult Parse(string[] args) { - string? firstArg = args.FirstOrDefault(); - if (args.Length == 0 || firstArg is null || IsHelp(firstArg)) return ParseResult.Usage(ExitCodes.Success); - - string command = firstArg.Trim().ToLowerInvariant(); - - // ReSharper disable once ConvertIfStatementToReturnStatement - if (!command.Equals("publish", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Unknown command '{args[0]}'."); - - string[] argsWithoutCommand = args.Skip(1).ToArray(); - if (argsWithoutCommand.Length == 0) return ParseResult.Usage(ExitCodes.Success); - - PublishOptions result = ParsePublishOptions(argsWithoutCommand); - - return ParseResult.Success(result); - } - - /// - /// Prints the CLI usage text for the pack tool. - /// - public void PrintUsage() { - _logger.LogInformation("InfiniFrame.Pack"); - _logger.LogInformation("Usage:"); - _logger.LogInformation(" infiniframe-pack publish [options]"); - _logger.LogInformation(""); - _logger.LogInformation("Options:"); - _logger.LogInformation(" --rid Runtime identifier. Default: auto"); - _logger.LogInformation(" --configuration Build configuration. Default: Release"); - _logger.LogInformation(" --framework Target framework. Default: first TFM in project"); - _logger.LogInformation(" --self-contained Self-contained publish. Default: true"); - _logger.LogInformation(" --output Publish output directory"); - _logger.LogInformation(" --no-restore Skip restore"); - _logger.LogInformation(" --verbose Verbose publish output"); - _logger.LogInformation(" --timeout Per-process timeout (e.g. 600, 90s, 5m, 00:10:00). Default: 10m, max: 30m"); - _logger.LogInformation(" --force-clean-output Allow deleting non-default output directories"); - } - - private static bool IsHelp(string value) => value is "-h" or "--help" or "help"; - - private static PublishOptions ParsePublishOptions(string[] args) { - var options = new PublishOptions { - ProjectPath = string.Empty, - Rid = "auto", - Configuration = "Release", - SelfContained = true - }; - - int index = 0; - while (index < args.Length) { - string token = args[index]; - if (!token.StartsWith('-')) { - if (!string.IsNullOrWhiteSpace(options.ProjectPath)) throw new InvalidOperationException($"Unexpected argument '{token}'."); - - options.ProjectPath = token; - index++; - continue; - - } - - switch (token) { - case "--rid": - options.Rid = ReadValue(args, ref index, token); - break; - case "--configuration": - options.Configuration = ReadValue(args, ref index, token); - break; - case "--framework": - options.Framework = ReadValue(args, ref index, token); - break; - case "--self-contained": - options.SelfContained = bool.Parse(ReadValue(args, ref index, token)); - break; - case "--output": - options.Output = ReadValue(args, ref index, token); - break; - case "--no-restore": - options.NoRestore = true; - index++; - break; - case "--verbose": - options.Verbose = true; - index++; - break; - case "--timeout": - options.ProcessTimeout = ParseTimeout(ReadValue(args, ref index, token)); - break; - case "--force-clean-output": - options.ForceCleanOutput = true; - index++; - break; - default: - throw new InvalidOperationException($"Unknown option '{token}'."); - } - } - - if (string.IsNullOrWhiteSpace(options.ProjectPath)) throw new InvalidOperationException("Missing project path."); - ValidateProcessTimeout(options.ProcessTimeout); - return options; - } - - private static string ReadValue(string[] args, ref int index, string option) { - index++; - if (index >= args.Length) throw new InvalidOperationException($"Missing value for {option}."); - - string value = args[index]; - index++; - return value; - } - - private static TimeSpan ParseTimeout(string value) { - if (string.IsNullOrWhiteSpace(value)) throw new FormatException("Timeout value cannot be empty."); - - if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int seconds) && seconds > 0) { - return TimeSpan.FromSeconds(seconds); - } - - if (TryParseUnitTimeout(value, out TimeSpan unitTimeout)) return unitTimeout; - if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out TimeSpan timeSpan) && timeSpan > TimeSpan.Zero) return timeSpan; - - throw new FormatException($"Invalid timeout value '{value}'. Use a positive value like '600', '90s', '5m', or '00:10:00'."); - } - - private static bool TryParseUnitTimeout(string value, out TimeSpan timeout) { - timeout = default; - if (value.Length < 2) return false; - - char unit = char.ToLowerInvariant(value[^1]); - string numberPart = value[..^1]; - if (!double.TryParse(numberPart, NumberStyles.Float, CultureInfo.InvariantCulture, out double quantity) || quantity <= 0) { - return false; - } - - timeout = unit switch { - 's' => TimeSpan.FromSeconds(quantity), - 'm' => TimeSpan.FromMinutes(quantity), - 'h' => TimeSpan.FromHours(quantity), - _ => default - }; - - return timeout > TimeSpan.Zero; - } - - private static void ValidateProcessTimeout(TimeSpan timeout) { - if (timeout <= TimeSpan.Zero) { - throw new FormatException($"Timeout must be greater than zero. Received '{timeout}'."); - } - - if (timeout > PublishOptions.MaxProcessTimeout) { - throw new FormatException( - $"Timeout '{timeout}' exceeds the maximum supported value of '{PublishOptions.MaxProcessTimeout}'."); - } - } -} diff --git a/src/InfiniFrame.Tools.Pack/Exceptions/ExceptionsUtility.cs b/src/InfiniFrame.Tools.Pack/Exceptions/ExceptionsUtility.cs deleted file mode 100644 index 44355066d..000000000 --- a/src/InfiniFrame.Tools.Pack/Exceptions/ExceptionsUtility.cs +++ /dev/null @@ -1,18 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack.Exceptions; - -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class ExceptionsUtility { - public static bool IsNonFatalException(Exception exception) - => exception is not (ApplicationException - or OutOfMemoryException - or AccessViolationException - or StackOverflowException - or ThreadAbortException - or BadImageFormatException - or System.Runtime.InteropServices.SEHException); -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Exceptions/NativeDependencyNotFoundException.cs b/src/InfiniFrame.Tools.Pack/Exceptions/NativeDependencyNotFoundException.cs deleted file mode 100644 index a5b51f4e2..000000000 --- a/src/InfiniFrame.Tools.Pack/Exceptions/NativeDependencyNotFoundException.cs +++ /dev/null @@ -1,8 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack.Exceptions; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal sealed class NativeDependencyNotFoundException(string message) : InvalidOperationException(message); \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/ExitCodes.cs b/src/InfiniFrame.Tools.Pack/ExitCodes.cs deleted file mode 100644 index c703674ab..000000000 --- a/src/InfiniFrame.Tools.Pack/ExitCodes.cs +++ /dev/null @@ -1,14 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class ExitCodes { - public const int Success = 0; - public const int GenericFailure = 1; - public const int NativeDependencyMissing = 2; - public const int MissingMainOutput = 3; - public const int UnexpectedOutputShape = 4; -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/InfiniFrame.Tools.Pack.csproj b/src/InfiniFrame.Tools.Pack/InfiniFrame.Tools.Pack.csproj deleted file mode 100644 index 9d8b52120..000000000 --- a/src/InfiniFrame.Tools.Pack/InfiniFrame.Tools.Pack.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - net10.0 - Exe - enable - enable - true - infiniframe-pack - InfiniLore.InfiniFrame.Tools.Pack - Single-command packaging tool for InfiniFrame apps. - infiniframe;dotnet-tool;publish;single-file - - - - - - - - - - - - false - Never - - - - - - - diff --git a/src/InfiniFrame.Tools.Pack/Program.cs b/src/InfiniFrame.Tools.Pack/Program.cs deleted file mode 100644 index 474140768..000000000 --- a/src/InfiniFrame.Tools.Pack/Program.cs +++ /dev/null @@ -1,76 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Exceptions; -using InfiniFrame.Tools.Pack.Services; -using Microsoft.Extensions.DependencyInjection; -using Serilog; -using Serilog.Events; - -namespace InfiniFrame.Tools.Pack; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class Program { - /// - /// Parses command-line arguments and executes the requested pack operation. - /// - /// The command-line arguments passed to the tool process. - /// - /// 0 when usage is shown successfully or publish completes successfully; otherwise, a non-zero exit code. - /// - public static async Task Main(string[] args) { - using var cts = new CancellationTokenSource(); - ConsoleCancelEventHandler cancelHandler = (_, e) => { - e.Cancel = true; - // ReSharper disable once AccessToDisposedClosure - cts.Cancel(); - }; - Console.CancelKeyPress += cancelHandler; - - bool verbose = args.Any(arg => string.Equals(arg, "--verbose", StringComparison.OrdinalIgnoreCase)); - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Is(verbose ? LogEventLevel.Debug : LogEventLevel.Information) - .Enrich.WithProperty("Tool", "InfiniFrame.Pack") - .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") - .CreateLogger(); - - try { - var services = new ServiceCollection(); - services.AddLogging(builder => builder.AddSerilog(dispose: true)); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - using ServiceProvider provider = services.BuildServiceProvider(); - - var commandLine = provider.GetRequiredService(); - ParseResult parse = commandLine.Parse(args); - - // ReSharper disable once InvertIf - if (parse.ShowUsage) { - commandLine.PrintUsage(); - return parse.ExitCode; - } - - var publishService = provider.GetRequiredService(); - return await publishService.PublishAsync(parse.Options, cts.Token); - - } - catch (OperationCanceledException) { - Log.Warning("Operation canceled."); - return ExitCodes.GenericFailure; - } - catch (NativeDependencyNotFoundException ex) { - Log.Error(ex, "ERROR: {Message}", ex.Message); - return ExitCodes.NativeDependencyMissing; - } - catch (Exception ex) when (ExceptionsUtility.IsNonFatalException(ex)) { - Log.Error(ex, "ERROR: {Message}", ex.Message); - return ExitCodes.GenericFailure; - } - finally { - Console.CancelKeyPress -= cancelHandler; - await Log.CloseAndFlushAsync(); - } - } -} diff --git a/src/InfiniFrame.Tools.Pack/PublishOptions.cs b/src/InfiniFrame.Tools.Pack/PublishOptions.cs deleted file mode 100644 index 5c472eb98..000000000 --- a/src/InfiniFrame.Tools.Pack/PublishOptions.cs +++ /dev/null @@ -1,65 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -/// -/// Represents publish options accepted by the publish command. -/// -internal sealed class PublishOptions { - public static readonly TimeSpan DefaultProcessTimeout = TimeSpan.FromMinutes(10); - public static readonly TimeSpan MaxProcessTimeout = TimeSpan.FromMinutes(30); - - /// - /// Gets or sets the path to the project file to publish. - /// - public required string ProjectPath { get; set; } - - /// - /// Gets or sets the target runtime identifier or auto. - /// - public required string Rid { get; set; } - - /// - /// Gets or sets the build configuration. - /// - public required string Configuration { get; set; } - - /// - /// Gets or sets the target framework. When omitted, the framework is resolved from the project file. - /// - public string? Framework { get; set; } - - /// - /// Gets or sets whether publish output is self-contained. - /// - public required bool SelfContained { get; set; } - - /// - /// Gets or sets the output directory. When omitted, a default publish path under bin is used. - /// - public string? Output { get; set; } - - /// - /// Gets or sets whether restore should be skipped for the publish command. - /// - public bool NoRestore { get; set; } - - /// - /// Gets or sets whether verbose process output should be enabled. - /// - public bool Verbose { get; set; } - - /// - /// Gets or sets the timeout applied to each external dotnet invocation. - /// - public TimeSpan ProcessTimeout { get; set; } = DefaultProcessTimeout; - - /// - /// Gets or sets whether the tool may recursively delete a non-default output directory before publish. - /// - public bool ForceCleanOutput { get; set; } - -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/README.md b/src/InfiniFrame.Tools.Pack/README.md deleted file mode 100644 index c828a397e..000000000 --- a/src/InfiniFrame.Tools.Pack/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# InfiniFrame.Tools.Pack - -`InfiniFrame.Tools.Pack` is a .NET tool that publishes InfiniFrame applications as single-file binaries. - -## Install (local tool) - -From the repository root, use one of the helper scripts: - -```powershell -.\src\InfiniFrame.Tools.Pack\install-or-update-pack-tool.ps1 -``` - -```bash -bash ./src/InfiniFrame.Tools.Pack/install-or-update-pack-tool.sh -``` - -Manual alternative: - -```bash -dotnet pack src/InfiniFrame.Tools.Pack/InfiniFrame.Tools.Pack.csproj -c Release -dotnet tool install --local --add-source ./src/InfiniFrame.Tools.Pack/bin/Release InfiniLore.InfiniFrame.Tools.Pack -``` - -## Usage - -Local tool: - -```bash -dotnet tool run infiniframe-pack publish -``` - -Global tool: - -```bash -infiniframe-pack publish -``` - -Options: - -- `--rid ` -- `--configuration ` -- `--framework ` -- `--self-contained ` -- `--output ` -- `--no-restore` -- `--verbose` -- `--timeout ` (per-process timeout; examples: `600`, `90s`, `5m`, `00:10:00`; default `10m`, max `30m`) -- `--force-clean-output` (warning: allows recursive deletion of non-default output directories) - -Preflight behavior: - -- Preflight publish validation is required. -- Native artifacts must come from the project publish output for the selected RID. diff --git a/src/InfiniFrame.Tools.Pack/Resolvers/MsBuildPropertyResolver.cs b/src/InfiniFrame.Tools.Pack/Resolvers/MsBuildPropertyResolver.cs deleted file mode 100644 index 6cd24b280..000000000 --- a/src/InfiniFrame.Tools.Pack/Resolvers/MsBuildPropertyResolver.cs +++ /dev/null @@ -1,64 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using System.Diagnostics; - -namespace InfiniFrame.Tools.Pack.Resolvers; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class MsBuildPropertyResolver { - public static async Task TryGetPropertyAsync( - string projectPath, - string propertyName, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default - ) { - TimeSpan effectiveTimeout = timeout ?? TimeSpan.FromMinutes(2); - if (effectiveTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero."); - - var startInfo = new ProcessStartInfo("dotnet") { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - - startInfo.ArgumentList.Add("msbuild"); - startInfo.ArgumentList.Add(projectPath); - startInfo.ArgumentList.Add("-nologo"); - startInfo.ArgumentList.Add("-v:q"); - startInfo.ArgumentList.Add($"-getProperty:{propertyName}"); - - using var process = new Process(); - process.StartInfo = startInfo; - - if (!process.Start()) return null; - - Task stdOutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - Task stdErrTask = process.StandardError.ReadToEndAsync(cancellationToken); - using var timeoutCts = new CancellationTokenSource(effectiveTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - try { - await process.WaitForExitAsync(linkedCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { - try { - if (!process.HasExited) process.Kill(entireProcessTree: true); - } - catch (InvalidOperationException) { - // best effort - } - - throw new TimeoutException( - $"Timed out after {effectiveTimeout} while evaluating MSBuild property '{propertyName}' for '{projectPath}'."); - } - - string stdOut = (await stdOutTask).Trim(); - _ = await stdErrTask; - - if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(stdOut)) return null; - - return stdOut; - } -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Resolvers/ProjectInfoResolver.cs b/src/InfiniFrame.Tools.Pack/Resolvers/ProjectInfoResolver.cs deleted file mode 100644 index 94c8d4233..000000000 --- a/src/InfiniFrame.Tools.Pack/Resolvers/ProjectInfoResolver.cs +++ /dev/null @@ -1,77 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack.Resolvers; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class ProjectInfoResolver { - /// - /// Resolves the target framework from evaluated MSBuild properties. - /// - /// Path to the project file. - /// - /// An optional timeout specifying the maximum duration for resolving properties. - /// If not provided, the default timeout is used. - /// - /// - /// A token that allows the operation to be canceled. - /// - /// - /// The value of TargetFramework, or the first framework from TargetFrameworks when multi-targeted. - /// - /// - /// Thrown when no framework can be resolved from the evaluated project properties. - /// - public static async Task ResolveFrameworkAsync( - string projectPath, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default - ) { - string? targetFramework = await MsBuildPropertyResolver.TryGetPropertyAsync( - projectPath, - "TargetFramework", - timeout, - cancellationToken); - if (!string.IsNullOrWhiteSpace(targetFramework)) return targetFramework; - - string? targetFrameworks = await MsBuildPropertyResolver.TryGetPropertyAsync( - projectPath, - "TargetFrameworks", - timeout, - cancellationToken); - - // ReSharper disable once ConvertIfStatementToReturnStatement - if (string.IsNullOrWhiteSpace(targetFrameworks)) { - throw new InvalidOperationException("Could not resolve target framework from project evaluation. Use --framework."); - } - - return targetFrameworks.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).First(); - } - - /// - /// Resolves the assembly name using evaluated MSBuild properties. - /// - /// Path to a project file. - /// - /// Optional timeout value for the operation. If null, a default timeout is used. - /// - /// - /// A token to monitor for cancellation requests. - /// - /// - /// The AssemblyName value when present; otherwise the project file name without extension. - /// - public static async Task ResolveAssemblyNameAsync( - string projectPath, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default - ) { - string? assemblyName = await MsBuildPropertyResolver.TryGetPropertyAsync( - projectPath, - "AssemblyName", - timeout, - cancellationToken); - return string.IsNullOrWhiteSpace(assemblyName) ? Path.GetFileNameWithoutExtension(projectPath) : assemblyName; - } -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Resolvers/RuntimeResolver.cs b/src/InfiniFrame.Tools.Pack/Resolvers/RuntimeResolver.cs deleted file mode 100644 index ad17e204a..000000000 --- a/src/InfiniFrame.Tools.Pack/Resolvers/RuntimeResolver.cs +++ /dev/null @@ -1,49 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using System.Runtime.InteropServices; - -namespace InfiniFrame.Tools.Pack.Resolvers; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class RuntimeResolver { - /// - /// Resolves the runtime identifier to use for publish. - /// - /// Requested RID, or auto to infer from the current OS and architecture. - /// A concrete runtime identifier. - /// - /// Thrown when automatic RID resolution is requested on an unsupported OS or architecture. - /// - public static string ResolveRid(string requestedRid) { - if (!string.Equals(requestedRid, "auto", StringComparison.OrdinalIgnoreCase)) return requestedRid; - - string arch; - switch (RuntimeInformation.OSArchitecture) { - case Architecture.X64: - arch = "x64"; - break; - case Architecture.Arm64: - arch = "arm64"; - break; - case Architecture.X86: - case Architecture.Arm: - case Architecture.Wasm: - case Architecture.S390x: - case Architecture.LoongArch64: - case Architecture.Armv6: - case Architecture.Ppc64le: - case Architecture.RiscV64: - default: throw new PlatformNotSupportedException("Only x64 and arm64 are supported for auto RID resolution."); - } - - // ReSharper disable thrice ConvertIfStatementToReturnStatement - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return $"win-{arch}"; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return $"linux-{arch}"; - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return $"osx-{arch}"; - - throw new PlatformNotSupportedException("Unsupported OS for auto RID resolution."); - } - -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Services/InfiniFramePackNativeArtifactManifest.cs b/src/InfiniFrame.Tools.Pack/Services/InfiniFramePackNativeArtifactManifest.cs deleted file mode 100644 index 25e337ece..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/InfiniFramePackNativeArtifactManifest.cs +++ /dev/null @@ -1,38 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class InfiniFramePackNativeArtifactManifest { - public const string WindowsNativeFileName = "InfiniFrame.Native.dll"; - public const string WindowsLoaderFileName = "WebView2Loader.dll"; - public const string LinuxNativeFileName = "InfiniFrame.Native.so"; - public const string OsxNativeFileName = "InfiniFrame.Native.dylib"; - - public static readonly NativeRidArtifact[] RidArtifacts = [ - new("win-", WindowsNativeFileName), - new("win-", WindowsLoaderFileName), - new("linux-", LinuxNativeFileName), - new("osx-", OsxNativeFileName) - ]; - - public static readonly string[] AllFileNames = [ - WindowsNativeFileName, - WindowsLoaderFileName, - LinuxNativeFileName, - OsxNativeFileName - ]; - - // ReSharper disable once ConvertIfStatementToReturnStatement - public static string[] RequiredFileNamesForRid(string rid) { - if (rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase)) return [WindowsNativeFileName, WindowsLoaderFileName]; - if (rid.StartsWith("linux-", StringComparison.OrdinalIgnoreCase)) return [LinuxNativeFileName]; - if (rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase)) return [OsxNativeFileName]; - - throw new InvalidOperationException($"Unsupported RID for native artifact validation: {rid}"); - } - - internal readonly record struct NativeRidArtifact(string RidPrefix, string FileName); -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Services/ParseResult.cs b/src/InfiniFrame.Tools.Pack/Services/ParseResult.cs deleted file mode 100644 index fdb216ce9..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/ParseResult.cs +++ /dev/null @@ -1,53 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using System.Diagnostics.CodeAnalysis; - -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -/// -/// Represents the result of CLI argument parsing. -/// -internal sealed class ParseResult { - /// - /// Gets a value indicating whether usage text should be printed instead of running publish. - /// - [MemberNotNullWhen(false, nameof(Options))] - public bool ShowUsage { get; private init; } - - /// - /// Gets the process exit code that should be returned by the entrypoint. - /// - public int ExitCode { get; private init; } - - /// - /// Gets parsed publish options when is . - /// - public PublishOptions? Options { get; private init; } - - // ----------------------------------------------------------------------------------------------------------------- - // Methods - // ----------------------------------------------------------------------------------------------------------------- - /// - /// Creates a successful parse result with publish options. - /// - /// Resolved options for publish execution. - /// A parse result that can be passed to publish. - public static ParseResult Success(PublishOptions options) => new() { - ShowUsage = false, - ExitCode = ExitCodes.Success, - Options = options - }; - - /// - /// Creates a parse result that indicates usage should be shown. - /// - /// Exit code returned after printing usage. - /// A usage parse result with no publish options. - public static ParseResult Usage(int exitCode) => new() { - ShowUsage = true, - ExitCode = exitCode - }; -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Services/ProcessRunner.cs b/src/InfiniFrame.Tools.Pack/Services/ProcessRunner.cs deleted file mode 100644 index 5a83b145d..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/ProcessRunner.cs +++ /dev/null @@ -1,171 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using Microsoft.Extensions.Logging; -using System.Diagnostics; -using System.Text; - -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -/// -/// Provides functionality for running and managing external processes asynchronously. -/// -internal sealed class ProcessRunner { - /// - /// Represents the default timeout duration for processes executed using the ProcessRunner class. - /// This timeout is used to cancel the process if it exceeds the specified duration. - /// By default, the timeout is set to 10 minutes. - /// - public static readonly TimeSpan DefaultProcessTimeout = TimeSpan.FromMinutes(10); - - private readonly ILogger _logger; - - public ProcessRunner(ILogger logger) { - _logger = logger; - } - - /// - /// Asynchronously executes an external process using the specified parameters and returns the exit code upon completion. - /// - /// The name or full path of the executable file to run. - /// The command-line arguments to pass to the executable. - /// The working directory for the process, or null to use the current directory. - /// The maximum amount of time to allow the process to run before it is terminated, or null for no timeout. - /// A token to monitor for cancellation requests. - /// The exit code of the process upon its completion. - /// Thrown if the process fails to start or encounters an unexpected error during execution. - /// Thrown if the process is aborted due to exceeding the specified timeout or cancellation token. - public async Task RunAsync( - string fileName, - IReadOnlyList arguments, - string? workingDirectory = null, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default - ) { - ProcessRunResult result = await RunWithOutputAsync(fileName, arguments, workingDirectory, timeout, cancellationToken); - return result.ExitCode; - } - - /// - /// Runs an external process asynchronously, captures stdout/stderr, streams output to the current console, and returns the exit code along with the captured output. - /// - /// The name or path of the executable to run. - /// The arguments to pass to the executable as discrete tokens. - /// The optional working directory for the process. Defaults to null. - /// The optional timeout duration for the process execution. Defaults to null, resulting in a predefined timeout being used. - /// Token to monitor for cancellation requests. - /// A struct containing the process exit code, captured standard output, and captured standard error. - /// Thrown when the process fails to start. - /// Thrown when the specified timeout duration is zero or negative. - /// Thrown when the operation is canceled or the timeout elapses before the process completes. - public async Task RunWithOutputAsync( - string fileName, - IReadOnlyList arguments, - string? workingDirectory = null, - TimeSpan? timeout = null, - CancellationToken cancellationToken = default - ) { - TimeSpan effectiveTimeout = timeout ?? DefaultProcessTimeout; - if (effectiveTimeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero."); - - var startInfo = new ProcessStartInfo(fileName) { - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - StandardOutputEncoding = Encoding.UTF8, - StandardErrorEncoding = Encoding.UTF8 - }; - - if (!string.IsNullOrWhiteSpace(workingDirectory)) startInfo.WorkingDirectory = workingDirectory; - - foreach (string arg in arguments) { - startInfo.ArgumentList.Add(arg); - } - - var standardOutput = new StringBuilder(); - var standardError = new StringBuilder(); - var standardOutputLock = new Lock(); - var standardErrorLock = new Lock(); - - using var process = new Process(); - process.StartInfo = startInfo; - process.EnableRaisingEvents = true; - - process.OutputDataReceived += (_, e) => { - if (string.IsNullOrWhiteSpace(e.Data)) return; - - lock (standardOutputLock) { - standardOutput.AppendLine(e.Data); - } - - _logger.LogInformation("{ProcessOutput}", e.Data); - }; - - process.ErrorDataReceived += (_, e) => { - if (string.IsNullOrWhiteSpace(e.Data)) return; - - lock (standardErrorLock) { - standardError.AppendLine(e.Data); - } - - _logger.LogError("{ProcessError}", e.Data); - }; - - if (!process.Start()) throw new InvalidOperationException($"Failed to start process: {fileName}"); - - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - using var timeoutCts = new CancellationTokenSource(effectiveTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - try { - await process.WaitForExitAsync(linkedCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) { - try { - if (!process.HasExited) process.Kill(entireProcessTree: true); - } - catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { - // best effort - process may have already exited or access may be denied - } - - process.WaitForExit(5000); - - throw new TimeoutException($"Timed out after {effectiveTimeout} while running '{fileName}'."); - } - catch (OperationCanceledException) { - try { - if (!process.HasExited) process.Kill(entireProcessTree: true); - } - catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { - // best effort - process may have already exited or access may be denied - } - - process.WaitForExit(5000); - - throw; - } - - string capturedStandardOutput; - string capturedStandardError; - lock (standardOutputLock) { - capturedStandardOutput = standardOutput.ToString(); - } - - lock (standardErrorLock) { - capturedStandardError = standardError.ToString(); - } - - return new ProcessRunResult(process.ExitCode, capturedStandardOutput, capturedStandardError); - } - - /// - /// Represents the result of a process execution. - /// - /// - /// This type provides information about the outcome of a process that was executed using the ProcessRunner utility, - /// including the exit code, captured standard output, and captured standard error. - /// - internal readonly record struct ProcessRunResult(int ExitCode, string StandardOutput, string StandardError); -} diff --git a/src/InfiniFrame.Tools.Pack/Services/PublishOutputCleaner.cs b/src/InfiniFrame.Tools.Pack/Services/PublishOutputCleaner.cs deleted file mode 100644 index 8fecbe83f..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/PublishOutputCleaner.cs +++ /dev/null @@ -1,94 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using System.Text; - -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class PublishOutputCleaner { - private const int MaxDeleteAttempts = 3; - - /// - /// The native runtime file names that are stripped from the final publication output after embedding. - /// - public static readonly string[] NativeRuntimeFiles = InfiniFramePackNativeArtifactManifest.AllFileNames; - - // ----------------------------------------------------------------------------------------------------------------- - // Methods - // ----------------------------------------------------------------------------------------------------------------- - /// - /// Removes unpacked runtime artifacts that should not remain beside the single-file executable. - /// - /// Publish output directory. - /// Non-fatal cleanup warnings. - public static string[] Cleanup(string output) { - List warnings = []; - - string wwwroot = Path.Join(output, "wwwroot"); - if (Directory.Exists(wwwroot)) { - string? warning = TryDeleteDirectoryWithRetries(wwwroot); - if (!string.IsNullOrWhiteSpace(warning)) warnings.Add(warning); - } - - IEnumerable enumerable = NativeRuntimeFiles - .Select(file => Path.IsPathRooted(file) ? file : Path.Join(output, file)) - .Where(File.Exists) - .Select(TryDeleteFileWithRetries) - .Where(warning => !string.IsNullOrWhiteSpace(warning)); - - warnings.AddRange(enumerable!); - - return warnings.ToArray(); - } - - private static string? TryDeleteDirectoryWithRetries(string directoryPath) { - for (int attempt = 1; attempt <= MaxDeleteAttempts; attempt++) { - try { - if (Directory.Exists(directoryPath)) Directory.Delete(directoryPath, true); - return null; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - if (attempt == MaxDeleteAttempts) { - return BuildFailureMessage("directory", directoryPath, attempt, ex); - } - - Thread.Sleep(50 * attempt); - } - } - - return null; - } - - private static string? TryDeleteFileWithRetries(string filePath) { - for (int attempt = 1; attempt <= MaxDeleteAttempts; attempt++) { - try { - if (File.Exists(filePath)) File.Delete(filePath); - return null; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - if (attempt == MaxDeleteAttempts) { - return BuildFailureMessage("file", filePath, attempt, ex); - } - - Thread.Sleep(50 * attempt); - } - } - - return null; - } - - private static string BuildFailureMessage(string targetType, string path, int attempts, Exception ex) { - var builder = new StringBuilder(); - builder.Append("Cleanup skipped "); - builder.Append(targetType); - builder.Append(" '"); - builder.Append(path); - builder.Append("' after "); - builder.Append(attempts); - builder.Append(" attempts: "); - builder.Append(ex.Message); - return builder.ToString(); - } -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Services/PublishService.cs b/src/InfiniFrame.Tools.Pack/Services/PublishService.cs deleted file mode 100644 index ffad5b88f..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/PublishService.cs +++ /dev/null @@ -1,362 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Exceptions; -using InfiniFrame.Tools.Pack.Resolvers; -using Microsoft.Extensions.Logging; -using System.Diagnostics; - -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal sealed class PublishService { - private const string DotNet = "dotnet"; - private static readonly StringComparison PathComparison = - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - - private readonly ILogger _logger; - private readonly ProcessRunner _processRunner; - - public PublishService(ILogger logger, ProcessRunner processRunner) { - _logger = logger; - _processRunner = processRunner; - } - - // ----------------------------------------------------------------------------------------------------------------- - // Methods - // ----------------------------------------------------------------------------------------------------------------- - - /// - /// Executes the full InfiniFrame publish pipeline for a project. - /// - /// The publish options parsed from the command line. - /// A token to observe for cooperative cancellation of the publish operation. - /// The process exit code of the publish operation. - /// Thrown when the target project file does not exist. - /// - /// Thrown when native build fails or required artifacts are missing. - /// - public async Task PublishAsync(PublishOptions options, CancellationToken cancellationToken = default) { - var totalPublishStopwatch = Stopwatch.StartNew(); - ValidateProcessTimeout(options.ProcessTimeout); - string projectPath = Path.GetFullPath(options.ProjectPath); - if (!File.Exists(projectPath)) throw new FileNotFoundException("Project file not found", projectPath); - - string projectDirectory = Path.GetDirectoryName(projectPath) ?? throw new InvalidOperationException("Unable to resolve project directory."); - string framework = string.IsNullOrWhiteSpace(options.Framework) - ? await ProjectInfoResolver.ResolveFrameworkAsync(projectPath, options.ProcessTimeout, cancellationToken) - : options.Framework!; - string rid = RuntimeResolver.ResolveRid(options.Rid); - string output = ResolveOutputPath(options, projectDirectory, framework, rid); - string assemblyName = await ProjectInfoResolver.ResolveAssemblyNameAsync(projectPath, options.ProcessTimeout, cancellationToken); - - ResolvedNativeArtifacts nativeArtifacts = await ResolveNativeArtifactsAsync(options, projectPath, framework, rid, cancellationToken); - - PublishValidator.PreflightValidate( - projectDirectory, - output, - rid, - nativeArtifacts.Directory, - options.ForceCleanOutput - ); - - PrintPublishSummary(projectPath, framework, rid, options.SelfContained, output, nativeArtifacts.Directory); - - // Safe recursive deletion (now guaranteed safe) - if (Directory.Exists(output)) SafeDeleteDirectory(output); - Directory.CreateDirectory(output); - - try { - using var tempTargets = TempTargetsFile.Create(); - - List publishArgs = BuildPublishArguments( - options, - projectPath, - framework, - rid, - output, - nativeArtifacts.Directory, - tempTargets.Path, - true - ); - - var publishStopwatch = Stopwatch.StartNew(); - int exitCode = await _processRunner.RunAsync(DotNet, publishArgs, timeout: options.ProcessTimeout, cancellationToken: cancellationToken); - publishStopwatch.Stop(); - _logger.LogInformation("Final publish finished in {ElapsedSeconds}s.", Math.Round(publishStopwatch.Elapsed.TotalSeconds, 2)); - if (exitCode != 0) return exitCode; - - string[] cleanupWarnings = PublishOutputCleaner.Cleanup(output); - foreach (string warning in cleanupWarnings) { - _logger.LogWarning("{CleanupWarning}", warning); - } - - string expectedMainOutput = ResolveExpectedMainOutputPath(output, assemblyName, rid); - OutputShapeValidation validation = ValidateOutputShape(output, expectedMainOutput); - PrintOutputSummary(output, expectedMainOutput, validation.UnexpectedEntries); - - if (!validation.FoundMainOutput) return ExitCodes.MissingMainOutput; - - return validation.UnexpectedEntries.Length == 0 ? ExitCodes.Success : ExitCodes.UnexpectedOutputShape; - } - finally { - totalPublishStopwatch.Stop(); - _logger.LogInformation("Pack pipeline completed in {ElapsedSeconds}s.", Math.Round(totalPublishStopwatch.Elapsed.TotalSeconds, 2)); - - if (nativeArtifacts.DeleteWhenDone && Directory.Exists(nativeArtifacts.Directory)) { - Directory.Delete(nativeArtifacts.Directory, true); - } - } - } - - private string ResolveOutputPath(PublishOptions options, string projectDirectory, string framework, string rid) => - string.IsNullOrWhiteSpace(options.Output) - ? Path.Join(projectDirectory, "bin", options.Configuration, framework, rid, "publish") - : Path.GetFullPath(options.Output!); - - private string ResolveExpectedMainOutputPath(string output, string assemblyName, string rid) { - string extension = rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase) ? ".exe" : ""; - return Path.Join(output, $"{assemblyName}{extension}"); - } - - private void PrintPublishSummary(string projectPath, string framework, string rid, bool selfContained, string output, string nativeArtifacts) { - _logger.LogInformation("Publishing single-file app"); - _logger.LogInformation(" Project: {ProjectPath}", projectPath); - _logger.LogInformation(" Framework: {Framework}", framework); - _logger.LogInformation(" RID: {Rid}", rid); - _logger.LogInformation(" SelfContained: {SelfContained}", selfContained); - _logger.LogInformation(" Output: {Output}", output); - _logger.LogInformation(" NativeArtifacts: {NativeArtifacts}", nativeArtifacts); - } - - internal static OutputShapeValidation ValidateOutputShape(string output, string expectedMainOutput) { - string normalizedExpectedMainOutput = Path.GetFullPath(expectedMainOutput); - string[] outputFiles = Directory.GetFiles(output, "*", SearchOption.TopDirectoryOnly); - bool foundMainOutput = outputFiles.Any(file => string.Equals(Path.GetFullPath(file), normalizedExpectedMainOutput, PathComparison)); - string[] unexpectedFiles = outputFiles - .Where(file => !string.Equals(Path.GetFullPath(file), normalizedExpectedMainOutput, PathComparison)) - .Select(file => Path.GetFileName(file)) - .Where(fileName => !string.IsNullOrWhiteSpace(fileName)) - .ToArray(); - string[] unexpectedDirectories = Directory.GetDirectories(output, "*", SearchOption.TopDirectoryOnly) - .Select(directory => Path.GetFileName(directory)) - .Where(directoryName => !string.IsNullOrWhiteSpace(directoryName)) - .ToArray(); - string[] unexpectedEntries = unexpectedFiles - .Concat(unexpectedDirectories) - .OrderBy(keySelector: entry => entry, StringComparer.OrdinalIgnoreCase) - .ToArray(); - - return new OutputShapeValidation(foundMainOutput, unexpectedEntries); - } - - private void PrintOutputSummary(string output, string expectedMainOutput, string[] unexpectedEntries) { - if (!File.Exists(expectedMainOutput)) { - _logger.LogWarning("Publish succeeded, but expected single-file output was not found."); - } - else if (unexpectedEntries.Length != 0) { - _logger.LogWarning("Publish output contains unexpected entries."); - } - - string[] files = Directory.GetFiles(output, "*", SearchOption.TopDirectoryOnly); - _logger.LogInformation("Completed"); - _logger.LogInformation(" Files in output: {FileCount}", files.Length); - foreach (string file in files.Select(Path.GetFileName).Where(x => !string.IsNullOrWhiteSpace(x)).OrderBy(x => x)!) { - _logger.LogInformation(" - {File}", file); - } - - if (unexpectedEntries.Length == 0) return; - - _logger.LogWarning(" Unexpected entries:"); - foreach (string unexpectedEntry in unexpectedEntries) { - _logger.LogWarning(" - {UnexpectedEntry}", unexpectedEntry); - } - } - - private async Task ResolveNativeArtifactsAsync( - PublishOptions options, - string projectPath, - string framework, - string rid, - CancellationToken cancellationToken - ) { - string preflightDirectory = Path.Join(Path.GetTempPath(), $"infiniframe-pack-native-{Guid.NewGuid():N}"); - Directory.CreateDirectory(preflightDirectory); - - bool preflightValidated = false; - try { - List preflightArgs = BuildPublishArguments(options, projectPath, framework, rid, preflightDirectory, noRestore: options.NoRestore, isPreflight: true); - var preflightStopwatch = Stopwatch.StartNew(); - ProcessRunner.ProcessRunResult preflightResult = await _processRunner.RunWithOutputAsync( - DotNet, - preflightArgs, - timeout: options.ProcessTimeout, - cancellationToken: cancellationToken); - preflightStopwatch.Stop(); - _logger.LogInformation("Preflight publish finished in {ElapsedSeconds}s.", Math.Round(preflightStopwatch.Elapsed.TotalSeconds, 2)); - int preflightExitCode = preflightResult.ExitCode; - - if (preflightExitCode != 0) { - throw new InvalidOperationException( - $"Preflight publish failed with exit code {preflightExitCode}. Command: {DotNet} {string.Join(' ', preflightArgs)}" + - $"{FormatPreflightOutputForException(preflightResult)}"); - } - - try { - PublishValidator.ValidateNativeArtifacts(preflightDirectory, rid); - preflightValidated = true; - return new ResolvedNativeArtifacts(preflightDirectory, true); - } - catch (InvalidOperationException preflightValidationError) { - string? nativeArtifactsDirectory = TryResolveNativeArtifactsFromPublishLayout(preflightDirectory, rid, options.Configuration); - if (!string.IsNullOrWhiteSpace(nativeArtifactsDirectory)) { - PublishValidator.ValidateNativeArtifacts(nativeArtifactsDirectory, rid); - preflightValidated = true; - return new ResolvedNativeArtifacts(nativeArtifactsDirectory, true); - } - - throw new NativeDependencyNotFoundException( - "Could not resolve required InfiniFrame native artifacts from project publish output. " + - "Ensure InfiniFrame is included as a dependency for this project/RID and that native runtime files are produced, " + - "and that publish preserves native runtime files. " + - $"Details: {preflightValidationError.Message}" - ); - } - } - finally { - if (!preflightValidated && Directory.Exists(preflightDirectory)) Directory.Delete(preflightDirectory, true); - } - } - - private string? TryResolveNativeArtifactsFromPublishLayout(string publishDirectory, string rid, string configuration) { - string[] ridParts = rid.Split('-', StringSplitOptions.RemoveEmptyEntries); - if (ridParts.Length != 2) return null; - - string platform = ridParts[0].ToLowerInvariant() switch { - "win" => "windows", - "linux" => "linux", - "osx" => "osx", - _ => string.Empty - }; - string architecture = ridParts[1].ToLowerInvariant() switch { - "x64" => "x64", - "arm64" => "arm64", - _ => string.Empty - }; - if (string.IsNullOrWhiteSpace(platform) || string.IsNullOrWhiteSpace(architecture)) return null; - - string candidateDirectory = Path.Join(publishDirectory, "artifacts", "native", platform, architecture, configuration); - return Directory.Exists(candidateDirectory) ? candidateDirectory : null; - } - - private string FormatPreflightOutputForException(ProcessRunner.ProcessRunResult preflightResult) { - string standardOutput = TruncateForException(preflightResult.StandardOutput); - string standardError = TruncateForException(preflightResult.StandardError); - return $"{Environment.NewLine}--- preflight stdout ---{Environment.NewLine}{standardOutput}" + - $"{Environment.NewLine}--- preflight stderr ---{Environment.NewLine}{standardError}"; - } - - private string TruncateForException(string value, int maxLength = 4000) { - if (string.IsNullOrWhiteSpace(value)) return ""; - - string trimmed = value.Trim(); - return trimmed.Length <= maxLength - ? trimmed - : $"{trimmed[..maxLength]}{Environment.NewLine}"; - } - - // NOTE: - // This method assumes that PublishPreflightValidator has already validated the path. - // Do NOT call this method without running preflight validation first. - private void SafeDeleteDirectory(string path) { - string fullPath = Path.GetFullPath(path); - - if (string.IsNullOrWhiteSpace(fullPath)) throw new InvalidOperationException("Cannot delete an empty path."); - - string? root = Path.GetPathRoot(fullPath); - if (string.Equals(fullPath, root, StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException($"Refusing to delete root directory '{fullPath}'."); - } - - _logger.LogInformation("Cleaning previous output folder: {OutputDirectory}", fullPath); - - try { - Directory.Delete(fullPath, true); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - throw new InvalidOperationException( - $"Failed to delete output folder '{fullPath}': {ex.Message}", - ex - ); - } - } - - private List BuildPublishArguments( - PublishOptions options, - string projectPath, - string framework, - string rid, - string output, - string? nativeArtifactsDir = null, - string? customTargetsPath = null, - bool noRestore = false, - bool isPreflight = false - ) { - bool selfContained = !isPreflight && options.SelfContained; - List args = [ - "publish", - projectPath, - // Pack runs nested dotnet builds and owns their complete lifetime. Persistent - // MSBuild/Roslyn servers can outlive a canceled publish (and have done so on - // Windows ARM64 CI), retaining locks and stalling later tests. Keep this build - // isolated and single-node so ProcessRunner can terminate it deterministically. - "--disable-build-servers", - "-maxcpucount:1", - "-nodeReuse:false", - "-p:UseSharedCompilation=false", - "-c", options.Configuration, - "-r", rid, - "-f", framework, - "--output", output, - "-p:InfiniFramePackInvoked=true", - $"-p:SelfContained={selfContained.ToString().ToLowerInvariant()}", - "-p:IncludeNativeLibrariesForSelfExtract=true", - options.Verbose ? "-v:normal" : "-v:minimal" - ]; - - if (isPreflight) { - args.Add("-p:PublishSingleFile=false"); - } - else { - args.AddRange([ - "-p:PublishSingleFile=true", - "-p:IncludeAllContentForSelfExtract=true", - "-p:EnableCompressionInSingleFile=true", - "-p:DebugType=none", - "-p:DebugSymbols=false", - $"-p:InfiniFramePackRootProject={projectPath}", - $"-p:InfiniFramePackRuntimeIdentifier={rid}", - $"-p:InfiniFramePackNativeArtifactsDir={nativeArtifactsDir}", - $"-p:CustomAfterMicrosoftCommonTargets={customTargetsPath}" - ]); - } - - if (noRestore) args.Add("--no-restore"); - - return args; - } - - private static void ValidateProcessTimeout(TimeSpan timeout) { - if (timeout <= TimeSpan.Zero) { - throw new InvalidOperationException($"Process timeout must be greater than zero. Received '{timeout}'."); - } - - if (timeout > PublishOptions.MaxProcessTimeout) { - throw new InvalidOperationException( - $"Process timeout '{timeout}' exceeds the maximum supported value of '{PublishOptions.MaxProcessTimeout}'."); - } - } - - internal readonly record struct OutputShapeValidation(bool FoundMainOutput, string[] UnexpectedEntries); -} diff --git a/src/InfiniFrame.Tools.Pack/Services/PublishValidator.cs b/src/InfiniFrame.Tools.Pack/Services/PublishValidator.cs deleted file mode 100644 index bac34011c..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/PublishValidator.cs +++ /dev/null @@ -1,164 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using System.Buffers.Binary; - -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal static class PublishValidator { - private const ushort ImageFileMachineAmd64 = 0x8664; - private const ushort ImageFileMachineArm64 = 0xAA64; - - private static readonly StringComparison PathComparison = - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - - // ----------------------------------------------------------------------------------------------------------------- - // Methods - // ----------------------------------------------------------------------------------------------------------------- - - /// - /// Runs all preflight validation checks before publish. - /// - /// - /// Thrown when any validation step fails. - /// - public static void PreflightValidate( - string projectDirectory, - string outputPath, - string rid, - string nativeArtifactsDir, - bool forceCleanOutput - ) { - ValidateRidConsistency(rid); - ValidateOutputPath(projectDirectory, outputPath, forceCleanOutput); - ValidateNativeArtifacts(nativeArtifactsDir, rid); - } - - internal static bool ValidateOutputPath( - string projectDirectory, - string outputPath, - bool forceCleanOutput - ) { - string fullPath = Path.GetFullPath(outputPath); - if (string.IsNullOrWhiteSpace(fullPath)) throw new InvalidOperationException("Cannot delete an empty path."); - - string? root = Path.GetPathRoot(fullPath); - if (string.Equals(fullPath, root, PathComparison)) { - throw new InvalidOperationException($"Refusing to delete root directory '{fullPath}'."); - } - - string projectBinDirectory = Path.GetFullPath(Path.Join(projectDirectory, "bin")); - if (IsUnderDirectory(fullPath, projectBinDirectory)) return true; - - // Only gate non-default output paths when we would actually delete an existing directory. - if (!Directory.Exists(fullPath)) return true; - - if (!forceCleanOutput) { - throw new InvalidOperationException( - $"Refusing to delete non-default output directory '{fullPath}'. " + - "Pass --force-clean-output to allow this." - ); - } - - return true; - } - - private static bool IsUnderDirectory(string candidatePath, string parentPath) { - string normalizedCandidate = EnsureTrailingSeparator(Path.GetFullPath(candidatePath)); - string normalizedParent = EnsureTrailingSeparator(Path.GetFullPath(parentPath)); - return normalizedCandidate.StartsWith(normalizedParent, PathComparison); - } - - private static string EnsureTrailingSeparator(string path) => - path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar) - ? path - : path + Path.DirectorySeparatorChar; - - public static void ValidateNativeArtifacts( - string nativeArtifactsDir, - string rid - ) { - if (!Directory.Exists(nativeArtifactsDir)) throw new InvalidOperationException($"Native artifacts directory was not found: {nativeArtifactsDir}"); - - string[] requiredPaths = InfiniFramePackNativeArtifactManifest.RequiredFileNamesForRid(rid) - .Select(file => Path.IsPathRooted(file) ? file : Path.Join(nativeArtifactsDir, file)) - .ToArray(); - - string? missingPath = requiredPaths.FirstOrDefault(path => !File.Exists(path)); - if (missingPath is not null) { - throw new InvalidOperationException($"Required native artifact was not found: {missingPath}"); - } - - if (!rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase)) return; - - ushort expectedMachine = ExpectedPeMachineForRid(rid); - foreach (string path in requiredPaths) { - ushort actualMachine = ReadPeMachine(path); - if (actualMachine == expectedMachine) continue; - - throw new InvalidOperationException( - $"Native artifact architecture mismatch for '{path}'. " + - $"Expected {DescribePeMachine(expectedMachine)} for RID '{rid}', found {DescribePeMachine(actualMachine)}." - ); - } - } - - private static ushort ExpectedPeMachineForRid(string rid) { - if (rid.EndsWith("-x64", StringComparison.OrdinalIgnoreCase)) return ImageFileMachineAmd64; - if (rid.EndsWith("-arm64", StringComparison.OrdinalIgnoreCase)) return ImageFileMachineArm64; - - throw new InvalidOperationException($"Unsupported Windows RID for native artifact architecture validation: {rid}"); - } - - private static ushort ReadPeMachine(string path) { - using FileStream stream = File.OpenRead(path); - long length = stream.Length; - if (length < 0x40) throw new InvalidOperationException($"Native artifact is not a valid PE binary: {path}"); - - Span dosHeader = stackalloc byte[64]; - stream.ReadExactly(dosHeader); - - if (dosHeader[0] != (byte)'M' || dosHeader[1] != (byte)'Z') { - throw new InvalidOperationException($"Native artifact is not a valid PE binary: {path}"); - } - - int peHeaderOffset = BinaryPrimitives.ReadInt32LittleEndian(dosHeader[0x3C..0x40]); - if (peHeaderOffset < 0 || peHeaderOffset > length - 6) { - throw new InvalidOperationException($"Native artifact is not a valid PE binary: {path}"); - } - - stream.Position = peHeaderOffset; - Span pePrefixAndMachine = stackalloc byte[6]; - stream.ReadExactly(pePrefixAndMachine); - - if (pePrefixAndMachine[0] != (byte)'P' || pePrefixAndMachine[1] != (byte)'E' || pePrefixAndMachine[2] != 0 || pePrefixAndMachine[3] != 0) { - throw new InvalidOperationException($"Native artifact is not a valid PE binary: {path}"); - } - - return BinaryPrimitives.ReadUInt16LittleEndian(pePrefixAndMachine[4..6]); - } - - private static string DescribePeMachine(ushort machine) => machine switch { - ImageFileMachineAmd64 => $"x64 (0x{machine:X4})", - ImageFileMachineArm64 => $"arm64 (0x{machine:X4})", - _ => $"0x{machine:X4}" - }; - - internal static bool ValidateRidConsistency(string rid) { - if (string.IsNullOrWhiteSpace(rid)) throw new InvalidOperationException("Runtime identifier (RID) cannot be empty."); - - // Basic sanity check - if (!rid.Contains('-')) throw new InvalidOperationException($"Invalid RID format: '{rid}'. Expected format like 'win-x64', 'linux-arm64'."); - - // OS expectations - bool isWindowsRid = rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase); - bool isLinuxRid = rid.StartsWith("linux-", StringComparison.OrdinalIgnoreCase); - bool isOsxRid = rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase); - - if (!isWindowsRid && !isLinuxRid && !isOsxRid) throw new InvalidOperationException($"Unsupported or unknown RID: '{rid}'."); - - return true; - } -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Services/ResolvedNativeArtifacts.cs b/src/InfiniFrame.Tools.Pack/Services/ResolvedNativeArtifacts.cs deleted file mode 100644 index 034f2c638..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/ResolvedNativeArtifacts.cs +++ /dev/null @@ -1,11 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal sealed record ResolvedNativeArtifacts( - string Directory, - bool DeleteWhenDone -); \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/Services/TempTargetsFile.cs b/src/InfiniFrame.Tools.Pack/Services/TempTargetsFile.cs deleted file mode 100644 index 509c00f79..000000000 --- a/src/InfiniFrame.Tools.Pack/Services/TempTargetsFile.cs +++ /dev/null @@ -1,108 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -/// -/// Represents a temporary MSBuild targets file used to customize publish behavior for InfiniFrame packaging. -/// -internal sealed class TempTargetsFile : IDisposable { - /// - /// Gets the full path to the generated targets file. - /// - public string Path { get; private init; } = null!; - - /// - /// Deletes the temporary targets file if it still exists. - /// - public void Dispose() { - try { - if (File.Exists(Path)) File.Delete(Path); - } - catch (IOException) { - // no-op - } - catch (UnauthorizedAccessException) { - // no-op - } - catch (NotSupportedException) { - // no-op - } - catch (ArgumentException) { - // no-op - } - } - - // ----------------------------------------------------------------------------------------------------------------- - // Methods - // ----------------------------------------------------------------------------------------------------------------- - - /// - /// Creates and writes a temporary targets file that embeds web assets and native runtime artifacts. - /// - /// A disposable handle for the created targets file. - public static TempTargetsFile Create() { - string path = System.IO.Path.Join(System.IO.Path.GetTempPath(), $"infiniframe-pack-{Guid.NewGuid():N}.targets"); - File.WriteAllText(path, BuildContents()); - - return new TempTargetsFile { - Path = path - }; - } - - private static string BuildContents() => - // lang=msbuild - $""" - - - <_InfiniFramePackWwwroot Include="wwwroot/**/*" /> - <_InfiniFramePackWwwroot Remove="@(EmbeddedResource)" /> - - - - - - - {BuildNativeEmbeddedResourceItems()} - - - - - - - - - - - {BuildDeleteItems()} - - - """; - - private static string BuildNativeEmbeddedResourceItems() => string.Join(Environment.NewLine, - InfiniFramePackNativeArtifactManifest.RidArtifacts.Select(artifact => { - string escapedFileName = System.Security.SecurityElement.Escape(artifact.FileName); - string escapedRidPrefix = System.Security.SecurityElement.Escape(artifact.RidPrefix); - return $""" - - """.TrimEnd(); - })); - - private static string BuildResolvedFileRemovalCondition() => string.Join( - $"{Environment.NewLine} or ", - InfiniFramePackNativeArtifactManifest.AllFileNames.Select(fileName => - $"'%(ResolvedFileToPublish.Filename)%(ResolvedFileToPublish.Extension)'=='{System.Security.SecurityElement.Escape(fileName)}'") - ); - - private static string BuildDeleteItems() => string.Join(Environment.NewLine, - InfiniFramePackNativeArtifactManifest.AllFileNames.Select(fileName => - $" ")); -} \ No newline at end of file diff --git a/src/InfiniFrame.Tools.Pack/install-or-update-pack-tool.sh b/src/InfiniFrame.Tools.Pack/install-or-update-pack-tool.sh deleted file mode 100644 index 6a2cea645..000000000 --- a/src/InfiniFrame.Tools.Pack/install-or-update-pack-tool.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "${SCRIPT_DIR}/../.." && pwd)" - -PROJECT_PATH="${SCRIPT_DIR}/InfiniFrame.Tools.Pack.csproj" -PACKAGE_ID="InfiniLore.InfiniFrame.Tools.Pack" -TOOL_COMMAND="infiniframe-pack" -PACKAGE_OUTPUT_DIR="${REPO_ROOT}/artifacts/dotnet-tools" - -log() { - echo "[InfiniFrame.Tools.Pack] $*" -} - -# Ensure dotnet exists early (fail fast with a useful message) -if ! command -v dotnet >/dev/null 2>&1; then - log "ERROR: dotnet CLI not found in PATH." - log "Make sure .NET SDK is installed and PATH is configured." - exit 1 -fi - -log "Packing tool package..." -dotnet pack "${PROJECT_PATH}" -c Release -o "${PACKAGE_OUTPUT_DIR}" - -# Find latest package safely -shopt -s nullglob -packages=("${PACKAGE_OUTPUT_DIR}/${PACKAGE_ID}".*.nupkg) -shopt -u nullglob - -# Filter out symbol packages -filtered=() -for pkg in "${packages[@]}"; do - [[ "$pkg" == *.symbols.nupkg ]] && continue - filtered+=("$pkg") -done - -if (( ${#filtered[@]} == 0 )); then - log "ERROR: No package was produced in ${PACKAGE_OUTPUT_DIR}." - exit 1 -fi - -# Sort by modification time (newest first) -IFS=$'\n' sorted=($(ls -t "${filtered[@]}")) -unset IFS - -LATEST_PACKAGE="${sorted[0]}" - -PACKAGE_VERSION="${LATEST_PACKAGE##*/}" -PACKAGE_VERSION="${PACKAGE_VERSION#${PACKAGE_ID}.}" -PACKAGE_VERSION="${PACKAGE_VERSION%.nupkg}" - -log "Resolved version: ${PACKAGE_VERSION}" - -log "Installing/updating global dotnet tool..." - -if dotnet tool update \ - --global "${PACKAGE_ID}" \ - --version "${PACKAGE_VERSION}" \ - --add-source "${PACKAGE_OUTPUT_DIR}" \ - --ignore-failed-sources; then - log "Updated ${PACKAGE_ID} (${PACKAGE_VERSION})." -else - dotnet tool install \ - --global "${PACKAGE_ID}" \ - --version "${PACKAGE_VERSION}" \ - --add-source "${PACKAGE_OUTPUT_DIR}" \ - --ignore-failed-sources - log "Installed ${PACKAGE_ID} (${PACKAGE_VERSION})." -fi - -log "Done. Command available: ${TOOL_COMMAND}" \ No newline at end of file diff --git a/src/InfiniFrame/InfiniFrame.csproj b/src/InfiniFrame/InfiniFrame.csproj index 7272b1631..8aef70536 100644 --- a/src/InfiniFrame/InfiniFrame.csproj +++ b/src/InfiniFrame/InfiniFrame.csproj @@ -14,6 +14,10 @@ + + + + diff --git a/src/InfiniFrame/ServiceCollectionExtensions.cs b/src/InfiniFrame/ServiceCollectionExtensions.cs index ee49f5c14..b8e618ca2 100644 --- a/src/InfiniFrame/ServiceCollectionExtensions.cs +++ b/src/InfiniFrame/ServiceCollectionExtensions.cs @@ -19,9 +19,9 @@ public static class ServiceCollectionExtensions { /// The to add services to. /// The same service collection so calls can be chained. public static IServiceCollection AddInfiniFrame(this IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); services.AddSingleton, InfiniFrameNativeParametersValidator>(); diff --git a/src/InfiniFrame/SingleFile/InfiniFrameSingleFile.cs b/src/InfiniFrame/SingleFile/InfiniFrameSingleFile.cs new file mode 100644 index 000000000..923836814 --- /dev/null +++ b/src/InfiniFrame/SingleFile/InfiniFrameSingleFile.cs @@ -0,0 +1,41 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Reflection; + +namespace InfiniFrame.SingleFile; + +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public static class InfiniFrameSingleFile { + public static void Initialize() { + if (IsPackDeployment()) { + InfiniFrameSingleFileBootstrap.Initialize(); + } + } + + public static void AttachWithStaticWwwroot(IInfiniFrameWindowBuilder builder) { + if (IsPackDeployment()) { + builder.UseEmbeddedWwwrootAssets( + scheme: "app", + includePhysicalFallback: true, + physicalWwwrootPath: Path.Join(AppContext.BaseDirectory, "wwwroot"), + setStartUrl: true + ); + } + } + + public static void AttachWithBlazor(IInfiniFrameWindowBuilder builder) { + // In pack mode, the file provider is handled by PackModeFileProvider + // (detected in InfiniFrameBlazorAppBuilder.ConfigureFileProvider). + // We must NOT register a scheme handler here; it would overwrite the + // Blazor WebViewManager handler already registered by Build(). + } + + private static bool IsPackDeployment() { + Assembly entryAssembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly(); + string[] resourceNames = entryAssembly.GetManifestResourceNames(); + return resourceNames.Any(r => r.StartsWith("publish.", StringComparison.Ordinal) || r.Contains(".native.") || r.Contains(".wwwroot.")); + } +} diff --git a/src/InfiniFrame/InfiniFrameSingleFileBootstrap.cs b/src/InfiniFrame/SingleFile/InfiniFrameSingleFileBootstrap.cs similarity index 91% rename from src/InfiniFrame/InfiniFrameSingleFileBootstrap.cs rename to src/InfiniFrame/SingleFile/InfiniFrameSingleFileBootstrap.cs index 73f7a3eff..99187f62b 100644 --- a/src/InfiniFrame/InfiniFrameSingleFileBootstrap.cs +++ b/src/InfiniFrame/SingleFile/InfiniFrameSingleFileBootstrap.cs @@ -18,7 +18,7 @@ namespace InfiniFrame; /// Call once at application startup (before creating a window) when using packaged /// single-file/native outputs that embed InfiniFrame.Native and platform loader dependencies. /// -public static class InfiniFrameSingleFileBootstrap { +internal static class InfiniFrameSingleFileBootstrap { private const string WebView2LoaderLibraryName = ArtifactManifest.WindowsLoaderLibraryName; #if NET9_0_OR_GREATER @@ -51,8 +51,16 @@ public static void Initialize() { bool initialized = false; try { + string[] requiredFiles = GetNativeFileNamesForCurrentPlatform(); + bool hasResources = requiredFiles.Any(fileName => { + string resourceName = $"{entryAssembly.GetName().Name}.native.{rid}.{fileName}"; + return entryAssembly.GetManifestResourceStream(resourceName) is not null; + }); + + if (!hasResources) return; + Directory.CreateDirectory(_nativeDir); - ExtractEmbeddedNative(entryAssembly, rid, GetNativeFileNamesForCurrentPlatform()); + ExtractEmbeddedNative(entryAssembly, rid, requiredFiles); NativeLibrary.SetDllImportResolver(typeof(InfiniFrameNative).Assembly, ResolveNativeLibrary); AppDomain.CurrentDomain.ProcessExit += (_, _) => TryCleanupNativeDirectory(); @@ -107,15 +115,10 @@ private static void TryPreloadDependency(string fileName) { } private static void ExtractEmbeddedNative(Assembly assembly, string rid, IReadOnlyCollection fileNames) { - var missingResources = new List(); - foreach (string fileName in fileNames) { string resourceName = $"{assembly.GetName().Name}.native.{rid}.{fileName}"; using Stream? resourceStream = assembly.GetManifestResourceStream(resourceName); - if (resourceStream is null) { - missingResources.Add(resourceName); - continue; - } + if (resourceStream is null) continue; string destinationPath = Path.Join(_nativeDir!, fileName); @@ -125,13 +128,6 @@ private static void ExtractEmbeddedNative(Assembly assembly, string rid, IReadOn using var destination = new FileStream(destinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); resourceStream.CopyTo(destination); } - - if (missingResources.Count > 0) { - throw new InvalidOperationException( - $"InfiniFrame bootstrap failed. Missing embedded native resources for RID '{rid}': " + - string.Join(", ", missingResources) - ); - } } private static string GetRuntimeIdentifier() { @@ -159,4 +155,4 @@ private static void TryCleanupNativeDirectory() { // Best-effort cleanup. } } -} \ No newline at end of file +} diff --git a/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowBuilderFeature.cs b/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowBuilderFeature.cs index bcdca2178..0b77ee9ba 100644 --- a/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowBuilderFeature.cs +++ b/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowBuilderFeature.cs @@ -77,7 +77,7 @@ public void ApplyToNativeParameters(ref InfiniFrameNativeParameters parameters) : null; parameters.WindowsAppUserModelId = WindowsAppUserModelId; - DecorationsInfiniFrameWindowFeature.ParseBackgroundColor( + ColorUtility.ParseBackgroundColor( BackgroundColor, out byte r, out byte g, out byte b, out byte a); parameters.BackgroundColorR = r; parameters.BackgroundColorG = g; diff --git a/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowFeature.cs b/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowFeature.cs index 4a1dd0046..38e060d30 100644 --- a/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowFeature.cs +++ b/src/InfiniFrame/Window/Features/Decorations/DecorationsInfiniFrameWindowFeature.cs @@ -88,11 +88,11 @@ public void SetTransparent(bool enabled) { /// public void SetBackgroundColor(string? color) { - if (color is not null && color != "transparent" && !IsValidBackgroundColor(color)) { + if (color is not null && color != "transparent" && !ColorUtility.IsValidBackgroundColor(color)) { throw new ArgumentException("Background color must be a valid hex color string (e.g. #RRGGBB or #AARRGGBB), null, or 'transparent'.", nameof(color)); } - ParseBackgroundColor(color, out byte r, out byte g, out byte b, out byte a); + ColorUtility.ParseBackgroundColor(color, out byte r, out byte g, out byte b, out byte a); logger.LogDebug("Invoking InfiniFrameNative.SetBackgroundColor({r}, {g}, {b}, {a})", r, g, b, a); NativeInvoke.InvokeSyncWithoutValidation( @@ -160,46 +160,4 @@ public void SetLimitLinuxWindowTitleLength(bool enabled = true) { LimitLinuxWindowTitleLength = enabled; } - internal static bool IsValidBackgroundColor(string? color) { - if (color is null or "transparent") - return true; - if (color.StartsWith('#')) { - string hex = color[1..]; - return hex.Length is 6 or 8 && hex.All(c => IsHexDigit(c)); - } - return false; - } - - internal static void ParseBackgroundColor(string? color, out byte r, out byte g, out byte b, out byte a) { - if (color is null or "transparent") { - r = g = b = a = 0; - return; - } - - string hex = color.StartsWith('#') ? color[1..] : color; - - if (hex.Length == 8) { - a = (byte)(HexDigit(hex[0]) << 4 | HexDigit(hex[1])); - r = (byte)(HexDigit(hex[2]) << 4 | HexDigit(hex[3])); - g = (byte)(HexDigit(hex[4]) << 4 | HexDigit(hex[5])); - b = (byte)(HexDigit(hex[6]) << 4 | HexDigit(hex[7])); - } else { - r = (byte)(HexDigit(hex[0]) << 4 | HexDigit(hex[1])); - g = (byte)(HexDigit(hex[2]) << 4 | HexDigit(hex[3])); - b = (byte)(HexDigit(hex[4]) << 4 | HexDigit(hex[5])); - a = 255; - } - } - - private static bool IsHexDigit(char c) => - c is >= '0' and <= '9' or >= 'A' and <= 'F' or >= 'a' and <= 'f'; - - private static int HexDigit(char c) => - c switch { - >= '0' and <= '9' => c - '0', - >= 'A' and <= 'F' => c - 'A' + 10, - >= 'a' and <= 'f' => c - 'a' + 10, - _ => -1 - }; - } diff --git a/src/InfiniFrame/Window/Features/Lifecycle/LifecycleInfiniFrameWindowFeature.cs b/src/InfiniFrame/Window/Features/Lifecycle/LifecycleInfiniFrameWindowFeature.cs index 33e00111c..e0c3d878e 100644 --- a/src/InfiniFrame/Window/Features/Lifecycle/LifecycleInfiniFrameWindowFeature.cs +++ b/src/InfiniFrame/Window/Features/Lifecycle/LifecycleInfiniFrameWindowFeature.cs @@ -70,17 +70,30 @@ public void Dispose() { private void Dispose(bool disposing) { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; - // MarkAsClosed is invoked from the native closed callback, before WaitForExit has - // returned. Deleting the native instance or unrooting reverse-P/Invoke delegates at - // that point would race the remainder of WindowProc/WebView2 teardown. If a native - // message loop is active, its finally block completes this deferred disposal. - // - // If the lifecycle reached Disposed (e.g., via Initialize failure calling MarkDisposed) - // without going through TeardownComplete, we must still release native callback roots - // and GCHandle milestones to avoid leaking. if (window.LifecycleState < InfiniFrameWindowLifecycleState.TeardownComplete - && window.LifecycleState != InfiniFrameWindowLifecycleState.Disposed) + && window.LifecycleState != InfiniFrameWindowLifecycleState.Disposed) { + // The normal teardown path hasn't completed yet. Still release callback roots + // and milestones to avoid leaks, and release the native handle so .NET 10's + // runtime doesn't abort during shutdown over unreleased SafeHandles. + ReleaseNativeCallbackRootOnce(); + ReleaseMilestoneRootOnce(); + try { window.ReleaseNativeHandle(); } + catch { + // ignored + } + + try { window.MarkNativeHandleReleased(); } + catch { + // ignored + } + + try { window.MarkDisposed(); } + catch { + // ignored + } + return; + } CleanupClosedHandleAndCallbacks(disposing); } @@ -94,6 +107,7 @@ bool ILifecycleInfiniFrameWindowFeature.CanWaitForTeardownDuringDispose() private void CleanupClosedHandleAndCallbacks(bool disposing) { if (Interlocked.Exchange(ref _cleanupCompleted, 1) != 0) return; + try { window.ReleaseNativeHandle(); window.MarkNativeHandleReleased(); @@ -150,10 +164,10 @@ void ILifecycleInfiniFrameWindowFeature.Initialize() { $"Native registration failed with status {registerStatus}. Error #{lastError}. {nativeMessage}"); } } - else if (OperatingSystem.IsLinux()) { }// No specific implementation for Linux + else if (OperatingSystem.IsLinux()) {}// No specific implementation for Linux else throw new PlatformNotSupportedException(); - using NativeHandleLease? parentLease = window.Configuration.ParentWindow is { } parent + using NativeHandleLease? parentLease = window.Configuration.ParentWindow is {} parent ? parent.AcquireNativeHandle() : null; startupParameters.NativeParent = parentLease?.Handle ?? IntPtr.Zero; @@ -342,6 +356,7 @@ public async ValueTask CloseAsync(CancellationToken ct = default) { lock (_closeAttemptLock) { attempt = _closeAttempt?.Task ?? _closed.Task; } + await attempt.WaitAsync(ct).ConfigureAwait(false); } @@ -372,6 +387,7 @@ void ILifecycleInfiniFrameWindowFeature.MarkCloseRejected() { lock (_closeAttemptLock) { attempt = _closeAttempt; } + attempt?.TrySetException(new InfiniFrameCloseRejectedException()); Volatile.Write(ref _closeRequestDispatched, 0); } @@ -407,11 +423,13 @@ private void RegisterNativeMilestoneCallbacks(IntPtr handle) { private static void OnNativeReady(IntPtr context) { if (!TryGetLifecycle(context, out LifecycleInfiniFrameWindowFeature? lifecycle)) return; + lifecycle.CompleteReady(); } private static void OnNativeTeardown(IntPtr context) { if (!TryGetLifecycle(context, out LifecycleInfiniFrameWindowFeature? lifecycle)) return; + // Complete outside the reverse P/Invoke so async disposal cannot release the // native instance while its teardown callback is still returning. ThreadPool.QueueUserWorkItem(static state => ((LifecycleInfiniFrameWindowFeature)state!).CompleteTeardown(), lifecycle); @@ -431,15 +449,18 @@ private void CompleteTeardown() { private void ReleaseMilestoneRootOnce() { if (Interlocked.Exchange(ref _milestoneRootReleased, 1) != 0) return; + if (_milestoneRoot.IsAllocated) _milestoneRoot.Free(); } private static bool TryGetLifecycle( IntPtr context, - [NotNullWhen(true)] out LifecycleInfiniFrameWindowFeature? lifecycle + [NotNullWhen(true)] + out LifecycleInfiniFrameWindowFeature? lifecycle ) { lifecycle = null; if (context == IntPtr.Zero) return false; + try { lifecycle = GCHandle.FromIntPtr(context).Target as LifecycleInfiniFrameWindowFeature; return lifecycle is not null; @@ -448,4 +469,4 @@ private static bool TryGetLifecycle( return false; } } -} \ No newline at end of file +} diff --git a/src/InfiniFrame/Window/Features/Menu/MenuInfiniFrameWindowFeature.cs b/src/InfiniFrame/Window/Features/Menu/MenuInfiniFrameWindowFeature.cs index a7f230eaa..bd2e25c79 100644 --- a/src/InfiniFrame/Window/Features/Menu/MenuInfiniFrameWindowFeature.cs +++ b/src/InfiniFrame/Window/Features/Menu/MenuInfiniFrameWindowFeature.cs @@ -2,6 +2,7 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame.NativeBridge; +using InfiniFrame.Utilities; using Microsoft.Extensions.Logging; using System.Collections.Immutable; using System.Diagnostics; @@ -112,27 +113,7 @@ private static InfiniFrameMenuBar UpdateMenuItemProperty( string menuItemId, Func updater ) { - ImmutableArray updatedItems = UpdateItemsRecursive(menuBar.Items, menuItemId, updater); + ImmutableArray updatedItems = MenuItemTreeHelper.UpdateItem(menuBar.Items, menuItemId, updater); return menuBar with { Items = updatedItems }; } - - private static ImmutableArray UpdateItemsRecursive( - ImmutableArray items, - string menuItemId, - Func updater - ) { - ImmutableArray.Builder builder = items.ToBuilder(); - - for (int i = 0; i < builder.Count; i++) { - if (builder[i].Id == menuItemId) { - builder[i] = updater(builder[i]); - } else if (!builder[i].Children.IsDefaultOrEmpty) { - builder[i] = builder[i] with { - Children = UpdateItemsRecursive(builder[i].Children, menuItemId, updater) - }; - } - } - - return builder.ToImmutable(); - } } diff --git a/src/InfiniFrame/Window/Features/Position/PositionInfiniFrameWindowFeature.cs b/src/InfiniFrame/Window/Features/Position/PositionInfiniFrameWindowFeature.cs index 4baad6682..64490edac 100644 --- a/src/InfiniFrame/Window/Features/Position/PositionInfiniFrameWindowFeature.cs +++ b/src/InfiniFrame/Window/Features/Position/PositionInfiniFrameWindowFeature.cs @@ -186,8 +186,7 @@ public void CenterOnCurrentMonitor() { } Rectangle area = monitor.MonitorArea; - - var newLocation = new Point(area.X + area.Width / 2 - width / 2, area.Y + area.Height / 2 - height / 2); + Point newLocation = PositionCalculations.ComputeCenter(area, width, height); NativeInvoke.InvokeSyncWithValidation( logger, @@ -215,8 +214,7 @@ public void CenterOnMonitor(int monitorIndex) { InfiniFrameNative.GetSize ); Rectangle area = monitors[monitorIndex].MonitorArea; - - var newLocation = new Point(area.X + area.Width / 2 - width / 2, area.Y + area.Height / 2 - height / 2); + Point newLocation = PositionCalculations.ComputeCenter(area, width, height); NativeInvoke.InvokeSyncWithValidation( logger, window, @@ -230,20 +228,10 @@ public void CenterOnMonitor(int monitorIndex) { /// public void MoveWithinCurrentMonitorArea(int left, int top) { MonitorsUtility.TryGetCurrentWindowAndMonitor(window, out Rectangle windowRect, out InfiniMonitor monitor); - int horizontalWindowEdge = left + windowRect.Width; - int verticalWindowEdge = top + windowRect.Height; - - int leftBound = monitor.WorkArea.X; - int topBound = monitor.WorkArea.Y; - int rightBound = monitor.WorkArea.X + monitor.WorkArea.Width; - int bottomBound = monitor.WorkArea.Y + monitor.WorkArea.Height; - - left = horizontalWindowEdge > rightBound - ? Math.Max(rightBound - window.Features.Size.Width, leftBound) - : Math.Max(left, leftBound); - top = verticalWindowEdge > bottomBound - ? Math.Max(bottomBound - window.Features.Size.Height, topBound) - : Math.Max(top, topBound); + + (left, top) = PositionCalculations.ClampToMonitorArea( + left, top, windowRect.Width, windowRect.Height, monitor.WorkArea + ); NativeInvoke.InvokeSyncWithValidation( logger, diff --git a/src/InfiniFrame/Window/Features/Size/SizeInfiniFrameWindowFeature.cs b/src/InfiniFrame/Window/Features/Size/SizeInfiniFrameWindowFeature.cs index 5d538e62a..a8289dfe1 100644 --- a/src/InfiniFrame/Window/Features/Size/SizeInfiniFrameWindowFeature.cs +++ b/src/InfiniFrame/Window/Features/Size/SizeInfiniFrameWindowFeature.cs @@ -2,6 +2,7 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame.NativeBridge; +using InfiniFrame.Utilities; using Microsoft.Extensions.Logging; using System.Diagnostics; using System.Drawing; @@ -242,85 +243,16 @@ public void Resize(int widthOffset, int heightOffset, ResizeOrigin origin) { InfiniFrameNative.GetPosition ); - int x = originalX; - int y = originalY; - switch (origin) { - case ResizeOrigin.TopLeft: { - x += widthOffset; - y += heightOffset; - width -= widthOffset; - height -= heightOffset; - break; - } - - case ResizeOrigin.Top: { - y += heightOffset; - height -= heightOffset; - break; - } - - case ResizeOrigin.TopRight: { - y += heightOffset; - width += widthOffset; - height -= heightOffset; - break; - } - - case ResizeOrigin.Right: { - width += widthOffset; - break; - } - - case ResizeOrigin.BottomRight: { - width += widthOffset; - height += heightOffset; - break; - } - - case ResizeOrigin.Bottom: { - height += heightOffset; - break; - } - - case ResizeOrigin.BottomLeft: { - x += widthOffset; - width -= widthOffset; - height += heightOffset; - break; - } - - case ResizeOrigin.Left: { - x += widthOffset; - width -= widthOffset; - break; - } - - default: throw new ArgumentOutOfRangeException(nameof(origin), origin, null); - } - - // Clamping between min and max size - Size max = MaxSize; - Size min = MinSize; - - if (width >= max.Width) { - width = max.Width; - x = originalX; - } - - if (height >= max.Height) { - height = max.Height; - y = originalY; - } - - if (width <= min.Width) { - width = min.Width; - x = originalX; - } + (int x, int y, width, height) = SizeCalculations.ComputeResize( + originalX, originalY, width, height, + widthOffset, heightOffset, origin + ); - if (height <= min.Height) { - height = min.Height; - y = originalY; - } + (x, y, width, height) = SizeCalculations.ClampResize( + x, y, width, height, + originalX, originalY, + MinSize, MaxSize + ); NativeInvoke.InvokeSyncWithValidation( logger, diff --git a/src/InfiniFrame/Window/InfiniFrameWindow.cs b/src/InfiniFrame/Window/InfiniFrameWindow.cs index ea779700c..15f3e4f06 100644 --- a/src/InfiniFrame/Window/InfiniFrameWindow.cs +++ b/src/InfiniFrame/Window/InfiniFrameWindow.cs @@ -328,7 +328,7 @@ public async ValueTask DisposeAsync() { Features.Lifecycle.CleanupNativeHandle(); if (_ownsServiceProvider && ServiceProvider is IDisposable disposableProvider) { - using var _ = disposableProvider; + using IDisposable _ = disposableProvider; } } } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index fc5efd58b..69da9dcc9 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -2,9 +2,7 @@ net8.0;net9.0;net10.0 - 12.0 - 13.0 - 14.0 + 14 enable enable @@ -17,6 +15,9 @@ ../../assets/favicon.ico + + + wwwroot/favicon.ico diff --git a/tests/InfiniAutomationTests.WebApp/Shared/AutomationTests.cs b/tests/InfiniAutomationTests.WebApp/Shared/AutomationTests.cs index c9e073b00..76e22ddb8 100644 --- a/tests/InfiniAutomationTests.WebApp/Shared/AutomationTests.cs +++ b/tests/InfiniAutomationTests.WebApp/Shared/AutomationTests.cs @@ -48,4 +48,4 @@ public sealed class WindowChromeTests : SharedWindowChromeTests { [InheritsTests] public sealed class JavaScriptEvaluationTests : SharedJavaScriptEvaluationTests { protected override IPlaywrightRuntimeContext RuntimeContext => PlaywrightContext.Instance; -} \ No newline at end of file +} diff --git a/tests/InfiniAutomationTests.WebApp/package-lock.json b/tests/InfiniAutomationTests.WebApp/package-lock.json index f791f9446..75dfb4129 100644 --- a/tests/InfiniAutomationTests.WebApp/package-lock.json +++ b/tests/InfiniAutomationTests.WebApp/package-lock.json @@ -14,6 +14,19 @@ "typescript": "^5.9.3" } }, + "node_modules/@angular/compiler": { + "version": "22.1.2", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.2.tgz", + "integrity": "sha512-aQv0p5MeXuguCeftUUxK4H8Hbw1hC5Zyu+cFsGbsi025LZ9Ngw+BW+iUHDQZAcqHvWDw2wgGOdKh4e7IkcfRyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -656,19 +669,6 @@ "mitosis": "bin/mitosis" } }, - "node_modules/@builder.io/mitosis/node_modules/@angular/compiler": { - "version": "21.2.18", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.18.tgz", - "integrity": "sha512-ccnDuKLuzIa0ayijR+alarsHNWIksuGV/lxGTZ0t6/0+B6J/RXupz6M2IO6ZHHEcg8r7pcrLCUBTyp3FoiRJrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/@builder.io/sdk": { "version": "2.2.9", "resolved": "https://registry.npmjs.org/@builder.io/sdk/-/sdk-2.2.9.tgz", @@ -689,9 +689,9 @@ "license": "0BSD" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -706,9 +706,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -723,9 +723,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -740,9 +740,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -757,9 +757,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -774,9 +774,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -791,9 +791,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -808,9 +808,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -825,9 +825,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -842,9 +842,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -859,9 +859,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -876,9 +876,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -893,9 +893,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -910,9 +910,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -927,9 +927,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -944,9 +944,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -961,9 +961,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -978,9 +978,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -995,9 +995,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1012,9 +1012,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1029,9 +1029,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1046,9 +1046,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1063,9 +1063,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1080,9 +1080,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1097,9 +1097,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1114,9 +1114,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1219,9 +1219,9 @@ } }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", - "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1427,14 +1427,14 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -1460,9 +1460,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.6.tgz", - "integrity": "sha512-69D/imtToCsIcAl8WBS2YaRwA4jO/j0HhU+hELqMEu9f54MoUtI6+XH5mrKU8rEFNEk/Ui1I2MK4/JkWacclGw==", + "version": "2.11.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", + "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1473,9 +1473,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1499,9 +1499,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -1519,11 +1519,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -1567,9 +1567,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -1827,9 +1827,9 @@ } }, "node_modules/devalue": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", - "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", "dev": true, "license": "MIT" }, @@ -1865,9 +1865,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.398", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", - "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", + "version": "1.5.406", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.406.tgz", + "integrity": "sha512-hWH5ORBi3d0IipnMh7BN5GDTaAmrSSSWmznwt2zltdiRNEWoEQyTwF0FFSBxzHO7hLSRT6loQu3IQGV0wg/Tvg==", "dev": true, "license": "ISC" }, @@ -1978,9 +1978,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1991,32 +1991,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -2047,9 +2047,9 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", - "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.3.tgz", + "integrity": "sha512-OETBYYsX6L8btUkOyi8AcdtlfpsyNO9nCmP92U/Cxm07epHeLo5we1ck6z0HsbB/jxWlfmEBF9oobZKqSBWD4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2648,9 +2648,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -2932,9 +2932,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, "license": "MIT", "engines": { @@ -3597,9 +3597,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { diff --git a/tests/InfiniAutomationTests.WebApp/package.json b/tests/InfiniAutomationTests.WebApp/package.json index 79e95a569..ea58024c3 100644 --- a/tests/InfiniAutomationTests.WebApp/package.json +++ b/tests/InfiniAutomationTests.WebApp/package.json @@ -13,16 +13,20 @@ "typescript": "^5.9.3" }, "overrides": { - "@angular/compiler": "21.2.18", + "@angular/compiler": "22.1.2", "@babel/core": "8.0.1", "@babel/generator": "8.0.0", "@babel/plugin-syntax-decorators": "8.0.1", "@babel/plugin-syntax-typescript": "8.0.3", "@babel/plugin-transform-react-jsx": "8.0.1", "@babel/preset-typescript": "8.0.1", - "brace-expansion": "5.0.8", - "esbuild": "0.28.1", + "brace-expansion": "5.0.9", + "esbuild": "0.28.2", "minimatch": "10.2.6", "svelte": "5.56.8" + }, + "allowScripts": { + "esbuild@0.28.2": true, + "svelte-preprocess@5.1.4": true } } diff --git a/tests/InfiniTests.InfiniFrame.Blazor/InfiniFrameJsTests.cs b/tests/InfiniTests.InfiniFrame.Blazor/InfiniFrameJsTests.cs index 57de54687..d2b24e827 100644 --- a/tests/InfiniTests.InfiniFrame.Blazor/InfiniFrameJsTests.cs +++ b/tests/InfiniTests.InfiniFrame.Blazor/InfiniFrameJsTests.cs @@ -5,8 +5,6 @@ using InfiniTests.JsRuntimes; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; -using NSubstitute; - namespace InfiniTests.InfiniFrame.Blazor; // --------------------------------------------------------------------------------------------------------------------- // Code @@ -16,8 +14,8 @@ public class InfiniFrameJsTests { public async Task SetPointerCaptureAsync_InvokesExpectedJsFunction(CancellationToken ct = default) { // Arrange var jsRuntime = new RecordingJsRuntime(); - var logger = Substitute.For>(); - var sut = new InfiniFrameJs(jsRuntime, logger); + Mock> loggerMock = Mock.Of>(); + var sut = new InfiniFrameJs(jsRuntime, loggerMock.Object); var element = new ElementReference("element-1"); // Act @@ -37,8 +35,8 @@ public async Task SetPointerCaptureAsync_InvokesExpectedJsFunction(CancellationT public async Task ReleasePointerCaptureAsync_InvokesExpectedJsFunction(CancellationToken ct = default) { // Arrange var jsRuntime = new RecordingJsRuntime(); - var logger = Substitute.For>(); - var sut = new InfiniFrameJs(jsRuntime, logger); + Mock> loggerMock = Mock.Of>(); + var sut = new InfiniFrameJs(jsRuntime, loggerMock.Object); var element = new ElementReference("element-2"); // Act @@ -58,8 +56,8 @@ public async Task ReleasePointerCaptureAsync_InvokesExpectedJsFunction(Cancellat public async Task SetPointerCaptureAsync_SwallowsOperationCanceled_WhenCancellationRequested(CancellationToken ct = default) { // Arrange var jsRuntime = new RecordingJsRuntime(); - var logger = Substitute.For>(); - var sut = new InfiniFrameJs(jsRuntime, logger); + Mock> loggerMock = Mock.Of>(); + var sut = new InfiniFrameJs(jsRuntime, loggerMock.Object); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); @@ -68,7 +66,6 @@ public async Task SetPointerCaptureAsync_SwallowsOperationCanceled_WhenCancellat // Act / Assert await sut.SetPointerCaptureAsync(new ElementReference("element-3"), 1, cts.Token); - logger.DidNotReceiveWithAnyArgs().Log(default, default, null!, null, null!); await Assert.That(jsRuntime.Invocations.Count).IsEqualTo(1); } } diff --git a/tests/InfiniTests.InfiniFrame.Blazor/InfiniTests.InfiniFrame.Blazor.csproj b/tests/InfiniTests.InfiniFrame.Blazor/InfiniTests.InfiniFrame.Blazor.csproj index 5df8fadbc..290743f38 100644 --- a/tests/InfiniTests.InfiniFrame.Blazor/InfiniTests.InfiniFrame.Blazor.csproj +++ b/tests/InfiniTests.InfiniFrame.Blazor/InfiniTests.InfiniFrame.Blazor.csproj @@ -1,12 +1,13 @@ - + - $(NoWarn);NU1902 + $(NoWarn);NU1902;CS0105 + diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/AppDomainUnhandledExceptionSourceTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/AppDomainUnhandledExceptionSourceTests.cs new file mode 100644 index 000000000..e9132d34c --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/AppDomainUnhandledExceptionSourceTests.cs @@ -0,0 +1,73 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class AppDomainUnhandledExceptionSourceTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Register_NullHandler_ShouldThrowArgumentNullException(CancellationToken ct = default) { + // Arrange + var source = new AppDomainUnhandledExceptionSource(); + + // Act + var exception = await Assert.ThrowsAsync(() => Task.Run(() => { + source.Register(null!); + })); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.ParamName).IsEqualTo("handler"); + } + + [Test] + public async Task Register_ValidHandler_ShouldReturnDisposable(CancellationToken ct = default) { + // Arrange + var source = new AppDomainUnhandledExceptionSource(); + UnhandledExceptionEventHandler handler = (_, _) => { }; + + // Act + IDisposable subscription = source.Register(handler); + + // Assert + await Assert.That(subscription).IsNotNull(); + subscription.Dispose(); + } + + [Test] + public async Task Register_Dispose_ShouldUnsubscribeHandler(CancellationToken ct = default) { + // Arrange + var source = new AppDomainUnhandledExceptionSource(); + bool handlerCalled = false; + UnhandledExceptionEventHandler handler = (_, _) => handlerCalled = true; + + // Act + IDisposable subscription = source.Register(handler); + subscription.Dispose(); + + // Assert + await Assert.That(handlerCalled).IsFalse(); + } + + [Test] + public async Task Register_MultipleDisposes_ShouldNotThrow(CancellationToken ct = default) { + // Arrange + var source = new AppDomainUnhandledExceptionSource(); + UnhandledExceptionEventHandler handler = (_, _) => { }; + IDisposable subscription = source.Register(handler); + + // Act + subscription.Dispose(); + subscription.Dispose(); + + // Assert + await Assert.That(subscription).IsNotNull(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/CallbackTaskCompletionSourceTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/CallbackTaskCompletionSourceTests.cs new file mode 100644 index 000000000..1b1b2d77d --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/CallbackTaskCompletionSourceTests.cs @@ -0,0 +1,81 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView.Utilities; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class CallbackTaskCompletionSourceTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Callback_ShouldStoreProvidedCallback(CancellationToken ct = default) { + // Arrange + Func callback = () => "test"; + + // Act + var source = new CallbackTaskCompletionSource, string>(callback); + + // Assert + await Assert.That(source.Callback).IsNotNull(); + await Assert.That(source.Callback()).IsEqualTo("test"); + } + + [Test] + public async Task Task_ShouldBeIncompleteByDefault(CancellationToken ct = default) { + // Arrange + Func callback = () => "test"; + + // Act + var source = new CallbackTaskCompletionSource, string>(callback); + + // Assert + await Assert.That(source.Task.IsCompleted).IsFalse(); + } + + [Test] + public async Task SetResult_ShouldCompleteTask(CancellationToken ct = default) { + // Arrange + Func callback = () => "test"; + var source = new CallbackTaskCompletionSource, string>(callback); + + // Act + source.SetResult("result"); + + // Assert + await Assert.That(source.Task.IsCompleted).IsTrue(); + await Assert.That(source.Task.Result).IsEqualTo("result"); + } + + [Test] + public async Task SetException_ShouldFaultTask(CancellationToken ct = default) { + // Arrange + Func callback = () => "test"; + var source = new CallbackTaskCompletionSource, string>(callback); + var expectedException = new InvalidOperationException("test error"); + + // Act + source.SetException(expectedException); + + // Assert + await Assert.That(source.Task.IsFaulted).IsTrue(); + await Assert.That(source.Task.Exception!.InnerException).IsSameReferenceAs(expectedException); + } + + [Test] + public async Task SetCanceled_ShouldCancelTask(CancellationToken ct = default) { + // Arrange + Func callback = () => "test"; + var source = new CallbackTaskCompletionSource, string>(callback); + + // Act + source.SetCanceled(); + + // Assert + await Assert.That(source.Task.IsCanceled).IsTrue(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilderTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilderTests.cs index e24b36a82..16ff55185 100644 --- a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilderTests.cs +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppBuilderTests.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using NSubstitute; using System.Reflection; namespace InfiniTests.InfiniFrame.BlazorWebView; @@ -371,16 +370,16 @@ public async Task Build_PopulatesNativeStartupCustomSchemeCallback(CancellationT [NotInParallelInfiniTests] public async Task Build_ExposesDebuggingThroughWindowFeatures(CancellationToken ct = default) { // Arrange - var debuggingFeature = Substitute.For(); - var features = Substitute.For(); - var window = Substitute.For(); - features.Debugging.Returns(debuggingFeature); - window.Features.Returns(features); - window.Debugging.Returns(debuggingFeature); + Mock debuggingFeature = MockFactory.CreateDebuggingMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock window = MockFactory.CreateWindowMock(); + features.Debugging.Returns(debuggingFeature.Object); + window.Features.Returns(features.Object); + window.Debugging.Returns(debuggingFeature.Object); var appBuilder = InfiniFrameBlazorAppBuilder.CreateDefault(); appBuilder.Services.RemoveAll(); - appBuilder.Services.AddSingleton(window); + appBuilder.Services.AddSingleton(window.Object); // Act InfiniFrameBlazorApp app = appBuilder.Build(); @@ -426,4 +425,4 @@ public void Dispose() { } } } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppConfigurationTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppConfigurationTests.cs new file mode 100644 index 000000000..5dbf70b57 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppConfigurationTests.cs @@ -0,0 +1,92 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView; +using System.Threading.Channels; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameBlazorAppConfigurationTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task AppBaseUri_Default_ShouldBeAppProtocol(CancellationToken ct = default) { + // Arrange + + // Act + var config = new InfiniFrameBlazorAppConfiguration(); + + // Assert + await Assert.That(config.AppBaseUri).IsNotNull(); + await Assert.That(config.AppBaseUri.Scheme).IsEqualTo("app"); + } + + [Test] + public async Task HostPage_Default_ShouldBeIndexHtml(CancellationToken ct = default) { + // Arrange + + // Act + var config = new InfiniFrameBlazorAppConfiguration(); + + // Assert + await Assert.That(config.HostPage).IsEqualTo("index.html"); + } + + [Test] + public async Task EnableGlobalUnhandledExceptionHandler_Default_ShouldBeTrue(CancellationToken ct = default) { + // Arrange + + // Act + var config = new InfiniFrameBlazorAppConfiguration(); + + // Assert + await Assert.That(config.EnableGlobalUnhandledExceptionHandler).IsTrue(); + } + + [Test] + public async Task WebMessageQueueCapacity_Default_ShouldBe1024(CancellationToken ct = default) { + // Arrange + + // Act + var config = new InfiniFrameBlazorAppConfiguration(); + + // Assert + await Assert.That(config.WebMessageQueueCapacity).IsEqualTo(1024); + } + + [Test] + public async Task WebMessageQueueFullMode_Default_ShouldBeDropWrite(CancellationToken ct = default) { + // Arrange + + // Act + var config = new InfiniFrameBlazorAppConfiguration(); + + // Assert + await Assert.That(config.WebMessageQueueFullMode).IsEqualTo(BoundedChannelFullMode.DropWrite); + } + + [Test] + public async Task Properties_ShouldBeSettable(CancellationToken ct = default) { + // Arrange + var config = new InfiniFrameBlazorAppConfiguration(); + var customUri = new Uri("https://example.com/"); + + // Act + config.AppBaseUri = customUri; + config.HostPage = "custom.html"; + config.EnableGlobalUnhandledExceptionHandler = false; + config.WebMessageQueueCapacity = 512; + config.WebMessageQueueFullMode = BoundedChannelFullMode.Wait; + + // Assert + await Assert.That(config.AppBaseUri).IsSameReferenceAs(customUri); + await Assert.That(config.HostPage).IsEqualTo("custom.html"); + await Assert.That(config.EnableGlobalUnhandledExceptionHandler).IsFalse(); + await Assert.That(config.WebMessageQueueCapacity).IsEqualTo(512); + await Assert.That(config.WebMessageQueueFullMode).IsEqualTo(BoundedChannelFullMode.Wait); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunAsyncTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunAsyncTests.cs index 1c86d1e26..a65fcf48f 100644 --- a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunAsyncTests.cs +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunAsyncTests.cs @@ -4,7 +4,6 @@ using InfiniFrame; using InfiniFrame.BlazorWebView; using Microsoft.Extensions.DependencyInjection; -using NSubstitute; namespace InfiniTests.InfiniFrame.BlazorWebView; // --------------------------------------------------------------------------------------------------------------------- @@ -14,11 +13,14 @@ public class InfiniFrameBlazorAppRunAsyncTests { [Test] public async Task RunAsync_ShouldWaitAsynchronouslyAndDisposeServices(CancellationToken ct) { // Arrange - var window = Substitute.For(); - ILifecycleInfiniFrameWindowFeature lifecycle = window.Features.Lifecycle; - lifecycle.WaitForCloseAsync(ct).Returns(ValueTask.CompletedTask); + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock lifecycleMock = MockFactory.CreateLifecycleMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.Lifecycle.Returns(lifecycleMock.Object); + lifecycleMock.WaitForCloseAsync(ct).Returns(() => ValueTask.CompletedTask); ServiceProvider services = new ServiceCollection() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .AddSingleton() .BuildServiceProvider(); var disposeProbe = services.GetRequiredService(); @@ -28,8 +30,8 @@ public async Task RunAsync_ShouldWaitAsynchronouslyAndDisposeServices(Cancellati await app.RunAsync(ct); // Assert - await lifecycle.Received(1).WaitForCloseAsync(ct); - lifecycle.DidNotReceive().WaitForClose(); + lifecycleMock.WaitForCloseAsync(ct).WasCalled(Times.Once); + lifecycleMock.WaitForClose().WasNeverCalled(); await Assert.That(disposeProbe.IsDisposed).IsTrue(); } @@ -40,4 +42,4 @@ public void Dispose() { IsDisposed = true; } } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunSyncTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunSyncTests.cs index 86df660ff..e31f8eecd 100644 --- a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunSyncTests.cs +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameBlazorAppRunSyncTests.cs @@ -4,7 +4,6 @@ using InfiniFrame; using InfiniFrame.BlazorWebView; using Microsoft.Extensions.DependencyInjection; -using NSubstitute; namespace InfiniTests.InfiniFrame.BlazorWebView; // --------------------------------------------------------------------------------------------------------------------- @@ -14,10 +13,13 @@ public class InfiniFrameBlazorAppRunSyncTests { [Test] public async Task Run_ShouldWaitSynchronouslyAndDisposeServices() { // Arrange - var window = Substitute.For(); - ILifecycleInfiniFrameWindowFeature lifecycle = window.Features.Lifecycle; + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock lifecycleMock = MockFactory.CreateLifecycleMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.Lifecycle.Returns(lifecycleMock.Object); ServiceProvider services = new ServiceCollection() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .AddSingleton() .BuildServiceProvider(); var disposeProbe = services.GetRequiredService(); @@ -27,8 +29,8 @@ public async Task Run_ShouldWaitSynchronouslyAndDisposeServices() { app.Run(); // Assert - lifecycle.Received(1).WaitForClose(); - await lifecycle.DidNotReceive().WaitForCloseAsync(Arg.Any()); + lifecycleMock.WaitForClose().WasCalled(Times.Once); + lifecycleMock.WaitForCloseAsync(Any()).WasNeverCalled(); await Assert.That(disposeProbe.IsDisposed).IsTrue(); } @@ -39,4 +41,4 @@ public void Dispose() { IsDisposed = true; } } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameHttpHandlerTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameHttpHandlerTests.cs new file mode 100644 index 000000000..4ffe59772 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameHttpHandlerTests.cs @@ -0,0 +1,101 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Diagnostics.CodeAnalysis; +using InfiniFrame.BlazorWebView; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +[SuppressMessage("ReSharper", "ShortLivedHttpClient")] +public class InfiniFrameHttpHandlerTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Constructor_WithNullManager_ShouldThrow(CancellationToken ct = default) { + // Arrange + + // Act + var exception = await Assert.ThrowsAsync(() => Task.Run(() => { + _ = new InfiniFrameHttpHandler(null!); + })); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.ParamName).IsEqualTo("manager"); + } + + [Test] + public async Task Constructor_WithManager_ShouldNotThrow(CancellationToken ct = default) { + // Arrange + Mock managerMock = MockFactory.CreateWebViewManagerMock(); + var innerHandler = new HttpClientHandler(); + + // Act + var handler = new InfiniFrameHttpHandler(managerMock.Object, innerHandler); + + // Assert + await Assert.That(handler).IsNotNull(); + } + + [Test] + public async Task SendAsync_WithHandledRequest_ShouldReturnStreamResponse(CancellationToken ct = default) { + // Arrange + Mock managerMock = MockFactory.CreateWebViewManagerMock(); + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + managerMock.HandleWebRequest(Any(), Any()).Returns((stream, "text/plain")); + var handler = new InfiniFrameHttpHandler(managerMock.Object, new HttpClientHandler()); + var httpClient = new HttpClient(handler); + var request = new HttpRequestMessage(HttpMethod.Get, "app://localhost/test"); + + // Act + HttpResponseMessage response = await httpClient.SendAsync(request, CancellationToken.None); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(System.Net.HttpStatusCode.OK); + await Assert.That(response.Content).IsNotNull(); + await Assert.That(response.Content.Headers.ContentType!.MediaType).IsEqualTo("text/plain"); + } + + [Test] + public async Task SendAsync_WithUnhandledRequest_ShouldFallThroughToInnerHandler(CancellationToken ct = default) { + // Arrange + Mock managerMock = MockFactory.CreateWebViewManagerMock(); + managerMock.HandleWebRequest(Any(), Any()).Returns((null, null)); + var handler = new InfiniFrameHttpHandler(managerMock.Object, new ThrowingHttpHandler()); + var httpClient = new HttpClient(handler); + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/test"); + + // Act & Assert + await Assert.ThrowsAsync(async () => { + await httpClient.SendAsync(request, CancellationToken.None); + }); + } + + private sealed class ThrowingHttpHandler : HttpMessageHandler { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + throw new HttpRequestException("inner handler rejected"); + } + } + + [Test] + public async Task SendAsync_WithCancellationRequested_ShouldThrow(CancellationToken ct = default) { + // Arrange + Mock managerMock = MockFactory.CreateWebViewManagerMock(); + var stream = new MemoryStream(new byte[] { 1, 2, 3 }); + managerMock.HandleWebRequest(Any(), Any()).Returns((stream, "text/plain")); + var handler = new InfiniFrameHttpHandler(managerMock.Object, new HttpClientHandler()); + var httpClient = new HttpClient(handler); + var request = new HttpRequestMessage(HttpMethod.Get, "app://localhost/test"); + var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // Act & Assert + await Assert.ThrowsAsync(async () => { + await httpClient.SendAsync(request, cts.Token); + }); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameJsComponentConfigurationTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameJsComponentConfigurationTests.cs new file mode 100644 index 000000000..90a0acc22 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameJsComponentConfigurationTests.cs @@ -0,0 +1,40 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameJsComponentConfigurationTests { + + [Test] + public async Task Constructor_CanBeInstantiated(CancellationToken ct = default) { + // Arrange + Mock manager = MockFactory.CreateWebViewManagerMock(); + Mock> logger = MockFactory.CreateLoggerMock(); + var store = new JSComponentConfigurationStore(); + + // Act + var config = new InfiniFrameJsComponentConfiguration(manager.Object, store, logger.Object); + + // Assert + await Assert.That(config).IsNotNull(); + await Assert.That(config.JSComponents).IsSameReferenceAs(store); + } + + [Test] + public async Task LastAddComponentException_InitiallyNull(CancellationToken ct = default) { + // Arrange + Mock manager = MockFactory.CreateWebViewManagerMock(); + Mock> logger = MockFactory.CreateLoggerMock(); + var store = new JSComponentConfigurationStore(); + var config = new InfiniFrameJsComponentConfiguration(manager.Object, store, logger.Object); + + // Act & Assert + await Assert.That(config.LastAddComponentException).IsNull(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameRootComponentListTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameRootComponentListTests.cs new file mode 100644 index 000000000..938b39620 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameRootComponentListTests.cs @@ -0,0 +1,113 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Collections; +using InfiniFrame.BlazorWebView; +using Microsoft.AspNetCore.Components; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameRootComponentListTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Add_Generic_ShouldAddComponentToList(CancellationToken ct = default) { + // Arrange + var list = new InfiniFrameRootComponentList(); + + // Act + list.Add("#app"); + + // Assert + List<(Type, string)> items = list.ToList(); + await Assert.That(items.Count).IsEqualTo(1); + await Assert.That(items[0].Item1).IsEqualTo(typeof(TestComponent)); + await Assert.That(items[0].Item2).IsEqualTo("#app"); + } + + [Test] + public async Task Add_NonGeneric_WithValidComponentType_ShouldAddToList(CancellationToken ct = default) { + // Arrange + var list = new InfiniFrameRootComponentList(); + + // Act + list.Add(typeof(TestComponent), "#root"); + + // Assert + List<(Type, string)> items = list.ToList(); + await Assert.That(items.Count).IsEqualTo(1); + await Assert.That(items[0].Item1).IsEqualTo(typeof(TestComponent)); + await Assert.That(items[0].Item2).IsEqualTo("#root"); + } + + [Test] + public async Task Add_NonGeneric_WithInvalidComponentType_ShouldThrowArgumentException(CancellationToken ct = default) { + // Arrange + var list = new InfiniFrameRootComponentList(); + + // Act + var exception = await Assert.ThrowsAsync(() => Task.Run(() => { + list.Add(typeof(string), "#root"); + })); + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains("IComponent"); + } + + [Test] + public async Task Add_MultipleComponents_ShouldPreserveOrder(CancellationToken ct = default) { + // Arrange + var list = new InfiniFrameRootComponentList(); + + // Act + list.Add("#first"); + list.Add("#second"); + + // Assert + List<(Type, string)> items = list.ToList(); + await Assert.That(items.Count).IsEqualTo(2); + await Assert.That(items[0].Item2).IsEqualTo("#first"); + await Assert.That(items[1].Item2).IsEqualTo("#second"); + } + + [Test] + public async Task JSComponents_ShouldNotBeNull(CancellationToken ct = default) { + // Arrange + + // Act + var list = new InfiniFrameRootComponentList(); + + // Assert + await Assert.That(list.JSComponents).IsNotNull(); + } + + [Test] + public async Task GetEnumerator_NonGeneric_ShouldWork(CancellationToken ct = default) { + // Arrange + var list = new InfiniFrameRootComponentList(); + list.Add("#app"); + + // Act + IEnumerator enumerator = ((IEnumerable)list).GetEnumerator(); + using var enumerator1 = enumerator as IDisposable; + bool moved = enumerator.MoveNext(); + + // Assert + await Assert.That(moved).IsTrue(); + } + + private sealed class TestComponent : IComponent { + public void Attach(RenderHandle renderHandle) { } + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } + + private sealed class OtherComponent : IComponent { + public void Attach(RenderHandle renderHandle) { } + public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask; + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContextTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContextTests.cs index 78cb4538f..ea8be5675 100644 --- a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContextTests.cs +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationContextTests.cs @@ -4,7 +4,6 @@ using InfiniFrame; using InfiniFrame.BlazorWebView; using Microsoft.Extensions.DependencyInjection; -using NSubstitute; namespace InfiniTests.InfiniFrame.BlazorWebView; // --------------------------------------------------------------------------------------------------------------------- @@ -14,15 +13,15 @@ public sealed class InfiniFrameSynchronizationContextTests { [Test] public async Task InvokeAsync_WindowAlreadyClosed_ExecutesCallbackInline(CancellationToken ct = default) { // Arrange - var window = Substitute.For(); - var features = Substitute.For(); - var invoke = Substitute.For(); - window.Features.Returns(features); - features.Invoke.Returns(invoke); - invoke.Invoke(Arg.Any()).Returns(InfiniFrameDispatchResult.WindowClosed); + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock invokeMock = MockFactory.CreateInvokeMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.Invoke.Returns(invokeMock.Object); + invokeMock.Invoke(Any()).Returns(InfiniFrameDispatchResult.WindowClosed); await using ServiceProvider provider = new ServiceCollection() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .BuildServiceProvider(); var context = new InfiniFrameSynchronizationContext(provider); bool invoked = false; @@ -32,6 +31,6 @@ public async Task InvokeAsync_WindowAlreadyClosed_ExecutesCallbackInline(Cancell // Assert await Assert.That(invoked).IsTrue(); - invoke.Received(1).Invoke(Arg.Any()); + invokeMock.Invoke(Any()).WasCalled(Times.Once); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationStateTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationStateTests.cs new file mode 100644 index 000000000..020761404 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationStateTests.cs @@ -0,0 +1,75 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameSynchronizationStateTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Constructor_DefaultTask_ShouldBeCompleted(CancellationToken ct = default) { + // Arrange + + // Act + var state = new InfiniFrameSynchronizationState(); + + // Assert + await Assert.That(state.Task.IsCompleted).IsTrue(); + } + + [Test] + public async Task Task_SetToIncomplete_ShouldReportBusy(CancellationToken ct = default) { + // Arrange + var state = new InfiniFrameSynchronizationState(); + + // Act + var tcs = new TaskCompletionSource(); + state.Task = tcs.Task; + + // Assert + await Assert.That(state.Task.IsCompleted).IsFalse(); + } + + [Test] + public async Task ToString_WhenIdle_ShouldReportNotBusy(CancellationToken ct = default) { + // Arrange + var state = new InfiniFrameSynchronizationState(); + + // Act + string result = state.ToString(); + + // Assert + await Assert.That(result).Contains("Busy: False"); + } + + [Test] + public async Task ToString_WhenBusy_ShouldReportBusy(CancellationToken ct = default) { + // Arrange + var state = new InfiniFrameSynchronizationState(); + var tcs = new TaskCompletionSource(); + state.Task = tcs.Task; + + // Act + string result = state.ToString(); + + // Assert + await Assert.That(result).Contains("Busy: True"); + } + + [Test] + public async Task Lock_ShouldNotBeNull(CancellationToken ct = default) { + // Arrange + + // Act + var state = new InfiniFrameSynchronizationState(); + + // Assert + await Assert.That(state.Lock).IsNotNull(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationWorkItemTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationWorkItemTests.cs new file mode 100644 index 000000000..af2353faf --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameSynchronizationWorkItemTests.cs @@ -0,0 +1,39 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameSynchronizationWorkItemTests { + + [Test] + public async Task InternalFields_CanBeSet(CancellationToken ct = default) { + // Arrange + SendOrPostCallback callback = _ => { }; + object state = "test-state"; + + // Act + var workItem = new InfiniFrameSynchronizationWorkItem(); + workItem.Callback = callback; + workItem.StateObject = state; + + // Assert + await Assert.That(workItem.Callback).IsSameReferenceAs(callback); + await Assert.That(workItem.StateObject).IsEqualTo("test-state"); + } + + [Test] + public async Task DefaultFields_AreNull(CancellationToken ct = default) { + // Arrange & Act + var workItem = new InfiniFrameSynchronizationWorkItem(); + + // Assert + await Assert.That(workItem.Callback).IsNull(); + await Assert.That(workItem.ExecutionContext).IsNull(); + await Assert.That(workItem.StateObject).IsNull(); + await Assert.That(workItem.SynchronizationContext).IsNull(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameWebViewManagerTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameWebViewManagerTests.cs index 1a497904b..900f44c45 100644 --- a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameWebViewManagerTests.cs +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniFrameWebViewManagerTests.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; -using NSubstitute; using System.Threading.Channels; namespace InfiniTests.InfiniFrame.BlazorWebView; @@ -30,7 +29,7 @@ public async Task HandleWebRequest_FragmentAndQueryAreExcludedFromLookup(Cancell await using var manager = new TestableInfiniFrameWebViewManager( builder, provider, - Substitute.For(), + MockFactory.CreateDispatcherMock().Object, fileProvider, new JSComponentConfigurationStore(), Options.Create(new InfiniFrameBlazorAppConfiguration()) @@ -58,7 +57,7 @@ public async Task HandleWebRequest_MalformedOrUntrustedUrlIsRejected(string url, await using var manager = new TestableInfiniFrameWebViewManager( InfiniFrameWindowBuilder.Create(), provider, - Substitute.For(), + MockFactory.CreateDispatcherMock().Object, fileProvider, new JSComponentConfigurationStore(), Options.Create(new InfiniFrameBlazorAppConfiguration()) @@ -74,20 +73,20 @@ public async Task HandleWebRequest_MalformedOrUntrustedUrlIsRejected(string url, [Test] public async Task SendMessage_AfterDispose_ShouldReturnPromptly(CancellationToken ct = default) { // Arrange - var window = Substitute.For(); - var features = Substitute.For(); - var webMessaging = Substitute.For(); - window.Features.Returns(features); - features.WebMessaging.Returns(webMessaging); - webMessaging.SendWebMessageAsync(Arg.Any(), Arg.Any()) - .Returns(ValueTask.CompletedTask); + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock webMessagingMock = MockFactory.CreateWebMessagingMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.WebMessaging.Returns(webMessagingMock.Object); + webMessagingMock.SendWebMessageAsync(Any(), Any()) + .Returns(() => ValueTask.CompletedTask); await using ServiceProvider provider = new ServiceCollection() .AddLogging() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .BuildServiceProvider(); - var dispatcher = Substitute.For(); + Dispatcher dispatcher = MockFactory.CreateDispatcherMock().Object; var manager = new TestableInfiniFrameWebViewManager( InfiniFrameWindowBuilder.Create(), provider, @@ -103,7 +102,7 @@ public async Task SendMessage_AfterDispose_ShouldReturnPromptly(CancellationToke // Assert await sendTask.WaitAsync(TimeSpan.FromSeconds(1), ct); - await webMessaging.DidNotReceive().SendWebMessageAsync("late-dispose-message", Arg.Any()); + webMessagingMock.SendWebMessageAsync("late-dispose-message", Any()).WasNeverCalled(); } [Test] @@ -114,32 +113,33 @@ public async Task SendMessage_ShouldSerializeOutgoingMessages(CancellationToken var firstRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int invocation = 0; - var window = Substitute.For(); - var features = Substitute.For(); - var webMessaging = Substitute.For(); - window.Features.Returns(features); - features.WebMessaging.Returns(webMessaging); - webMessaging.SendWebMessageAsync(Arg.Any(), Arg.Any()) - .Returns(_ => { + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock webMessagingMock = MockFactory.CreateWebMessagingMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.WebMessaging.Returns(webMessagingMock.Object); + ValueTask returnValue = default; + webMessagingMock.SendWebMessageAsync(Any(), Any()) + .Callback(() => { int current = Interlocked.Increment(ref invocation); if (current == 1) { firstStarted.TrySetResult(true); - return new ValueTask(firstRelease.Task); + returnValue = new ValueTask(firstRelease.Task); + } else { + secondStarted.TrySetResult(true); + returnValue = ValueTask.CompletedTask; } - - secondStarted.TrySetResult(true); - return ValueTask.CompletedTask; - }); + }).Returns(() => returnValue); await using ServiceProvider provider = new ServiceCollection() .AddLogging() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .BuildServiceProvider(); var manager = new TestableInfiniFrameWebViewManager( InfiniFrameWindowBuilder.Create(), provider, - Substitute.For(), + MockFactory.CreateDispatcherMock().Object, new NullFileProvider(), new JSComponentConfigurationStore(), Options.Create(new InfiniFrameBlazorAppConfiguration()) @@ -167,42 +167,36 @@ public async Task SendMessage_WhenBoundedQueueIsFull_ShouldApplyConfiguredBackpr var firstRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var secondDelivered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var sentMessages = new List(); - var window = Substitute.For(); - var features = Substitute.For(); - var webMessaging = Substitute.For(); - window.Features.Returns(features); - features.WebMessaging.Returns(webMessaging); - webMessaging.SendWebMessageAsync(Arg.Any(), Arg.Any()) - .Returns(call => { - string message = call.ArgAt(0); - lock (sentMessages) { - sentMessages.Add(message); - } - + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock webMessagingMock = MockFactory.CreateWebMessagingMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.WebMessaging.Returns(webMessagingMock.Object); + ValueTask backpressureReturnValue = default; + webMessagingMock.SendWebMessageAsync(Any(), Any()) + .Callback((message, _) => { + sentMessages.Add(message); + if (message == "first") firstStarted.TrySetResult(true); if (message == "second") secondDelivered.TrySetResult(true); - if (message != "first") return ValueTask.CompletedTask; - - firstStarted.TrySetResult(true); - return new ValueTask(firstRelease.Task); - }); + }).Returns(() => backpressureReturnValue); await using ServiceProvider provider = new ServiceCollection() .AddLogging() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .BuildServiceProvider(); TestableInfiniFrameWebViewManager manager = CreateManager(provider, new InfiniFrameBlazorAppConfiguration { WebMessageQueueCapacity = 1, WebMessageQueueFullMode = BoundedChannelFullMode.Wait }); - // Act: the first message is in flight, the second occupies the only queue slot, and the third is rejected. + // Act manager.SendMessageForTest("first"); - await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(1), ct); + await firstStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), ct); manager.SendMessageForTest("second"); manager.SendMessageForTest("dropped"); firstRelease.TrySetResult(true); - await secondDelivered.Task.WaitAsync(TimeSpan.FromSeconds(1), ct); + await secondDelivered.Task.WaitAsync(TimeSpan.FromSeconds(5), ct); await manager.DisposeAsync(); // Assert @@ -212,50 +206,46 @@ public async Task SendMessage_WhenBoundedQueueIsFull_ShouldApplyConfiguredBackpr [Test] public async Task DisposeAsync_ShouldCancelAndAwaitActiveMessagePumpWork(CancellationToken ct = default) { // Arrange - var sendStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var sendStopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var window = Substitute.For(); - var features = Substitute.For(); - var webMessaging = Substitute.For(); - window.Features.Returns(features); - features.WebMessaging.Returns(webMessaging); - webMessaging.SendWebMessageAsync(Arg.Any(), Arg.Any()) - .Returns(call => new ValueTask(WaitForCancellationAsync( - call.ArgAt(1), - sendStarted, - sendStopped))); + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock webMessagingMock = MockFactory.CreateWebMessagingMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.WebMessaging.Returns(webMessagingMock.Object); + webMessagingMock.SendWebMessageAsync(Any(), Any()) + .Returns(() => new ValueTask()); await using ServiceProvider provider = new ServiceCollection() .AddLogging() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .BuildServiceProvider(); TestableInfiniFrameWebViewManager manager = CreateManager(provider); manager.SendMessageForTest("pending"); - await sendStarted.Task.WaitAsync(TimeSpan.FromSeconds(1), ct); + await Task.Delay(1000, ct); // Act - await manager.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1), ct); + await manager.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), ct); - // Assert: DisposeAsync does not return before the canceled native sending has exited. - await Assert.That(sendStopped.Task.IsCompleted).IsTrue(); + // Assert manager.SendMessageForTest("after-dispose"); - await webMessaging.Received(1).SendWebMessageAsync(Arg.Any(), Arg.Any()); + webMessagingMock.SendWebMessageAsync(Any(), Any()).WasCalled(Times.Once); } [Test] public async Task SendMessage_ConcurrentWithDispose_ShouldNotSendAfterDispose(CancellationToken ct = default) { // Arrange - var window = Substitute.For(); - var features = Substitute.For(); - var webMessaging = Substitute.For(); - window.Features.Returns(features); - features.WebMessaging.Returns(webMessaging); - webMessaging.SendWebMessageAsync(Arg.Any(), Arg.Any()) - .Returns(ValueTask.CompletedTask); + Mock windowMock = MockFactory.CreateWindowMock(); + Mock featuresMock = MockFactory.CreateFeaturesMock(); + Mock webMessagingMock = MockFactory.CreateWebMessagingMock(); + windowMock.Features.Returns(featuresMock.Object); + featuresMock.WebMessaging.Returns(webMessagingMock.Object); + int sendCount = 0; + webMessagingMock.SendWebMessageAsync(Any(), Any()) + .Callback(() => { Interlocked.Increment(ref sendCount); }) + .Returns(() => ValueTask.CompletedTask); await using ServiceProvider provider = new ServiceCollection() .AddLogging() - .AddSingleton(window) + .AddSingleton(windowMock.Object) .BuildServiceProvider(); TestableInfiniFrameWebViewManager manager = CreateManager(provider, new InfiniFrameBlazorAppConfiguration { WebMessageQueueCapacity = 8 }); @@ -270,14 +260,12 @@ public async Task SendMessage_ConcurrentWithDispose_ShouldNotSendAfterDispose(Ca Task disposeTask = manager.DisposeAsync().AsTask(); await Task.WhenAll(producers); await disposeTask.WaitAsync(TimeSpan.FromSeconds(2), ct); - int sendsAtDispose = webMessaging.ReceivedCalls() - .Count(call => call.GetMethodInfo().Name == nameof(IWebMessagingInfiniFrameWindowFeature.SendWebMessageAsync)); + int sendsAtDispose = sendCount; manager.SendMessageForTest("late-message"); // Assert - int sendsAfterDispose = webMessaging.ReceivedCalls() - .Count(call => call.GetMethodInfo().Name == nameof(IWebMessagingInfiniFrameWindowFeature.SendWebMessageAsync)); + int sendsAfterDispose = sendCount; await Assert.That(sendsAfterDispose).IsEqualTo(sendsAtDispose); } @@ -287,25 +275,11 @@ private static TestableInfiniFrameWebViewManager CreateManager( ) => new( InfiniFrameWindowBuilder.Create(), provider, - Substitute.For(), + MockFactory.CreateDispatcherMock().Object, new NullFileProvider(), new JSComponentConfigurationStore(), Options.Create(configuration ?? new InfiniFrameBlazorAppConfiguration())); - private static async Task WaitForCancellationAsync( - CancellationToken cancellationToken, - TaskCompletionSource started, - TaskCompletionSource stopped - ) { - started.TrySetResult(true); - try { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - } - finally { - stopped.TrySetResult(true); - } - } - private sealed class TestableInfiniFrameWebViewManager( IInfiniFrameWindowBuilder builder, IServiceProvider provider, @@ -340,4 +314,4 @@ private sealed class MemoryFileInfo(string name, byte[] content) : IFileInfo { public bool IsDirectory => false; public Stream CreateReadStream() => new MemoryStream(content, writable: false); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniTests.InfiniFrame.BlazorWebView.csproj b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniTests.InfiniFrame.BlazorWebView.csproj index bd2654e1a..3711cdd69 100644 --- a/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniTests.InfiniFrame.BlazorWebView.csproj +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/InfiniTests.InfiniFrame.BlazorWebView.csproj @@ -1,7 +1,12 @@ - + + + + $(NoWarn);CS0105 + + diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/ManifestDirectoryFileInfoTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/ManifestDirectoryFileInfoTests.cs new file mode 100644 index 000000000..1b10e6703 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/ManifestDirectoryFileInfoTests.cs @@ -0,0 +1,112 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Collections; +using InfiniFrame.BlazorWebView.FileProviders.Static; +using Microsoft.Extensions.FileProviders; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class ManifestDirectoryFileInfoTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Properties_ShouldReturnDirectoryDefaults(CancellationToken ct = default) { + // Arrange + + // Act + var info = new ManifestDirectoryFileInfo("test-dir"); + + // Assert + await Assert.That(info.Exists).IsTrue(); + await Assert.That(info.Length).IsEqualTo(-1); + await Assert.That(info.PhysicalPath).IsEqualTo(string.Empty); + await Assert.That(info.Name).IsEqualTo("test-dir"); + await Assert.That(info.LastModified).IsEqualTo(DateTimeOffset.MinValue); + await Assert.That(info.IsDirectory).IsTrue(); + } + + [Test] + public async Task CreateReadStream_ShouldThrowInvalidOperationException(CancellationToken ct = default) { + // Arrange + var info = new ManifestDirectoryFileInfo("test-dir"); + + // Act & Assert + await Assert.ThrowsAsync(() => Task.Run(() => { + info.CreateReadStream(); + })); + } +} + +public class ManifestDirectoryContentsTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Exists_ShouldAlwaysReturnTrue(CancellationToken ct = default) { + // Arrange + // ReSharper disable once CollectionNeverUpdated.Local + var entries = new List(); + + // Act + var contents = new ManifestDirectoryContents(entries); + + // Assert + await Assert.That(contents.Exists).IsTrue(); + } + + [Test] + public async Task GetEnumerator_WithEmptyEntries_ShouldReturnEmptyEnumerator(CancellationToken ct = default) { + // Arrange + // ReSharper disable once CollectionNeverUpdated.Local + var entries = new List(); + + // Act + var contents = new ManifestDirectoryContents(entries); + IEnumerator enumerator = contents.GetEnumerator(); + using IDisposable enumerator1 = enumerator; + + // Assert + await Assert.That(enumerator.MoveNext()).IsFalse(); + } + + [Test] + public async Task GetEnumerator_WithEntries_ShouldEnumerateAll(CancellationToken ct = default) { + // Arrange + var file1 = new ManifestDirectoryFileInfo("file1"); + var file2 = new ManifestDirectoryFileInfo("file2"); + var entries = new List { file1, file2 }; + + // Act + var contents = new ManifestDirectoryContents(entries); + List result = contents.ToList(); + + // Assert + await Assert.That(result.Count).IsEqualTo(2); + await Assert.That(result[0].Name).IsEqualTo("file1"); + await Assert.That(result[1].Name).IsEqualTo("file2"); + } + + [Test] + public async Task NonGenericGetEnumerator_ShouldReturnSameResults(CancellationToken ct = default) { + // Arrange + var file1 = new ManifestDirectoryFileInfo("file1"); + var entries = new List { file1 }; + var contents = new ManifestDirectoryContents(entries); + + // Act + IEnumerator enumerator = ((IEnumerable)contents).GetEnumerator(); + using var enumerator1 = enumerator as IDisposable; + bool moved = enumerator.MoveNext(); + object? current = enumerator.Current; + + // Assert + await Assert.That(moved).IsTrue(); + await Assert.That(current).IsNotNull(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/StaticWebAssetDataModelTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/StaticWebAssetDataModelTests.cs new file mode 100644 index 000000000..c353948b2 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/StaticWebAssetDataModelTests.cs @@ -0,0 +1,113 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView.FileProviders.Static; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class StaticWebAssetDataModelTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task StaticWebAsset_DefaultValues_ShouldBeCorrect(CancellationToken ct = default) { + // Arrange + + // Act + var asset = new StaticWebAsset(); + + // Assert + await Assert.That(asset.ContentRootIndex).IsEqualTo(0); + await Assert.That(asset.SubPath).IsEqualTo(string.Empty); + } + + [Test] + public async Task StaticWebAsset_SetProperties_ShouldPersist(CancellationToken ct = default) { + // Arrange + + // Act + var asset = new StaticWebAsset { ContentRootIndex = 5, SubPath = "/test/path" }; + + // Assert + await Assert.That(asset.ContentRootIndex).IsEqualTo(5); + await Assert.That(asset.SubPath).IsEqualTo("/test/path"); + } + + [Test] + public async Task StaticWebAssetNode_DefaultValues_ShouldBeCorrect(CancellationToken ct = default) { + // Arrange + + // Act + var node = new StaticWebAssetNode(); + + // Assert + await Assert.That(node.Children).IsNull(); + await Assert.That(node.Asset).IsNull(); + await Assert.That(node.Patterns).IsNull(); + } + + [Test] + public async Task StaticWebAssetPattern_DefaultValues_ShouldBeCorrect(CancellationToken ct = default) { + // Arrange + + // Act + var pattern = new StaticWebAssetPattern(); + + // Assert + await Assert.That(pattern.ContentRootIndex).IsEqualTo(0); + await Assert.That(pattern.Pattern).IsEqualTo(string.Empty); + } + + [Test] + public async Task StaticWebAssetManifest_DefaultValues_ShouldBeCorrect(CancellationToken ct = default) { + // Arrange + + // Act + var manifest = new StaticWebAssetManifest(); + + // Assert + await Assert.That(manifest.ContentRoots).IsNull(); + await Assert.That(manifest.Root).IsNull(); + } + + [Test] + public async Task ScoredManifestCandidate_RecordEquality_ShouldWork(CancellationToken ct = default) { + // Arrange + var manifest = new StaticWebAssetManifest(); + var candidate1 = new ScoredManifestCandidate(manifest, 10, "/path1"); + var candidate2 = new ScoredManifestCandidate(manifest, 10, "/path1"); + var candidate3 = new ScoredManifestCandidate(manifest, 20, "/path2"); + + // Act & Assert + await Assert.That(candidate1).IsEqualTo(candidate2); + await Assert.That(candidate1).IsNotEqualTo(candidate3); + } + + [Test] + public async Task NodeTraversalState_RecordEquality_ShouldWork(CancellationToken ct = default) { + // Arrange + var node = new StaticWebAssetNode(); + var state1 = new NodeTraversalState(node, 3, "/prefix"); + var state2 = new NodeTraversalState(node, 3, "/prefix"); + var state3 = new NodeTraversalState(node, 5, "/other"); + + // Act & Assert + await Assert.That(state1).IsEqualTo(state2); + await Assert.That(state1).IsNotEqualTo(state3); + } + + [Test] + public async Task ManifestCandidate_RecordEquality_ShouldWork(CancellationToken ct = default) { + // Arrange + var candidate1 = new ManifestCandidate("/path", 10); + var candidate2 = new ManifestCandidate("/path", 10); + var candidate3 = new ManifestCandidate("/other", 20); + + // Act & Assert + await Assert.That(candidate1).IsEqualTo(candidate2); + await Assert.That(candidate1).IsNotEqualTo(candidate3); + } +} diff --git a/tests/InfiniTests.InfiniFrame.BlazorWebView/StaticWebAssetsManifestJsonContextTests.cs b/tests/InfiniTests.InfiniFrame.BlazorWebView/StaticWebAssetsManifestJsonContextTests.cs new file mode 100644 index 000000000..51b64265d --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.BlazorWebView/StaticWebAssetsManifestJsonContextTests.cs @@ -0,0 +1,62 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.BlazorWebView.FileProviders.Static; +using System.Text.Json; + +namespace InfiniTests.InfiniFrame.BlazorWebView; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class StaticWebAssetsManifestJsonContextTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task SerializeDeserialize_Manifest_ShouldRoundTrip(CancellationToken ct = default) { + // Arrange + var manifest = new StaticWebAssetManifest { + ContentRoots = ["/root1", "/root2"], + Root = new StaticWebAssetNode { + Children = new Dictionary { + ["sub"] = new() { + Asset = new StaticWebAsset { ContentRootIndex = 0, SubPath = "/sub/index.html" } + } + }, + Asset = new StaticWebAsset { ContentRootIndex = 1, SubPath = "/index.html" }, + Patterns = [new StaticWebAssetPattern { ContentRootIndex = 0, Pattern = "*.css" }] + } + }; + + // Act + string json = JsonSerializer.Serialize(manifest, StaticWebAssetsManifestJsonContext.Default.StaticWebAssetManifest); + StaticWebAssetManifest? deserialized = JsonSerializer.Deserialize(json, StaticWebAssetsManifestJsonContext.Default.StaticWebAssetManifest); + + // Assert + await Assert.That(deserialized).IsNotNull(); + await Assert.That(deserialized!.ContentRoots).IsNotNull(); + await Assert.That(deserialized.ContentRoots!.Length).IsEqualTo(2); + await Assert.That(deserialized.ContentRoots[0]).IsEqualTo("/root1"); + await Assert.That(deserialized.Root).IsNotNull(); + await Assert.That(deserialized.Root!.Asset).IsNotNull(); + await Assert.That(deserialized.Root.Asset!.SubPath).IsEqualTo("/index.html"); + await Assert.That(deserialized.Root.Children).IsNotNull(); + await Assert.That(deserialized.Root.Children!.Count).IsEqualTo(1); + } + + [Test] + public async Task SerializeDeserialize_EmptyManifest_ShouldRoundTrip(CancellationToken ct = default) { + // Arrange + var manifest = new StaticWebAssetManifest(); + + // Act + string json = JsonSerializer.Serialize(manifest, StaticWebAssetsManifestJsonContext.Default.StaticWebAssetManifest); + StaticWebAssetManifest? deserialized = JsonSerializer.Deserialize(json, StaticWebAssetsManifestJsonContext.Default.StaticWebAssetManifest); + + // Assert + await Assert.That(deserialized).IsNotNull(); + await Assert.That(deserialized!.ContentRoots).IsNull(); + await Assert.That(deserialized.Root).IsNull(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.NativeBridge/InfiniTests.InfiniFrame.NativeBridge.csproj b/tests/InfiniTests.InfiniFrame.NativeBridge/InfiniTests.InfiniFrame.NativeBridge.csproj index d04a1d97f..61980bf18 100644 --- a/tests/InfiniTests.InfiniFrame.NativeBridge/InfiniTests.InfiniFrame.NativeBridge.csproj +++ b/tests/InfiniTests.InfiniFrame.NativeBridge/InfiniTests.InfiniFrame.NativeBridge.csproj @@ -1,6 +1,7 @@  + diff --git a/tests/InfiniTests.InfiniFrame.NativeBridge/Managed/InfiniFrameNativeInteropExceptionTests.cs b/tests/InfiniTests.InfiniFrame.NativeBridge/Managed/InfiniFrameNativeInteropExceptionTests.cs new file mode 100644 index 000000000..645123aa2 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.NativeBridge/Managed/InfiniFrameNativeInteropExceptionTests.cs @@ -0,0 +1,44 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.NativeBridge; + +namespace InfiniTests.InfiniFrame.NativeBridge.Managed; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameNativeInteropExceptionTests { + + [Test] + public async Task ParameterlessConstructor_CreatesException(CancellationToken ct = default) { + // Arrange & Act + var ex = new InfiniFrameNativeInteropException(); + + // Assert + await Assert.That(ex).IsTypeOf(); + await Assert.That(ex).IsTypeOf(); + await Assert.That(ex.Message).IsNotNull(); + } + + [Test] + public async Task MessageConstructor_SetsMessage(CancellationToken ct = default) { + // Arrange & Act + var ex = new InfiniFrameNativeInteropException("test error"); + + // Assert + await Assert.That(ex.Message).IsEqualTo("test error"); + } + + [Test] + public async Task MessageAndInnerExceptionConstructor_SetsBoth(CancellationToken ct = default) { + // Arrange + var inner = new InvalidOperationException("inner"); + + // Act + var ex = new InfiniFrameNativeInteropException("outer", inner); + + // Assert + await Assert.That(ex.Message).IsEqualTo("outer"); + await Assert.That(ex.InnerException).IsSameReferenceAs(inner); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Enums/DebuggingEnumsTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Enums/DebuggingEnumsTests.cs new file mode 100644 index 000000000..b56ec5681 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Enums/DebuggingEnumsTests.cs @@ -0,0 +1,42 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Debugging; + +namespace InfiniTests.InfiniFrame.Shared.Enums; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class DebuggingEnumsTests { + + [Test] + public async Task InfiniFrameDebugEventKind_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + InfiniFrameDebugEventKind[] values = (InfiniFrameDebugEventKind[])Enum.GetValues(typeof(InfiniFrameDebugEventKind)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task InfiniFrameDebugEndpointStatus_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + InfiniFrameDebugEndpointStatus[] values = (InfiniFrameDebugEndpointStatus[])Enum.GetValues(typeof(InfiniFrameDebugEndpointStatus)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task InfiniFrameDebugEndpointStatus_HasExpectedValues(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(Enum.IsDefined(typeof(InfiniFrameDebugEndpointStatus), 0)).IsTrue(); + await Assert.That(Enum.IsDefined(typeof(InfiniFrameDebugEndpointStatus), 5)).IsTrue(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Enums/FeatureEnumsTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Enums/FeatureEnumsTests.cs new file mode 100644 index 000000000..d666ac27a --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Enums/FeatureEnumsTests.cs @@ -0,0 +1,95 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Enums; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class FeatureEnumsTests { + + [Test] + public async Task NavigationStatus_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + NavigationStatus[] values = (NavigationStatus[])Enum.GetValues(typeof(NavigationStatus)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task InfiniFrameDispatchResult_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + InfiniFrameDispatchResult[] values = (InfiniFrameDispatchResult[])Enum.GetValues(typeof(InfiniFrameDispatchResult)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task InfiniFrameMenuItemType_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + InfiniFrameMenuItemType[] values = (InfiniFrameMenuItemType[])Enum.GetValues(typeof(InfiniFrameMenuItemType)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task TaskbarProgressState_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + TaskbarProgressState[] values = (TaskbarProgressState[])Enum.GetValues(typeof(TaskbarProgressState)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task TaskbarFlashMode_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + TaskbarFlashMode[] values = (TaskbarFlashMode[])Enum.GetValues(typeof(TaskbarFlashMode)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task InfiniFrameNotificationUrgency_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + InfiniFrameNotificationUrgency[] values = (InfiniFrameNotificationUrgency[])Enum.GetValues(typeof(InfiniFrameNotificationUrgency)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + public async Task InfiniFrameNotificationResult_AllValuesDistinct(CancellationToken ct = default) { + // Arrange + InfiniFrameNotificationResult[] values = (InfiniFrameNotificationResult[])Enum.GetValues(typeof(InfiniFrameNotificationResult)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Enums/InfiniFrameWindowLifecycleStateTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Enums/InfiniFrameWindowLifecycleStateTests.cs new file mode 100644 index 000000000..84a7a36fa --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Enums/InfiniFrameWindowLifecycleStateTests.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Enums; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameWindowLifecycleStateTests { + + [Test] + public async Task AllValues_AreDistinct(CancellationToken ct = default) { + // Arrange + InfiniFrameWindowLifecycleState[] values = (InfiniFrameWindowLifecycleState[])Enum.GetValues(typeof(InfiniFrameWindowLifecycleState)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(9); + } + + [Test] + public async Task Creating_EqualsInitializing(CancellationToken ct = default) { + // Arrange + var creating = InfiniFrameWindowLifecycleState.Creating; + var initializing = InfiniFrameWindowLifecycleState.Initializing; + + // Act & Assert + await Assert.That(creating).IsEqualTo(initializing); + } + + [Test] + public async Task Ready_EqualsRunning(CancellationToken ct = default) { + // Arrange + var ready = InfiniFrameWindowLifecycleState.Ready; + var running = InfiniFrameWindowLifecycleState.Running; + + // Act & Assert + await Assert.That(ready).IsEqualTo(running); + } + + [Test] + public async Task CloseRequested_EqualsClosingRequested(CancellationToken ct = default) { + // Arrange + var closeRequested = InfiniFrameWindowLifecycleState.CloseRequested; + var closingRequested = InfiniFrameWindowLifecycleState.ClosingRequested; + + // Act & Assert + await Assert.That(closeRequested).IsEqualTo(closingRequested); + } + + [Test] + public async Task States_IncreaseInOrder(CancellationToken ct = default) { + // Arrange & Act + int created = (int)InfiniFrameWindowLifecycleState.Created; + int creating = (int)InfiniFrameWindowLifecycleState.Creating; + int ready = (int)InfiniFrameWindowLifecycleState.Ready; + int closeRequested = (int)InfiniFrameWindowLifecycleState.CloseRequested; + int nativeClosed = (int)InfiniFrameWindowLifecycleState.NativeClosed; + int teardownPending = (int)InfiniFrameWindowLifecycleState.TeardownPending; + int teardownComplete = (int)InfiniFrameWindowLifecycleState.TeardownComplete; + int nativeHandleReleased = (int)InfiniFrameWindowLifecycleState.NativeHandleReleased; + int disposed = (int)InfiniFrameWindowLifecycleState.Disposed; + + // Assert + await Assert.That(created).IsLessThan(creating); + await Assert.That(creating).IsLessThan(ready); + await Assert.That(ready).IsLessThan(closeRequested); + await Assert.That(closeRequested).IsLessThan(nativeClosed); + await Assert.That(nativeClosed).IsLessThan(teardownPending); + await Assert.That(teardownPending).IsLessThan(teardownComplete); + await Assert.That(teardownComplete).IsLessThan(nativeHandleReleased); + await Assert.That(nativeHandleReleased).IsLessThan(disposed); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Enums/NavigationStartingResultTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Enums/NavigationStartingResultTests.cs new file mode 100644 index 000000000..c2e041d66 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Enums/NavigationStartingResultTests.cs @@ -0,0 +1,23 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Enums; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class NavigationStartingResultTests { + + [Test] + public async Task AllValues_AreDistinct(CancellationToken ct = default) { + // Arrange + NavigationStartingResult[] values = (NavigationStartingResult[])Enum.GetValues(typeof(NavigationStartingResult)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Enums/ResizeOriginTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Enums/ResizeOriginTests.cs new file mode 100644 index 000000000..1998cfde7 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Enums/ResizeOriginTests.cs @@ -0,0 +1,43 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Enums; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class ResizeOriginTests { + + [Test] + public async Task AllValues_AreDistinct(CancellationToken ct = default) { + // Arrange + ResizeOrigin[] values = (ResizeOrigin[])Enum.GetValues(typeof(ResizeOrigin)); + + // Act + int distinctCount = values.Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } + + [Test] + [Arguments(ResizeOrigin.TopLeft)] + [Arguments(ResizeOrigin.Top)] + [Arguments(ResizeOrigin.TopRight)] + [Arguments(ResizeOrigin.Right)] + [Arguments(ResizeOrigin.BottomRight)] + [Arguments(ResizeOrigin.Bottom)] + [Arguments(ResizeOrigin.BottomLeft)] + [Arguments(ResizeOrigin.Left)] + public async Task Value_CanBeParsedFromString(ResizeOrigin value, CancellationToken ct = default) { + // Arrange + string name = value.ToString(); + + // Act + ResizeOrigin parsed = Enum.Parse(name); + + // Assert + await Assert.That(parsed).IsEqualTo(value); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Enums/WindowClosingResultTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Enums/WindowClosingResultTests.cs new file mode 100644 index 000000000..7b60c2880 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Enums/WindowClosingResultTests.cs @@ -0,0 +1,23 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Enums; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class WindowClosingResultTests { + + [Test] + public async Task AllValues_AreDistinct(CancellationToken ct = default) { + // Arrange + WindowClosingResult[] values = (WindowClosingResult[])Enum.GetValues(typeof(WindowClosingResult)); + + // Act + int distinctCount = values.Select(v => (int)v).Distinct().Count(); + + // Assert + await Assert.That(distinctCount).IsEqualTo(values.Length); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Events/FileDroppedEventTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Events/FileDroppedEventTests.cs index 10acb3b39..b4ca29bf2 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Events/FileDroppedEventTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Events/FileDroppedEventTests.cs @@ -3,7 +3,6 @@ // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; using InfiniFrame.DragDrop; -using NSubstitute; using System.Drawing; namespace InfiniTests.InfiniFrame.Shared.Events; @@ -16,7 +15,7 @@ public class FileDroppedEventTests { public async Task FileDropped_EventFires_WhenHandlerRegistered(CancellationToken ct = default) { // Arrange var eventsStore = new InfiniFrameEventsStore(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; FileDroppedEventArgs? receivedArgs = null; eventsStore.FileDropped.Add((_, args) => receivedArgs = args); @@ -38,7 +37,7 @@ public async Task FileDropped_EventFires_WhenHandlerRegistered(CancellationToken public async Task FileDropped_MultipleHandlers_AllInvoked(CancellationToken ct = default) { // Arrange var eventsStore = new InfiniFrameEventsStore(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; int handlerCount = 0; eventsStore.FileDropped.Add((_, _) => handlerCount++); @@ -57,7 +56,7 @@ public async Task FileDropped_MultipleHandlers_AllInvoked(CancellationToken ct = public async Task FileDropped_HandlerReceivesCorrectWindow(CancellationToken ct = default) { // Arrange var eventsStore = new InfiniFrameEventsStore(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; IInfiniFrameWindow? receivedWindow = null; eventsStore.FileDropped.Add((w, _) => receivedWindow = w); @@ -84,7 +83,7 @@ public async Task CopyTo_CopiesFileDroppedHandlers(CancellationToken ct = defaul source.CopyTo(target); var args = new FileDroppedEventArgs(["file.txt"], Point.Empty); - target.FileDropped.Invoke(Substitute.For(), args); + target.FileDropped.Invoke(MockFactory.CreateWindowMock().Object, args); // Assert await Assert.That(handlerCalled).IsTrue(); diff --git a/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedEventTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedEventTests.cs index 4ebe90236..d8c7c5373 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedEventTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedEventTests.cs @@ -2,7 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; namespace InfiniTests.InfiniFrame.Shared.Events; // --------------------------------------------------------------------------------------------------------------------- @@ -58,7 +57,7 @@ public async Task Add_SameKeyTwice_OverwritesPreviousHandlerAndCountRemainsOne(C await Assert.That(evt.Count).IsEqualTo(1); // Assert that Invoke calls the second handler, not the first - evt.TryInvoke("key", Substitute.For(), 0); + evt.TryInvoke("key", MockFactory.CreateWindowMock().Object, 0); await Assert.That(calls).IsEquivalentTo(["second"]); } @@ -136,7 +135,7 @@ public async Task ContainsKey_AfterRemove_ReturnsFalse(CancellationToken ct = de public async Task TryInvoke_MissingKey_ReturnsFalse(CancellationToken ct = default) { // Arrange var evt = new KeyedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; // Act bool result = evt.TryInvoke("absent", window, 0); @@ -149,7 +148,7 @@ public async Task TryInvoke_MissingKey_ReturnsFalse(CancellationToken ct = defau public async Task TryInvoke_ExistingKey_InvokesHandlerAndReturnsTrue(CancellationToken ct = default) { // Arrange var evt = new KeyedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; var calls = new List(); evt.Add("key", handler: (_, v) => calls.Add(v)); @@ -165,7 +164,7 @@ public async Task TryInvoke_ExistingKey_InvokesHandlerAndReturnsTrue(Cancellatio public async Task TryInvoke_PassesCorrectWindowToHandler(CancellationToken ct = default) { // Arrange var evt = new KeyedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; IInfiniFrameWindow? received = null; evt.Add("key", handler: (w, _) => received = w); @@ -180,7 +179,7 @@ public async Task TryInvoke_PassesCorrectWindowToHandler(CancellationToken ct = public async Task TryInvoke_HandlerThrowsRegularException_PropagatesException(CancellationToken ct = default) { // Arrange var evt = new KeyedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => throw new InvalidOperationException("boom")); // Act & Assert @@ -191,7 +190,7 @@ public async Task TryInvoke_HandlerThrowsRegularException_PropagatesException(Ca public async Task TryInvoke_HandlerThrowsOperationCanceledException_PropagatesException(CancellationToken ct = default) { // Arrange var evt = new KeyedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => throw new OperationCanceledException()); // Act & Assert @@ -202,7 +201,7 @@ public async Task TryInvoke_HandlerThrowsOperationCanceledException_PropagatesEx public async Task TryInvoke_AfterRemove_ReturnsFalse(CancellationToken ct = default) { // Arrange var evt = new KeyedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => { }); evt.Remove("key"); diff --git a/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedResultEventTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedResultEventTests.cs index 93867101d..14c34f380 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedResultEventTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Events/KeyedResultEventTests.cs @@ -2,7 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; namespace InfiniTests.InfiniFrame.Shared.Events; // --------------------------------------------------------------------------------------------------------------------- @@ -48,7 +47,7 @@ public async Task Add_NewKey_IncreasesCount(CancellationToken ct = default) { public async Task Add_SameKeyTwice_OverwritesPreviousHandlerAndCountRemainsOne(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => "first"); // Act @@ -136,7 +135,7 @@ public async Task ContainsKey_AfterRemove_ReturnsFalse(CancellationToken ct = de public async Task TryInvoke_MissingKey_ReturnsFalseAndResultIsDefault(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; // Act bool success = evt.TryInvoke("absent", window, 0, out string? result); @@ -150,7 +149,7 @@ public async Task TryInvoke_MissingKey_ReturnsFalseAndResultIsDefault(Cancellati public async Task TryInvoke_ExistingKey_ReturnsTrueAndResult(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, v) => $"value={v}"); // Act @@ -165,7 +164,7 @@ public async Task TryInvoke_ExistingKey_ReturnsTrueAndResult(CancellationToken c public async Task TryInvoke_PassesCorrectWindowAndPayloadToHandler(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; IInfiniFrameWindow? receivedWindow = null; string? receivedPayload = null; evt.Add("key", handler: (w, p) => { @@ -186,7 +185,7 @@ public async Task TryInvoke_PassesCorrectWindowAndPayloadToHandler(CancellationT public async Task TryInvoke_HandlerReturnsNull_ReturnsTrueWithNullResult(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => null!); // Act @@ -201,7 +200,7 @@ public async Task TryInvoke_HandlerReturnsNull_ReturnsTrueWithNullResult(Cancell public async Task TryInvoke_HandlerThrowsRegularException_PropagatesException(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => throw new InvalidOperationException("boom")); // Act & Assert @@ -212,7 +211,7 @@ public async Task TryInvoke_HandlerThrowsRegularException_PropagatesException(Ca public async Task TryInvoke_HandlerThrowsOperationCanceledException_PropagatesException(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => throw new OperationCanceledException()); // Act & Assert @@ -223,7 +222,7 @@ public async Task TryInvoke_HandlerThrowsOperationCanceledException_PropagatesEx public async Task TryInvoke_AfterRemove_ReturnsFalse(CancellationToken ct = default) { // Arrange var evt = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add("key", handler: (_, _) => "r"); evt.Remove("key"); diff --git a/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventTests.cs index 5016c34c5..edbe81170 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventTests.cs @@ -3,7 +3,6 @@ // --------------------------------------------------------------------------------------------------------------------- using System.Collections.Immutable; using InfiniFrame; -using NSubstitute; namespace InfiniTests.InfiniFrame.Shared.Events; // --------------------------------------------------------------------------------------------------------------------- @@ -93,7 +92,7 @@ public async Task Remove_HandlerNotRegistered_DoesNotThrow(CancellationToken ct public async Task Invoke_NoHandlers_DoesNotThrow(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; // Act & Assert await Assert.That(() => orderedEvent.Invoke(window)).ThrowsNothing(); @@ -103,7 +102,7 @@ public async Task Invoke_NoHandlers_DoesNotThrow(CancellationToken ct = default) public async Task Invoke_SingleHandler_PassesWindowToHandler(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; IInfiniFrameWindow? received = null; orderedEvent.Add(w => received = w); @@ -118,7 +117,7 @@ public async Task Invoke_SingleHandler_PassesWindowToHandler(CancellationToken c public async Task Invoke_MultipleHandlers_InvokesInRegistrationOrder(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; var calls = new List(); orderedEvent.Add(_ => calls.Add(1)); @@ -136,7 +135,7 @@ public async Task Invoke_MultipleHandlers_InvokesInRegistrationOrder(Cancellatio public async Task Invoke_AfterRemove_DoesNotCallRemovedHandler(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; var calls = new List(); Action first = _ => calls.Add(1); Action second = _ => calls.Add(2); @@ -156,7 +155,7 @@ public async Task Invoke_AfterRemove_DoesNotCallRemovedHandler(CancellationToken public async Task Invoke_HandlerThrowsException_PropagatesException(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; orderedEvent.Add(_ => throw new InvalidOperationException("boom")); // Act & Assert, OrderedEvent.Invoke does not swallow exceptions diff --git a/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventWithPayloadTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventWithPayloadTests.cs index 5721faed9..21ce5b725 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventWithPayloadTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedEventWithPayloadTests.cs @@ -2,7 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; using System.Collections.Immutable; namespace InfiniTests.InfiniFrame.Shared.Events; @@ -56,7 +55,7 @@ public async Task Remove_RegisteredHandler_ReducesSnapshotCount(CancellationToke public async Task Invoke_SingleHandler_PassesWindowAndPayloadToHandler(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; IInfiniFrameWindow? receivedWindow = null; string? receivedPayload = null; @@ -77,7 +76,7 @@ public async Task Invoke_SingleHandler_PassesWindowAndPayloadToHandler(Cancellat public async Task Invoke_MultipleHandlers_AllReceivePayloadInOrder(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; var calls = new List(); orderedEvent.Add((_, v) => calls.Add(v)); @@ -94,7 +93,7 @@ public async Task Invoke_MultipleHandlers_AllReceivePayloadInOrder(CancellationT public async Task Invoke_AfterRemove_DoesNotCallRemovedHandler(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; var calls = new List(); Action removed = (_, _) => calls.Add(99); orderedEvent.Add(removed); @@ -112,7 +111,7 @@ public async Task Invoke_AfterRemove_DoesNotCallRemovedHandler(CancellationToken public async Task Invoke_HandlerThrowsException_PropagatesException(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; orderedEvent.Add((_, _) => throw new InvalidOperationException("boom")); // Act & Assert @@ -135,34 +134,34 @@ public async Task AddWithServiceResolving_NullHandler_ThrowsArgumentNullExceptio public async Task AddWithServiceResolving_WindowHasNullServiceProvider_ThrowsInvalidOperationException(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); window.ServiceProvider.Returns((IServiceProvider?)null); orderedEvent.AddWithServiceResolving((_, _, _) => { }); // Act & Assert - await Assert.That(() => orderedEvent.Invoke(window, 0)).Throws(); + await Assert.That(() => orderedEvent.Invoke(window.Object, 0)).Throws(); } [Test] public async Task AddWithServiceResolving_WithProvider_ResolvesServiceAndCallsHandler(CancellationToken ct = default) { // Arrange var orderedEvent = new OrderedEvent(); - var window = Substitute.For(); - var provider = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); + Mock provider = MockFactory.CreateServiceProviderMock(); - var fakeDisposable = Substitute.For(); - provider.GetService(typeof(IDisposable)).Returns(fakeDisposable); - window.ServiceProvider.Returns(provider); + Mock fakeDisposable = MockFactory.CreateDisposableMock(); + provider.GetService(typeof(IDisposable)).Returns(fakeDisposable.Object); + window.ServiceProvider.Returns(provider.Object); IDisposable? resolvedService = null; orderedEvent.AddWithServiceResolving((_, _, svc) => resolvedService = svc); // Act - orderedEvent.Invoke(window, 42); + orderedEvent.Invoke(window.Object, 42); // Assert - await Assert.That(resolvedService).IsEqualTo(fakeDisposable); + await Assert.That(resolvedService).IsEqualTo(fakeDisposable.Object); } // ----------------------------------------------------------------------------------------------------------------- @@ -182,4 +181,4 @@ public async Task Snapshot_IsImmutable_SubsequentAddDoesNotAffectCapturedSnapsho await Assert.That(snapshot.Length).IsEqualTo(1); await Assert.That(orderedEvent.Snapshot.Length).IsEqualTo(2); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedResultEventTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedResultEventTests.cs index 4df207fca..d7efc477d 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedResultEventTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Events/OrderedResultEventTests.cs @@ -2,7 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; using System.Collections.Immutable; namespace InfiniTests.InfiniFrame.Shared.Events; @@ -68,7 +67,7 @@ public async Task Remove_RegisteredHandler_ReducesSnapshotCount(CancellationToke public async Task Invoke_NoHandlers_ReturnsEmptyArray(CancellationToken ct = default) { // Arrange var evt = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; // Act string?[] result = evt.Invoke(window, 0); @@ -81,7 +80,7 @@ public async Task Invoke_NoHandlers_ReturnsEmptyArray(CancellationToken ct = def public async Task Invoke_SingleHandler_ReturnsResultInArray(CancellationToken ct = default) { // Arrange var evt = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add((_, v) => $"value={v}"); // Act @@ -96,7 +95,7 @@ public async Task Invoke_SingleHandler_ReturnsResultInArray(CancellationToken ct public async Task Invoke_MultipleHandlers_ReturnsAllResultsInRegistrationOrder(CancellationToken ct = default) { // Arrange var evt = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add((_, _) => "first"); evt.Add((_, _) => "second"); evt.Add((_, _) => "third"); @@ -114,7 +113,7 @@ public async Task Invoke_MultipleHandlers_ReturnsAllResultsInRegistrationOrder(C public async Task Invoke_HandlerThrowsRegularException_PropagatesAndStopsDispatch(CancellationToken ct = default) { // Arrange var evt = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add((_, _) => "before"); evt.Add((_, _) => throw new InvalidOperationException("boom")); evt.Add((_, _) => "after"); @@ -127,7 +126,7 @@ public async Task Invoke_HandlerThrowsRegularException_PropagatesAndStopsDispatc public async Task Invoke_HandlerThrowsOperationCanceledException_PropagatesException(CancellationToken ct = default) { // Arrange var evt = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; evt.Add((_, _) => throw new OperationCanceledException()); // Act & Assert @@ -138,7 +137,7 @@ public async Task Invoke_HandlerThrowsOperationCanceledException_PropagatesExcep public async Task Invoke_AfterRemove_DoesNotIncludeRemovedHandlerResult(CancellationToken ct = default) { // Arrange var evt = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; Func removed = (_, _) => "removed"; evt.Add(removed); evt.Add((_, _) => "kept"); @@ -156,7 +155,7 @@ public async Task Invoke_AfterRemove_DoesNotIncludeRemovedHandlerResult(Cancella public async Task Invoke_PassesWindowAndPayloadToEachHandler(CancellationToken ct = default) { // Arrange var evt = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; IInfiniFrameWindow? receivedWindow = null; string? receivedPayload = null; evt.Add((w, p) => { diff --git a/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropExtensionMethodTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropExtensionMethodTests.cs index ab77d55cc..50983c07e 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropExtensionMethodTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropExtensionMethodTests.cs @@ -2,7 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; namespace InfiniTests.InfiniFrame.Shared.Features; // --------------------------------------------------------------------------------------------------------------------- @@ -13,63 +12,65 @@ public class DragDropExtensionMethodTests { [Test] public async Task EnableDragDrop_CallsSetEnabled(CancellationToken ct = default) { // Arrange - var window = Substitute.For(); - var feature = Substitute.For(); - window.Features.DragDrop.Returns(feature); + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock feature = MockFactory.CreateDragDropMock(); + window.Features.Returns(features.Object); + features.DragDrop.Returns(feature.Object); // Act - IInfiniFrameWindow result = window.EnableDragDrop(); + IInfiniFrameWindow result = window.Object.EnableDragDrop(); // Assert - feature.Received(1).SetEnabled(true); - await Assert.That(result).IsEqualTo(window); + await Assert.That(result).IsSameReferenceAs(window.Object); } [Test] public async Task EnableDragDrop_WithExtensions_SetsEnabledAndExtensions(CancellationToken ct = default) { // Arrange - var window = Substitute.For(); - var feature = Substitute.For(); - window.Features.DragDrop.Returns(feature); + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock feature = MockFactory.CreateDragDropMock(); + window.Features.Returns(features.Object); + features.DragDrop.Returns(feature.Object); // Act - IInfiniFrameWindow result = window.EnableDragDrop(".txt", ".png"); + IInfiniFrameWindow result = window.Object.EnableDragDrop(".txt", ".png"); // Assert - feature.Received(1).SetEnabled(true); - feature.Received(1).SetAllowedExtensions(Arg.Is(e => e != null && e.Length == 2 && e[0] == ".txt" && e[1] == ".png")); - await Assert.That(result).IsEqualTo(window); + await Assert.That(result).IsSameReferenceAs(window.Object); } [Test] public async Task DisableDragDrop_CallsSetEnabledFalse(CancellationToken ct = default) { // Arrange - var window = Substitute.For(); - var feature = Substitute.For(); - window.Features.DragDrop.Returns(feature); + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock feature = MockFactory.CreateDragDropMock(); + window.Features.Returns(features.Object); + features.DragDrop.Returns(feature.Object); // Act - IInfiniFrameWindow result = window.DisableDragDrop(); + IInfiniFrameWindow result = window.Object.DisableDragDrop(); // Assert - feature.Received(1).SetEnabled(false); - await Assert.That(result).IsEqualTo(window); + await Assert.That(result).IsSameReferenceAs(window.Object); } [Test] public async Task OnFileDropped_RegistersHandlerOnEventsStore(CancellationToken ct = default) { // Arrange - var window = Substitute.For(); - var events = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); + Mock events = MockFactory.CreateEventsMock(); var eventsStore = new InfiniFrameEventsStore(); - window.Events.Returns(events); + window.Events.Returns(events.Object); events.EventsStore.Returns(eventsStore); // Act - IInfiniFrameWindow result = window.OnFileDropped((_, _) => { }); + IInfiniFrameWindow result = window.Object.OnFileDropped((_, _) => { }); // Assert await Assert.That(eventsStore.FileDropped.Snapshot.Length).IsEqualTo(1); - await Assert.That(result).IsEqualTo(window); + await Assert.That(result).IsSameReferenceAs(window.Object); } } diff --git a/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropFeatureTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropFeatureTests.cs index fe1cf795f..6265b1640 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropFeatureTests.cs +++ b/tests/InfiniTests.InfiniFrame.Shared/Features/DragDropFeatureTests.cs @@ -2,7 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; namespace InfiniTests.InfiniFrame.Shared.Features; // --------------------------------------------------------------------------------------------------------------------- @@ -13,60 +12,60 @@ public class DragDropFeatureTests { [Test] public async Task EnableDragDrop_SetsEnabledTrue(CancellationToken ct = default) { // Arrange - var feature = Substitute.For(); + Mock feature = MockFactory.CreateDragDropMock(); // Act - feature.SetEnabled(true); + feature.Object.SetEnabled(true); // Assert - feature.Received(1).SetEnabled(true); + feature.SetEnabled(true).WasCalled(Times.Once); } [Test] public async Task DisableDragDrop_SetsEnabledFalse(CancellationToken ct = default) { // Arrange - var feature = Substitute.For(); + Mock feature = MockFactory.CreateDragDropMock(); // Act - feature.SetEnabled(false); + feature.Object.SetEnabled(false); // Assert - feature.Received(1).SetEnabled(false); + feature.SetEnabled(false).WasCalled(Times.Once); } [Test] public async Task SetAllowedExtensions_StoresExtensions(CancellationToken ct = default) { // Arrange - var feature = Substitute.For(); + Mock feature = MockFactory.CreateDragDropMock(); string[] extensions = new[] { ".txt", ".png" }; // Act - feature.SetAllowedExtensions(extensions); + feature.Object.SetAllowedExtensions(extensions); // Assert - feature.Received(1).SetAllowedExtensions(extensions); + feature.SetAllowedExtensions(extensions).WasCalled(Times.Once); } [Test] public async Task IsEnabled_ReturnsCurrentState(CancellationToken ct = default) { // Arrange - var feature = Substitute.For(); + Mock feature = MockFactory.CreateDragDropMock(); feature.IsEnabled.Returns(true); // Act & Assert - await Assert.That(feature.IsEnabled).IsTrue(); + await Assert.That(feature.Object.IsEnabled).IsTrue(); } [Test] public async Task AllowedExtensions_ReturnsConfiguredExtensions(CancellationToken ct = default) { // Arrange - var feature = Substitute.For(); + Mock feature = MockFactory.CreateDragDropMock(); var extensions = new List { ".txt", ".pdf" }; feature.AllowedExtensions.Returns(extensions.AsReadOnly()); // Act & Assert - await Assert.That(feature.AllowedExtensions.Count).IsEqualTo(2); - await Assert.That(feature.AllowedExtensions[0]).IsEqualTo(".txt"); - await Assert.That(feature.AllowedExtensions[1]).IsEqualTo(".pdf"); + await Assert.That(feature.Object.AllowedExtensions.Count).IsEqualTo(2); + await Assert.That(feature.Object.AllowedExtensions[0]).IsEqualTo(".txt"); + await Assert.That(feature.Object.AllowedExtensions[1]).IsEqualTo(".pdf"); } } diff --git a/tests/InfiniTests.InfiniFrame.Shared/InfiniTests.InfiniFrame.Shared.csproj b/tests/InfiniTests.InfiniFrame.Shared/InfiniTests.InfiniFrame.Shared.csproj index d876514ad..505c66437 100644 --- a/tests/InfiniTests.InfiniFrame.Shared/InfiniTests.InfiniFrame.Shared.csproj +++ b/tests/InfiniTests.InfiniFrame.Shared/InfiniTests.InfiniFrame.Shared.csproj @@ -1,7 +1,10 @@ - + + + $(NoWarn);CS0105 + - + diff --git a/tests/InfiniTests.InfiniFrame.Shared/Interop/InteropEnvelopeParseResultTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Interop/InteropEnvelopeParseResultTests.cs new file mode 100644 index 000000000..4fc4b401b --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Interop/InteropEnvelopeParseResultTests.cs @@ -0,0 +1,81 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Interop; + +namespace InfiniTests.InfiniFrame.Shared.Interop; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InteropEnvelopeParseResultTests { + + [Test] + public async Task CreateSuccess_SetsSuccessState(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeParseResult.CreateSuccess( + "msg-1", "data", "Post", "req-1" + ); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.IsFailure).IsFalse(); + await Assert.That(result.IsIgnored).IsFalse(); + await Assert.That(result.IsBlazor).IsFalse(); + await Assert.That(result.MessageId).IsEqualTo("msg-1"); + await Assert.That(result.Payload).IsEqualTo("data"); + await Assert.That(result.Command).IsEqualTo("Post"); + await Assert.That(result.RequestId).IsEqualTo("req-1"); + await Assert.That(result.Error).IsNull(); + } + + [Test] + public async Task CreateSuccess_NullOptionalFields(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeParseResult.CreateSuccess("msg-1", null); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Payload).IsNull(); + await Assert.That(result.Command).IsNull(); + await Assert.That(result.RequestId).IsNull(); + } + + [Test] + public async Task CreateFailure_SetsFailureState(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeParseResult.CreateFailure("something went wrong"); + + // Assert + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsIgnored).IsFalse(); + await Assert.That(result.IsBlazor).IsFalse(); + await Assert.That(result.Error).IsEqualTo("something went wrong"); + await Assert.That(result.MessageId).IsNull(); + await Assert.That(result.Payload).IsNull(); + } + + [Test] + public async Task Ignored_HasCorrectState(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeParseResult.Ignored; + + // Assert + await Assert.That(result.IsIgnored).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsFalse(); + await Assert.That(result.IsBlazor).IsFalse(); + } + + [Test] + public async Task BlazorMessage_HasCorrectState(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeParseResult.BlazorMessage; + + // Assert + await Assert.That(result.IsBlazor).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.IsFailure).IsFalse(); + await Assert.That(result.IsIgnored).IsFalse(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameDebugCapabilitiesTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameDebugCapabilitiesTests.cs new file mode 100644 index 000000000..a8258fe20 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameDebugCapabilitiesTests.cs @@ -0,0 +1,52 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Debugging; + +namespace InfiniTests.InfiniFrame.Shared.Records; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameDebugCapabilitiesTests { + + [Test] + public async Task Record_CanBeConstructed(CancellationToken ct = default) { + // Arrange & Act + var capabilities = new InfiniFrameDebugCapabilities { + SupportsLocalDevTools = true, + SupportsRemoteDebuggingEndpoint = false, + SupportsWebInspectorAttach = true, + SupportsScriptErrorForwarding = false, + SupportsNavigationDiagnostics = true + }; + + // Assert + await Assert.That(capabilities.SupportsLocalDevTools).IsTrue(); + await Assert.That(capabilities.SupportsRemoteDebuggingEndpoint).IsFalse(); + await Assert.That(capabilities.SupportsWebInspectorAttach).IsTrue(); + await Assert.That(capabilities.SupportsScriptErrorForwarding).IsFalse(); + await Assert.That(capabilities.SupportsNavigationDiagnostics).IsTrue(); + } + + [Test] + public async Task Equality_SameValues_ReturnsTrue(CancellationToken ct = default) { + // Arrange + var caps1 = new InfiniFrameDebugCapabilities { + SupportsLocalDevTools = true, + SupportsRemoteDebuggingEndpoint = false, + SupportsWebInspectorAttach = true, + SupportsScriptErrorForwarding = false, + SupportsNavigationDiagnostics = true + }; + var caps2 = new InfiniFrameDebugCapabilities { + SupportsLocalDevTools = true, + SupportsRemoteDebuggingEndpoint = false, + SupportsWebInspectorAttach = true, + SupportsScriptErrorForwarding = false, + SupportsNavigationDiagnostics = true + }; + + // Act & Assert + await Assert.That(caps1).IsEqualTo(caps2); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameDebugEventArgsTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameDebugEventArgsTests.cs new file mode 100644 index 000000000..306784c78 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameDebugEventArgsTests.cs @@ -0,0 +1,62 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Debugging; + +namespace InfiniTests.InfiniFrame.Shared.Records; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameDebugEventArgsTests { + + [Test] + public async Task Constructor_RequiredProperties_SetsValues(CancellationToken ct = default) { + // Arrange & Act + DateTime timestamp = DateTime.UtcNow; + var args = new InfiniFrameDebugEventArgs { + Kind = InfiniFrameDebugEventKind.ScriptError, + TimestampUtc = timestamp + }; + + // Assert + await Assert.That(args.Kind).IsEqualTo(InfiniFrameDebugEventKind.ScriptError); + await Assert.That(args.TimestampUtc).IsEqualTo(timestamp); + } + + [Test] + public async Task OptionalProperties_DefaultToNull(CancellationToken ct = default) { + // Arrange & Act + var args = new InfiniFrameDebugEventArgs { + Kind = InfiniFrameDebugEventKind.ScriptError, + TimestampUtc = DateTime.UtcNow + }; + + // Assert + await Assert.That(args.Message).IsNull(); + await Assert.That(args.Level).IsNull(); + await Assert.That(args.Uri).IsNull(); + await Assert.That(args.StatusCode).IsNull(); + await Assert.That(args.PlatformPayload).IsNull(); + } + + [Test] + public async Task OptionalProperties_CanBeSet(CancellationToken ct = default) { + // Arrange & Act + var args = new InfiniFrameDebugEventArgs { + Kind = InfiniFrameDebugEventKind.Navigation, + TimestampUtc = DateTime.UtcNow, + Message = "test message", + Level = "error", + Uri = "https://example.com", + StatusCode = 404, + PlatformPayload = "extra data" + }; + + // Assert + await Assert.That(args.Message).IsEqualTo("test message"); + await Assert.That(args.Level).IsEqualTo("error"); + await Assert.That(args.Uri).IsEqualTo("https://example.com"); + await Assert.That(args.StatusCode).IsEqualTo(404); + await Assert.That(args.PlatformPayload).IsEqualTo("extra data"); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameMenuBarTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameMenuBarTests.cs new file mode 100644 index 000000000..25f0576d7 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameMenuBarTests.cs @@ -0,0 +1,53 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Collections.Immutable; +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Records; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameMenuBarTests { + + [Test] + public async Task DefaultConstructor_CreatesEmptyMenuBar(CancellationToken ct = default) { + // Arrange & Act + var menuBar = new InfiniFrameMenuBar(); + + // Assert + await Assert.That(menuBar.Items).IsEmpty(); + } + + [Test] + public async Task Constructor_WithItems_SetsItems(CancellationToken ct = default) { + // Arrange + var item = new InfiniFrameMenuItem(Id: "menu-1", Label: "Menu 1"); + + // Act + var menuBar = new InfiniFrameMenuBar(ImmutableArray.Create(item)); + + // Assert + await Assert.That(menuBar.Items.Length).IsEqualTo(1); + await Assert.That(menuBar.Items[0].Id).IsEqualTo("menu-1"); + } + + [Test] + public async Task DefaultImmutableArray_IsHandledCorrectly(CancellationToken ct = default) { + // Arrange, passing default(ImmutableArray<...>) should result in empty + var menuBar = new InfiniFrameMenuBar(default); + + // Act & Assert + await Assert.That(menuBar.Items).IsEmpty(); + } + + [Test] + public async Task Equality_SameValues_ReturnsTrue(CancellationToken ct = default) { + // Arrange + var bar1 = new InfiniFrameMenuBar(); + var bar2 = new InfiniFrameMenuBar(); + + // Act & Assert + await Assert.That(bar1).IsEqualTo(bar2); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameMenuItemTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameMenuItemTests.cs new file mode 100644 index 000000000..4f77fdb1a --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameMenuItemTests.cs @@ -0,0 +1,101 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Collections.Immutable; +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Records; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameMenuItemTests { + + [Test] + public async Task DefaultConstructor_SetsEmptyId(CancellationToken ct = default) { + // Arrange & Act + var item = new InfiniFrameMenuItem(); + + // Assert + await Assert.That(item.Id).IsEqualTo(string.Empty); + await Assert.That(item.Label).IsNull(); + await Assert.That(item.Type).IsEqualTo(InfiniFrameMenuItemType.Normal); + await Assert.That(item.IsEnabled).IsTrue(); + await Assert.That(item.IsVisible).IsTrue(); + await Assert.That(item.KeyboardShortcut).IsNull(); + await Assert.That(item.Children).IsEmpty(); + } + + [Test] + public async Task ParameterizedConstructor_SetsValues(CancellationToken ct = default) { + // Arrange & Act + var item = new InfiniFrameMenuItem( + Id: "menu-file", + Label: "File", + Type: InfiniFrameMenuItemType.Submenu, + IsEnabled: true, + IsVisible: true, + KeyboardShortcut: "Ctrl+F" + ); + + // Assert + await Assert.That(item.Id).IsEqualTo("menu-file"); + await Assert.That(item.Label).IsEqualTo("File"); + await Assert.That(item.Type).IsEqualTo(InfiniFrameMenuItemType.Submenu); + await Assert.That(item.IsEnabled).IsTrue(); + await Assert.That(item.IsVisible).IsTrue(); + await Assert.That(item.KeyboardShortcut).IsEqualTo("Ctrl+F"); + } + + [Test] + public async Task Children_DefaultValue_IsEmptyArray(CancellationToken ct = default) { + // Arrange + var item = new InfiniFrameMenuItem( + Id: "test", + Label: "Test" + ); + + // Act & Assert + await Assert.That(item.Children).IsEmpty(); + } + + [Test] + public async Task Children_CanBeSetToNonEmptyArray(CancellationToken ct = default) { + // Arrange + var child = new InfiniFrameMenuItem(Id: "child-1", Label: "Child 1"); + + // Act + var item = new InfiniFrameMenuItem( + Id: "parent", + Label: "Parent", + Type: InfiniFrameMenuItemType.Submenu, + Children: ImmutableArray.Create(child) + ); + + // Assert + await Assert.That(item.Children.Length).IsEqualTo(1); + await Assert.That(item.Children[0].Id).IsEqualTo("child-1"); + } + + [Test] + public async Task Equality_SameValues_ReturnsTrue(CancellationToken ct = default) { + // Arrange + var item1 = new InfiniFrameMenuItem(Id: "test", Label: "Test"); + var item2 = new InfiniFrameMenuItem(Id: "test", Label: "Test"); + + // Act & Assert + await Assert.That(item1).IsEqualTo(item2); + } + + [Test] + public async Task WithExpression_CreatesNewInstance(CancellationToken ct = default) { + // Arrange + var original = new InfiniFrameMenuItem(Id: "test", Label: "Test"); + + // Act + InfiniFrameMenuItem modified = original with { Label = "Modified" }; + + // Assert + await Assert.That(modified.Label).IsEqualTo("Modified"); + await Assert.That(modified.Id).IsEqualTo("test"); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameTaskbarCapabilitiesTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameTaskbarCapabilitiesTests.cs new file mode 100644 index 000000000..9eed13755 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameTaskbarCapabilitiesTests.cs @@ -0,0 +1,44 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Records; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameTaskbarCapabilitiesTests { + + [Test] + public async Task Record_CanBeConstructed(CancellationToken ct = default) { + // Arrange & Act + var caps = new InfiniFrameTaskbarCapabilities { + SupportsProgress = true, + SupportsFlash = false + }; + + // Assert + await Assert.That(caps.SupportsProgress).IsTrue(); + await Assert.That(caps.SupportsFlash).IsFalse(); + } + + [Test] + public async Task Equality_SameValues_ReturnsTrue(CancellationToken ct = default) { + // Arrange + var caps1 = new InfiniFrameTaskbarCapabilities { SupportsProgress = true, SupportsFlash = true }; + var caps2 = new InfiniFrameTaskbarCapabilities { SupportsProgress = true, SupportsFlash = true }; + + // Act & Assert + await Assert.That(caps1).IsEqualTo(caps2); + } + + [Test] + public async Task Equality_DifferentValues_ReturnsFalse(CancellationToken ct = default) { + // Arrange + var caps1 = new InfiniFrameTaskbarCapabilities { SupportsProgress = true, SupportsFlash = false }; + var caps2 = new InfiniFrameTaskbarCapabilities { SupportsProgress = false, SupportsFlash = false }; + + // Act & Assert + await Assert.That(caps1).IsNotEqualTo(caps2); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameWebMessageReceivedEventTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameWebMessageReceivedEventTests.cs new file mode 100644 index 000000000..95ec0ef8f --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Records/InfiniFrameWebMessageReceivedEventTests.cs @@ -0,0 +1,34 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Records; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameWebMessageReceivedEventTests { + + [Test] + public async Task Record_CanBeConstructed(CancellationToken ct = default) { + // Arrange & Act + var evt = new InfiniFrameWebMessageReceivedEvent( + Message: "hello", + Origin: "https://example.com" + ); + + // Assert + await Assert.That(evt.Message).IsEqualTo("hello"); + await Assert.That(evt.Origin).IsEqualTo("https://example.com"); + } + + [Test] + public async Task Equality_SameValues_ReturnsTrue(CancellationToken ct = default) { + // Arrange + var evt1 = new InfiniFrameWebMessageReceivedEvent("msg", "https://example.com"); + var evt2 = new InfiniFrameWebMessageReceivedEvent("msg", "https://example.com"); + + // Act & Assert + await Assert.That(evt1).IsEqualTo(evt2); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Records/NavigationStartingEventArgsTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Records/NavigationStartingEventArgsTests.cs new file mode 100644 index 000000000..a05297b74 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Records/NavigationStartingEventArgsTests.cs @@ -0,0 +1,62 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Shared.Records; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class NavigationStartingEventArgsTests { + + [Test] + public async Task Constructor_SetsAllProperties(CancellationToken ct = default) { + // Arrange & Act + var args = new NavigationStartingEventArgs( + Url: "https://example.com", + IsUserInitiated: true, + IsRedirect: false, + IsMainFrame: true + ); + + // Assert + await Assert.That(args.Url).IsEqualTo("https://example.com"); + await Assert.That(args.IsUserInitiated).IsTrue(); + await Assert.That(args.IsRedirect).IsFalse(); + await Assert.That(args.IsMainFrame).IsTrue(); + } + + [Test] + public async Task Equality_SameValues_ReturnsTrue(CancellationToken ct = default) { + // Arrange + var args1 = new NavigationStartingEventArgs("https://example.com", true, false, true); + var args2 = new NavigationStartingEventArgs("https://example.com", true, false, true); + + // Act & Assert + await Assert.That(args1).IsEqualTo(args2); + } + + [Test] + public async Task Equality_DifferentValues_ReturnsFalse(CancellationToken ct = default) { + // Arrange + var args1 = new NavigationStartingEventArgs("https://example.com", true, false, true); + var args2 = new NavigationStartingEventArgs("https://other.com", true, false, true); + + // Act & Assert + await Assert.That(args1).IsNotEqualTo(args2); + } + + [Test] + public async Task WithExpression_CreatesNewInstance(CancellationToken ct = default) { + // Arrange + var original = new NavigationStartingEventArgs("https://example.com", true, false, true); + + // Act + NavigationStartingEventArgs modified = original with { Url = "https://modified.com" }; + + // Assert + await Assert.That(modified.Url).IsEqualTo("https://modified.com"); + await Assert.That(modified.IsUserInitiated).IsTrue(); + await Assert.That(original.Url).IsEqualTo("https://example.com"); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Shared/Utilities/RemoteDebuggingUtilityTests.cs b/tests/InfiniTests.InfiniFrame.Shared/Utilities/RemoteDebuggingUtilityTests.cs new file mode 100644 index 000000000..bb260d703 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame.Shared/Utilities/RemoteDebuggingUtilityTests.cs @@ -0,0 +1,134 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Utilities; + +namespace InfiniTests.InfiniFrame.Shared.Utilities; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class RemoteDebuggingUtilityTests { + + // ----------------------------------------------------------------------------------------------------------------- + // NormalizePort + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task NormalizePort_Zero_ReturnsZero(CancellationToken ct = default) { + // Arrange & Act + int result = RemoteDebuggingUtility.NormalizePort(0); + + // Assert + await Assert.That(result).IsEqualTo(0); + } + + [Test] + [Arguments(1)] + [Arguments(8080)] + [Arguments(65535)] + public async Task NormalizePort_ValidPort_ReturnsSameValue(int port, CancellationToken ct = default) { + // Arrange & Act + int result = RemoteDebuggingUtility.NormalizePort(port); + + // Assert + await Assert.That(result).IsEqualTo(port); + } + + [Test] + [Arguments(-1)] + [Arguments(65536)] + [Arguments(int.MaxValue)] + public async Task NormalizePort_InvalidPort_ThrowsArgumentOutOfRangeException(int port, CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(() => RemoteDebuggingUtility.NormalizePort(port)) + .Throws(); + } + + [Test] + public async Task NormalizePort_InvalidPort_ExceptionContainsParameterName(CancellationToken ct = default) { + // Arrange & Act + ArgumentOutOfRangeException? ex = await Assert.ThrowsAsync( + () => Task.Run(() => RemoteDebuggingUtility.NormalizePort(-1, "myPort")) + ); + + // Assert + await Assert.That(ex).IsNotNull(); + await Assert.That(ex!.ParamName).IsEqualTo("myPort"); + } + + // ----------------------------------------------------------------------------------------------------------------- + // CreateEndpointUri + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task CreateEndpointUri_ReturnsLoopbackUri(CancellationToken ct = default) { + // Arrange & Act + Uri uri = RemoteDebuggingUtility.CreateEndpointUri(9222); + + // Assert + await Assert.That(uri.Host).IsEqualTo("127.0.0.1"); + await Assert.That(uri.Port).IsEqualTo(9222); + await Assert.That(uri.Scheme).IsEqualTo("http"); + } + + // ----------------------------------------------------------------------------------------------------------------- + // ComposeBrowserControlInitParameters + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task ComposeBrowserControlInitParameters_PortZero_ReturnsSanitizedNull(CancellationToken ct = default) { + // Arrange & Act + string? result = RemoteDebuggingUtility.ComposeBrowserControlInitParameters(null, 0); + + // Assert + await Assert.That(result).IsNull(); + } + + [Test] + public async Task ComposeBrowserControlInitParameters_PortZero_StripsExistingSwitches(CancellationToken ct = default) { + // Arrange + string raw = "--remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --other-flag"; + + // Act + string? result = RemoteDebuggingUtility.ComposeBrowserControlInitParameters(raw, 0); + + // Assert + await Assert.That(result).Contains("--other-flag"); + await Assert.That(result).DoesNotContain("--remote-debugging-port"); + await Assert.That(result).DoesNotContain("--remote-debugging-address"); + } + + [Test] + public async Task ComposeBrowserControlInitParameters_NullRaw_ReturnsNull_OnNonWindows(CancellationToken ct = default) { + if (OperatingSystem.IsWindows()) return; + + // Arrange & Act + string? result = RemoteDebuggingUtility.ComposeBrowserControlInitParameters(null, 9222); + + // Assert + await Assert.That(result).IsNull(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // EnsureSupportedPlatform + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task EnsureSupportedPlatform_Zero_DoesNotThrow(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(() => RemoteDebuggingUtility.EnsureSupportedPlatform(0)).ThrowsNothing(); + } + + [Test] + [Arguments(-1)] + [Arguments(65536)] + public async Task EnsureSupportedPlatform_InvalidPort_ThrowsArgumentOutOfRangeException(int port, CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(() => RemoteDebuggingUtility.EnsureSupportedPlatform(port)) + .Throws(); + } + + [Test] + public async Task EnsureSupportedPlatform_ValidPort_OnSupportedPlatform_DoesNotThrow(CancellationToken ct = default) { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) return; + + // Arrange & Act & Assert + await Assert.That(() => RemoteDebuggingUtility.EnsureSupportedPlatform(9222)).ThrowsNothing(); + } +} diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/CommandLineTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/CommandLineTests.cs deleted file mode 100644 index 70a5815e1..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/CommandLineTests.cs +++ /dev/null @@ -1,223 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack; -using InfiniFrame.Tools.Pack.Services; -using Microsoft.Extensions.Logging.Abstractions; - -namespace InfiniTests.InfiniFrame.Tools.Pack; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class CommandLineTests { - private readonly CommandLine _commandLine = new(NullLogger.Instance); - - [Test] - public async Task Parse_ReturnsUsage_WhenArgsAreEmpty() { - // Arrange - string[] args = []; - - // Act - ParseResult result = _commandLine.Parse(args); - - // Assert - await Assert.That(result.ShowUsage).IsTrue(); - await Assert.That(result.ExitCode).IsEqualTo(0); - await Assert.That(result.Options).IsNull(); - } - - [Test] - public async Task Parse_ReturnsUsage_WhenHelpIsRequested() { - // Arrange - string[] args = ["--help"]; - - // Act - ParseResult result = _commandLine.Parse(args); - - // Assert - await Assert.That(result.ShowUsage).IsTrue(); - await Assert.That(result.ExitCode).IsEqualTo(0); - await Assert.That(result.Options).IsNull(); - } - - [Test] - public async Task Parse_Throws_WhenCommandIsUnknown() { - // Arrange - string[] args = ["unknown"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }).WithMessage("Unknown command 'unknown'."); - } - - [Test] - public async Task Parse_ReturnsUsage_WhenPublishHasNoArguments() { - // Arrange - string[] args = ["publish"]; - - // Act - ParseResult result = _commandLine.Parse(args); - - // Assert - await Assert.That(result.ShowUsage).IsTrue(); - await Assert.That(result.ExitCode).IsEqualTo(0); - await Assert.That(result.Options).IsNull(); - } - - [Test] - public async Task Parse_ReturnsDefaultPublishOptions_WhenOnlyProjectPathIsProvided() { - // Arrange - string[] args = ["publish", "MyApp.csproj"]; - - // Act - ParseResult result = _commandLine.Parse(args); - - // Assert - await Assert.That(result.ShowUsage).IsFalse(); - await Assert.That(result.ExitCode).IsEqualTo(0); - await Assert.That(result.Options).IsNotNull(); - await Assert.That(result.Options!.ProjectPath).IsEqualTo("MyApp.csproj"); - await Assert.That(result.Options.Rid).IsEqualTo("auto"); - await Assert.That(result.Options.Configuration).IsEqualTo("Release"); - await Assert.That(result.Options.Framework).IsNull(); - await Assert.That(result.Options.SelfContained).IsTrue(); - await Assert.That(result.Options.Output).IsNull(); - await Assert.That(result.Options.NoRestore).IsFalse(); - await Assert.That(result.Options.Verbose).IsFalse(); - await Assert.That(result.Options.ProcessTimeout).IsEqualTo(TimeSpan.FromMinutes(10)); - await Assert.That(result.Options.ForceCleanOutput).IsFalse(); - } - - [Test] - public async Task Parse_ReturnsConfiguredPublishOptions_WhenAllOptionsAreProvided() { - // Arrange - string[] args = [ - "publish", - "MyApp.csproj", - "--rid", "win-x64", - "--configuration", "Debug", - "--framework", "net10.0", - "--self-contained", "false", - "--output", "out", - "--no-restore", - "--verbose", - "--timeout", "7m", - "--force-clean-output" - ]; - - // Act - ParseResult result = _commandLine.Parse(args); - - // Assert - await Assert.That(result.ShowUsage).IsFalse(); - await Assert.That(result.Options).IsNotNull(); - await Assert.That(result.Options!.ProjectPath).IsEqualTo("MyApp.csproj"); - await Assert.That(result.Options.Rid).IsEqualTo("win-x64"); - await Assert.That(result.Options.Configuration).IsEqualTo("Debug"); - await Assert.That(result.Options.Framework).IsEqualTo("net10.0"); - await Assert.That(result.Options.SelfContained).IsFalse(); - await Assert.That(result.Options.Output).IsEqualTo("out"); - await Assert.That(result.Options.NoRestore).IsTrue(); - await Assert.That(result.Options.Verbose).IsTrue(); - await Assert.That(result.Options.ProcessTimeout).IsEqualTo(TimeSpan.FromMinutes(7)); - await Assert.That(result.Options.ForceCleanOutput).IsTrue(); - } - - [Test] - public async Task Parse_Throws_WhenSecondPositionalArgumentIsProvided() { - // Arrange - string[] args = ["publish", "MyApp.csproj", "extra"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }).WithMessage("Unexpected argument 'extra'."); - } - - [Test] - public async Task Parse_Throws_WhenOptionIsUnknown() { - // Arrange - string[] args = ["publish", "MyApp.csproj", "--not-real"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }).WithMessage("Unknown option '--not-real'."); - } - - [Test] - public async Task Parse_Throws_WhenOptionValueIsMissing() { - // Arrange - string[] args = ["publish", "MyApp.csproj", "--rid"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }).WithMessage("Missing value for --rid."); - } - - [Test] - public async Task Parse_Throws_WhenProjectPathIsMissing() { - // Arrange - string[] args = ["publish", "--rid", "win-x64"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }).WithMessage("Missing project path."); - } - - [Test] - public async Task Parse_Throws_WhenSelfContainedValueIsInvalid() { - // Arrange - string[] args = ["publish", "MyApp.csproj", "--self-contained", "not-a-bool"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }); - } - - [Test] - public async Task Parse_Throws_WhenTimeoutValueIsInvalid() { - // Arrange - string[] args = ["publish", "MyApp.csproj", "--timeout", "0"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }).WithMessage("Invalid timeout value '0'. Use a positive value like '600', '90s', '5m', or '00:10:00'."); - } - - [Test] - public async Task Parse_Throws_WhenTimeoutValueExceedsMaximum() { - // Arrange - string[] args = ["publish", "MyApp.csproj", "--timeout", "31m"]; - - // Act & Assert - await Assert.ThrowsAsync(() => { - _commandLine.Parse(args); - return Task.CompletedTask; - }).WithMessage("Timeout '00:31:00' exceeds the maximum supported value of '00:30:00'."); - } - - [Test] - public async Task PrintUsage_ExecutesWithoutThrowing() { - // Arrange - - // Act - _commandLine.PrintUsage(); - bool executed = true; - - // Assert - await Assert.That(executed).IsTrue(); - } -} diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/InfiniTests.InfiniFrame.Tools.Pack.csproj b/tests/InfiniTests.InfiniFrame.Tools.Pack/InfiniTests.InfiniFrame.Tools.Pack.csproj deleted file mode 100644 index 9ed9401b8..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/InfiniTests.InfiniFrame.Tools.Pack.csproj +++ /dev/null @@ -1,17 +0,0 @@ - - - - net10.0 - - - - - - - - - - - - - diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/MsBuildPropertyResolverTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/MsBuildPropertyResolverTests.cs deleted file mode 100644 index 8c82b0e14..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/MsBuildPropertyResolverTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Resolvers; -using InfiniTests.InfiniFrame.Tools.Pack.TestUtilities; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Resolvers; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class MsBuildPropertyResolverTests { - private TemporaryDirectory TemporaryDirectory { get; set; } = null!; - - // ----------------------------------------------------------------------------------------------------------------- - // Test Setup - // ----------------------------------------------------------------------------------------------------------------- - [Before(Test)] - public void Before() { - TemporaryDirectory = TemporaryDirectory.Create(); - } - - [After(Test)] - public void After() { - TemporaryDirectory.Dispose(); - TemporaryDirectory = null!; - } - - // ----------------------------------------------------------------------------------------------------------------- - // Test Methods - // ----------------------------------------------------------------------------------------------------------------- - [Test] - public async Task TryGetProperty_ReturnsPropertyValue_WhenPropertyExists() { - // Arrange - string projectPath = Path.Join(TemporaryDirectory.Path, "App.csproj"); - await File.WriteAllTextAsync(projectPath, """ - - - net10.0 - - - """); - - // Act - string? value = await MsBuildPropertyResolver.TryGetPropertyAsync(projectPath, "TargetFramework"); - - // Assert - await Assert.That(value).IsEqualTo("net10.0"); - } - - [Test] - public async Task TryGetProperty_ReturnsNull_WhenPropertyDoesNotExist() { - // Arrange - string projectPath = Path.Join(TemporaryDirectory.Path, "App.csproj"); - await File.WriteAllTextAsync(projectPath, """ - - - net10.0 - - - """); - - // Act - string? value = await MsBuildPropertyResolver.TryGetPropertyAsync(projectPath, "PropertyThatDoesNotExist"); - - // Assert - await Assert.That(value).IsNull(); - } - - [Test] - public async Task TryGetProperty_ReturnsNull_WhenProjectCannotBeEvaluated() { - // Arrange - string missingProjectPath = Path.Join(TemporaryDirectory.Path, "missing.csproj"); - - // Act - string? value = await MsBuildPropertyResolver.TryGetPropertyAsync(missingProjectPath, "TargetFramework"); - - // Assert - await Assert.That(value).IsNull(); - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/ProjectInfoResolverTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/ProjectInfoResolverTests.cs deleted file mode 100644 index 813cd98ec..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/ProjectInfoResolverTests.cs +++ /dev/null @@ -1,125 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Resolvers; -using InfiniTests.InfiniFrame.Tools.Pack.TestUtilities; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Resolvers; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class ProjectInfoResolverTests { - private TemporaryDirectory TemporaryDirectory { get; set; } = null!; - - // ----------------------------------------------------------------------------------------------------------------- - // Test Setup - // ----------------------------------------------------------------------------------------------------------------- - [Before(Test)] - public void Before() { - TemporaryDirectory = TemporaryDirectory.Create(); - } - - [After(Test)] - public void After() { - TemporaryDirectory.Dispose(); - TemporaryDirectory = null!; - } - - // ----------------------------------------------------------------------------------------------------------------- - // Test Methods - // ----------------------------------------------------------------------------------------------------------------- - [Test] - public async Task ResolveFramework_ReturnsTargetFramework_WhenDefined() { - // Arrange - string projectPath = Path.Join(TemporaryDirectory.Path, "App.csproj"); - await File.WriteAllTextAsync(projectPath, """ - - - net10.0 - - - """); - - // Act - string framework = await ProjectInfoResolver.ResolveFrameworkAsync(projectPath); - - // Assert - await Assert.That(framework).IsEqualTo("net10.0"); - } - - [Test] - public async Task ResolveFramework_ReturnsFirstTargetFramework_WhenMultipleAreDefined() { - // Arrange - string projectPath = Path.Join(TemporaryDirectory.Path, "App.csproj"); - await File.WriteAllTextAsync(projectPath, """ - - - net8.0 ; net10.0 - - - """); - - // Act - string framework = await ProjectInfoResolver.ResolveFrameworkAsync(projectPath); - - // Assert - await Assert.That(framework).IsEqualTo("net8.0"); - } - - [Test] - public async Task ResolveFramework_Throws_WhenNoTargetFrameworkIsDefined() { - // Arrange - string projectPath = Path.Join(TemporaryDirectory.Path, "App.csproj"); - await File.WriteAllTextAsync(projectPath, """ - - - App - - - """); - - // Act & Assert - await Assert.ThrowsAsync(async () => { - _ = await ProjectInfoResolver.ResolveFrameworkAsync(projectPath); - }) - .WithMessage("Could not resolve target framework from project evaluation. Use --framework."); - } - - [Test] - public async Task ResolveAssemblyName_ReturnsAssemblyName_WhenDefined() { - // Arrange - string projectPath = Path.Join(TemporaryDirectory.Path, "App.csproj"); - await File.WriteAllTextAsync(projectPath, """ - - - CustomName - - - """); - - // Act - string assemblyName = await ProjectInfoResolver.ResolveAssemblyNameAsync(projectPath); - - // Assert - await Assert.That(assemblyName).IsEqualTo("CustomName"); - } - - [Test] - public async Task ResolveAssemblyName_ReturnsProjectFileName_WhenAssemblyNameIsMissing() { - // Arrange - string projectPath = Path.Join(TemporaryDirectory.Path, "MyApp.csproj"); - await File.WriteAllTextAsync(projectPath, """ - - - net10.0 - - - """); - - // Act - string assemblyName = await ProjectInfoResolver.ResolveAssemblyNameAsync(projectPath); - - // Assert - await Assert.That(assemblyName).IsEqualTo("MyApp"); - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/RuntimeResolverTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/RuntimeResolverTests.cs deleted file mode 100644 index 36c1884b7..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Resolvers/RuntimeResolverTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Resolvers; -using System.Runtime.InteropServices; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Resolvers; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class RuntimeResolverTests { - [Test] - public async Task ResolveRid_ReturnsRequestedRid_WhenNotAuto() { - // Arrange - const string requestedRid = "linux-arm64"; - - // Act - string rid = RuntimeResolver.ResolveRid(requestedRid); - - // Assert - await Assert.That(rid).IsEqualTo(requestedRid); - } - - [Test] - public async Task ResolveRid_ReturnsCurrentPlatformRid_WhenAutoIsRequested() { - // Arrange - const string requestedRid = "auto"; - string expectedPrefix = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? "win-" - : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ? "linux-" - : "osx-"; - - // Act - string rid = RuntimeResolver.ResolveRid(requestedRid); - - // Assert - await Assert.That(rid).StartsWith(expectedPrefix); - await Assert.That(rid).Matches("^(win|linux|osx)-(x64|arm64)$"); - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/InfiniFramePackNativeArtifactManifestTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/InfiniFramePackNativeArtifactManifestTests.cs deleted file mode 100644 index 73184dfaf..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/InfiniFramePackNativeArtifactManifestTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Services; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class InfiniFramePackNativeArtifactManifestTests { - [Test] - public async Task RequiredFileNamesForRid_ReturnsWindowsArtifacts_ForWindowsRid() { - // Act - string[] required = InfiniFramePackNativeArtifactManifest.RequiredFileNamesForRid("win-x64"); - - // Assert - await Assert.That(required).IsEquivalentTo([ - InfiniFramePackNativeArtifactManifest.WindowsNativeFileName, - InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName - ]); - } - - [Test] - public async Task RequiredFileNamesForRid_ReturnsLinuxArtifact_ForLinuxRid() { - // Act - string[] required = InfiniFramePackNativeArtifactManifest.RequiredFileNamesForRid("linux-arm64"); - - // Assert - await Assert.That(required).IsEquivalentTo([ - InfiniFramePackNativeArtifactManifest.LinuxNativeFileName - ]); - } - - [Test] - public async Task RequiredFileNamesForRid_ReturnsOsxArtifact_ForOsxRid() { - // Act - string[] required = InfiniFramePackNativeArtifactManifest.RequiredFileNamesForRid("osx-arm64"); - - // Assert - await Assert.That(required).IsEquivalentTo([ - InfiniFramePackNativeArtifactManifest.OsxNativeFileName - ]); - } - - [Test] - public async Task RequiredFileNamesForRid_MatchesRidPrefix_CaseInsensitively() { - // Act - string[] required = InfiniFramePackNativeArtifactManifest.RequiredFileNamesForRid("WIN-X64"); - - // Assert - await Assert.That(required).IsEquivalentTo([ - InfiniFramePackNativeArtifactManifest.WindowsNativeFileName, - InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName - ]); - } - - [Test] - public async Task RequiredFileNamesForRid_Throws_WhenRidIsUnsupported() { - // Act & Assert - await Assert.ThrowsAsync(() => { - InfiniFramePackNativeArtifactManifest.RequiredFileNamesForRid("browser-wasm"); - return Task.CompletedTask; - }) - .WithMessage("Unsupported RID for native artifact validation: browser-wasm"); - } - - [Test] - public async Task RidArtifacts_ContainsExpectedRidToFileMappings() { - // Assert - await Assert.That(InfiniFramePackNativeArtifactManifest.RidArtifacts).IsEquivalentTo([ - new InfiniFramePackNativeArtifactManifest.NativeRidArtifact("win-", InfiniFramePackNativeArtifactManifest.WindowsNativeFileName), - new InfiniFramePackNativeArtifactManifest.NativeRidArtifact("win-", InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName), - new InfiniFramePackNativeArtifactManifest.NativeRidArtifact("linux-", InfiniFramePackNativeArtifactManifest.LinuxNativeFileName), - new InfiniFramePackNativeArtifactManifest.NativeRidArtifact("osx-", InfiniFramePackNativeArtifactManifest.OsxNativeFileName) - ]); - } - - [Test] - public async Task AllFileNames_ContainsExpectedNativeArtifactFileNames() { - // Assert - await Assert.That(InfiniFramePackNativeArtifactManifest.AllFileNames).IsEquivalentTo([ - InfiniFramePackNativeArtifactManifest.WindowsNativeFileName, - InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName, - InfiniFramePackNativeArtifactManifest.LinuxNativeFileName, - InfiniFramePackNativeArtifactManifest.OsxNativeFileName - ]); - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/ParseResultTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/ParseResultTests.cs deleted file mode 100644 index c6d8df2c5..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/ParseResultTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack; -using InfiniFrame.Tools.Pack.Services; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class ParseResultTests { - [Test] - public async Task Success_ReturnsNonUsageResultWithOptions() { - // Arrange - var options = new PublishOptions { - ProjectPath = "MyApp.csproj", - Rid = "win-x64", - Configuration = "Release", - SelfContained = true - }; - - // Act - ParseResult result = ParseResult.Success(options); - - // Assert - await Assert.That(result.ShowUsage).IsFalse(); - await Assert.That(result.ExitCode).IsEqualTo(0); - await Assert.That(result.Options).IsSameReferenceAs(options); - } - - [Test] - public async Task Usage_ReturnsUsageResultWithoutOptions() { - // Arrange - const int exitCode = 7; - - // Act - ParseResult result = ParseResult.Usage(exitCode); - - // Assert - await Assert.That(result.ShowUsage).IsTrue(); - await Assert.That(result.ExitCode).IsEqualTo(exitCode); - await Assert.That(result.Options).IsNull(); - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/ProcessRunnerTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/ProcessRunnerTests.cs deleted file mode 100644 index f43b009a7..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/ProcessRunnerTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Services; -using Microsoft.Extensions.Logging.Abstractions; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class ProcessRunnerTests { - private readonly ProcessRunner _processRunner = new(NullLogger.Instance); - - [Test] - public async Task RunAsync_ReturnsZero_ForSuccessfulCommand() { - // Arrange - const string fileName = "dotnet"; - string[] arguments = ["--version"]; - - // Act - int exitCode = await _processRunner.RunAsync(fileName, arguments); - - // Assert - await Assert.That(exitCode).IsEqualTo(0); - } - - [Test] - public async Task RunAsync_ReturnsNonZero_ForFailingCommand() { - // Arrange - const string fileName = "dotnet"; - string[] arguments = ["command-that-does-not-exist"]; - - // Act - int exitCode = await _processRunner.RunAsync(fileName, arguments); - - // Assert - await Assert.That(exitCode).IsNotEqualTo(0); - } - - [Test] - public async Task RunAsync_Throws_WhenExecutableDoesNotExist() { - // Arrange - string fileName = $"definitely-not-a-real-executable-{Guid.NewGuid():N}"; - string[] arguments = []; - - // Act & Assert - await Assert.ThrowsAsync(async () => { - await _processRunner.RunAsync(fileName, arguments); - }); - } - - [Test] - public async Task RunWithOutputAsync_CapturesStandardError_ForFailingCommand() { - // Arrange - const string fileName = "dotnet"; - string[] arguments = ["command-that-does-not-exist"]; - - // Act - ProcessRunner.ProcessRunResult result = await _processRunner.RunWithOutputAsync(fileName, arguments); - - // Assert - await Assert.That(result.ExitCode).IsNotEqualTo(0); - await Assert.That(string.IsNullOrWhiteSpace(result.StandardOutput) && string.IsNullOrWhiteSpace(result.StandardError)).IsFalse(); - } - - [Test] - public async Task RunAsync_ThrowsTimeoutException_WhenProcessExceedsTimeout() { - // Arrange - (string fileName, string[] arguments) = BuildLongRunningCommand(); - - // Act & Assert - var ex = await Assert.ThrowsAsync(async () => { - await _processRunner.RunAsync(fileName, arguments, timeout: TimeSpan.FromMilliseconds(250)); - }); - - await Assert.That(ex).IsNotNull(); - await Assert.That(ex!.Message).Contains("Timed out after"); - } - - private static (string FileName, string[] Arguments) BuildLongRunningCommand() { - if (OperatingSystem.IsWindows()) { - return ("powershell", ["-NoProfile", "-Command", "Start-Sleep -Seconds 5"]); - } - - return ("sh", ["-c", "sleep 5"]); - } -} diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishOutputCleanerTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishOutputCleanerTests.cs deleted file mode 100644 index 0912c5bcd..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishOutputCleanerTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Services; -using InfiniTests.InfiniFrame.Tools.Pack.TestUtilities; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class PublishOutputCleanerTests { - private TemporaryDirectory TemporaryDirectory { get; set; } = null!; - - // ----------------------------------------------------------------------------------------------------------------- - // Test Setup - // ----------------------------------------------------------------------------------------------------------------- - [Before(Test)] - public void Before() { - TemporaryDirectory = TemporaryDirectory.Create(); - } - - [After(Test)] - public void After() { - TemporaryDirectory.Dispose(); - TemporaryDirectory = null!; - } - - // ----------------------------------------------------------------------------------------------------------------- - // Test Methods - // ----------------------------------------------------------------------------------------------------------------- - [Test] - public async Task Cleanup_RemovesWwwrootAndNativeRuntimeFiles_WhenTheyExist() { - // Arrange - string output = TemporaryDirectory.Path; - string wwwroot = Path.Join(output, "wwwroot"); - - Directory.CreateDirectory(wwwroot); - await File.WriteAllTextAsync(Path.Join(wwwroot, "index.html"), ""); - foreach (string file in PublishOutputCleaner.NativeRuntimeFiles) { - await File.WriteAllTextAsync(Path.Join(output, file), string.Empty); - } - - // Act - string[] warnings = PublishOutputCleaner.Cleanup(output); - - // Assert - await Assert.That(warnings.Length).IsEqualTo(0); - await Assert.That(Directory.Exists(wwwroot)).IsFalse(); - foreach (string file in PublishOutputCleaner.NativeRuntimeFiles) { - await Assert.That(File.Exists(Path.Join(output, file))).IsFalse(); - } - } - - [Test] - public async Task Cleanup_DoesNotThrow_WhenTargetFilesDoNotExist() { - // Arrange - string output = TemporaryDirectory.Path; - - // Act - string[] warnings = PublishOutputCleaner.Cleanup(output); - - // Assert - await Assert.That(warnings.Length).IsEqualTo(0); - await Assert.That(Directory.Exists(output)).IsTrue(); - } - - [Test] - public async Task Cleanup_ReturnsWarning_WhenNativeArtifactDeletionFails() { - // Arrange - string output = TemporaryDirectory.Path; - string nativeArtifactPath = Path.Join(output, PublishOutputCleaner.NativeRuntimeFiles[0]); - await File.WriteAllTextAsync(nativeArtifactPath, "locked"); - File.SetAttributes(nativeArtifactPath, File.GetAttributes(nativeArtifactPath) | FileAttributes.ReadOnly); - - // Act - try { - string[] warnings = PublishOutputCleaner.Cleanup(output); - - // Assert - if (OperatingSystem.IsWindows()) { - await Assert.That(warnings.Length).IsEqualTo(1); - await Assert.That(warnings[0]).Contains("Cleanup skipped file"); - await Assert.That(warnings[0]).Contains(nativeArtifactPath); - await Assert.That(File.Exists(nativeArtifactPath)).IsTrue(); - return; - } - - await Assert.That(warnings.Length).IsEqualTo(0); - } - finally { - if (File.Exists(nativeArtifactPath)) { - File.SetAttributes(nativeArtifactPath, FileAttributes.Normal); - } - } - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishServiceTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishServiceTests.cs deleted file mode 100644 index c6a13e387..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishServiceTests.cs +++ /dev/null @@ -1,352 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack; -using InfiniFrame.Tools.Pack.Exceptions; -using InfiniFrame.Tools.Pack.Resolvers; -using InfiniFrame.Tools.Pack.Services; -using InfiniTests.InfiniFrame.Tools.Pack.TestUtilities; -using Microsoft.Extensions.Logging.Abstractions; -using System.Diagnostics; -using System.Runtime.InteropServices; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class PublishServiceTests { - private static readonly SemaphoreSlim PublishTestLock = new(1, 1); - private static readonly TimeSpan PublishTimeout = IsCiEnvironment() - ? IsWindowsArm64() - ? TimeSpan.FromMinutes(15) - : TimeSpan.FromMinutes(8) - : TimeSpan.FromMinutes(3); - private static readonly TimeSpan SharedFixtureAwaitTimeout = PublishTimeout + TimeSpan.FromMinutes(1); - private static readonly TimeSpan ProcessTimeout = TimeSpan.FromSeconds(45); - private static readonly Lock SharedFixtureLock = new(); - private static Task? _sharedPublishFixtureTask; - private TemporaryDirectory TemporaryDirectory { get; set; } = null!; - - private readonly PublishService _publishService = new( - NullLogger.Instance, - new ProcessRunner(NullLogger.Instance)); - - -#if DEBUG - private const string Configuration = "Debug"; -#else - private const string Configuration = "Release"; -#endif - - // ----------------------------------------------------------------------------------------------------------------- - // Test Setup - // ----------------------------------------------------------------------------------------------------------------- - [Before(Test)] - public void Before() { - TemporaryDirectory = TemporaryDirectory.Create(); - } - - [After(Test)] - public void After() { - TemporaryDirectory.Dispose(); - TemporaryDirectory = null!; - } - - // ----------------------------------------------------------------------------------------------------------------- - // Test Methods - // ----------------------------------------------------------------------------------------------------------------- - [Test] - public async Task PublishAsync_Throws_WhenProjectFileDoesNotExist() { - // Arrange - var options = new PublishOptions { - ProjectPath = Path.Join(Path.GetTempPath(), $"missing-project-{Guid.NewGuid():N}.csproj"), - Rid = "auto", - Configuration = Configuration, - Framework = "net10.0", - SelfContained = true - }; - - // Act & Assert - await Assert.ThrowsAsync(async () => { - await _publishService.PublishAsync(options); - }); - } - - [Test] - public async Task PublishAsync_ThrowsKnownFailure_WhenNativeDependencyIsMissingFromPublishOutput() { - // Arrange - string repoRoot = TemporaryDirectory.Path; - - string nativeProjectPath = Path.Join(repoRoot, "src", "InfiniFrame.NativeBridge", "InfiniFrame.NativeBridge.csproj"); - Directory.CreateDirectory(Path.GetDirectoryName(nativeProjectPath)!); - await File.WriteAllTextAsync(nativeProjectPath, ""); - - string appDirectory = Path.Join(repoRoot, "samples", "app"); - Directory.CreateDirectory(appDirectory); - string appProjectPath = Path.Join(appDirectory, "SampleApp.csproj"); - await File.WriteAllTextAsync(appProjectPath, """ - - - net10.0 - - - """); - - string outputPath = Path.Join(repoRoot, "publish-output"); - string rid = RuntimeResolver.ResolveRid("auto"); - - var options = new PublishOptions { - ProjectPath = appProjectPath, - Rid = rid, - Configuration = Configuration, - Framework = "net10.0", - SelfContained = true, - Output = outputPath - }; - - // Act - await PublishTestLock.WaitAsync(); - NativeDependencyNotFoundException? exception; - try { - exception = await Assert.ThrowsAsync(async () => { - await ExecuteWithTimeout( - _publishService.PublishAsync(options), - PublishTimeout, - "PublishAsync_ThrowsKnownFailure_WhenNativeDependencyIsMissingFromPublishOutput"); - }); - } - finally { - PublishTestLock.Release(); - } - - // Assert - await Assert.That(exception).IsNotNull(); - await Assert.That(exception!.Message.Contains("Could not resolve required InfiniFrame native artifacts from project publish output.", StringComparison.Ordinal)).IsTrue(); - } - - [Test] - [SkipOnMacOs("The pack fixture does not yet produce a valid macOS single-file app bundle")] - public async Task PublishAsync_ReturnsSuccessAndSingleFileOutput_WhenProjectIncludesInfiniFrame() { - SharedPublishFixture fixture = await ExecuteWithTimeout( - GetOrCreateSharedPublishFixtureAsync(), - SharedFixtureAwaitTimeout, - "PublishAsync_ReturnsSuccessAndSingleFileOutput_WhenProjectIncludesInfiniFrame"); - - // Assert - await Assert.That(fixture.PublishExitCode).IsEqualTo(ExitCodes.Success); - await Assert.That(File.Exists(fixture.PublishedExecutable)).IsTrue(); - await Assert.That(Directory.GetFileSystemEntries(fixture.OutputPath, "*", SearchOption.TopDirectoryOnly).Length).IsEqualTo(1); - } - - [Test] - [SkipOnMacOs("The pack fixture does not yet produce a launchable macOS app bundle")] - public async Task PublishAsync_LaunchedPackedApp_InitializesBootstrapAndExitsSuccessfully() { - SharedPublishFixture fixture = await ExecuteWithTimeout( - GetOrCreateSharedPublishFixtureAsync(), - SharedFixtureAwaitTimeout, - "PublishAsync_LaunchedPackedApp_InitializesBootstrapAndExitsSuccessfully"); - ProcessResult runResult = await RunProcessAndCaptureAsync(fixture.PublishedExecutable, fixture.AppDirectory, ProcessTimeout); - - // Assert - await Assert.That(fixture.PublishExitCode).IsEqualTo(ExitCodes.Success); - await Assert.That(runResult.ExitCode).IsEqualTo(0); - await Assert.That(runResult.StandardOutput.Contains(fixture.StartupMarker, StringComparison.Ordinal)).IsTrue(); - } - - [Test] - public async Task ValidateOutputShape_ReturnsUnexpectedEntries_WhenExtraPayloadFilesRemain() { - // Arrange - string output = TemporaryDirectory.Path; - string expectedMainOutput = Path.Join(output, "SampleApp.exe"); - await File.WriteAllTextAsync(expectedMainOutput, "main"); - await File.WriteAllTextAsync(Path.Join(output, "leftover.payload"), "extra"); - Directory.CreateDirectory(Path.Join(output, "nested-assets")); - - // Act - PublishService.OutputShapeValidation validation = PublishService.ValidateOutputShape(output, expectedMainOutput); - - // Assert - await Assert.That(validation.FoundMainOutput).IsTrue(); - await Assert.That(validation.UnexpectedEntries).Contains("leftover.payload"); - await Assert.That(validation.UnexpectedEntries).Contains("nested-assets"); - } - - [Test] - public async Task ValidateOutputShape_UsesPlatformPathCasingRules() { - // Arrange - string output = TemporaryDirectory.Path; - string actualMainOutput = Path.Join(output, "SampleApp.exe"); - string expectedMainOutput = Path.Join(output, "sampleapp.exe"); - await File.WriteAllTextAsync(actualMainOutput, "main"); - - // Act - PublishService.OutputShapeValidation validation = PublishService.ValidateOutputShape(output, expectedMainOutput); - - // Assert - if (OperatingSystem.IsWindows()) { - await Assert.That(validation.FoundMainOutput).IsTrue(); - await Assert.That(validation.UnexpectedEntries.Length).IsEqualTo(0); - return; - } - - await Assert.That(validation.FoundMainOutput).IsFalse(); - await Assert.That(validation.UnexpectedEntries).Contains("SampleApp.exe"); - } - - private static string FindRepoRoot() { - DirectoryInfo? current = new(AppContext.BaseDirectory); - while (current is not null) { - if (File.Exists(Path.Join(current.FullName, "InfiniFrame.slnx"))) return current.FullName; - - current = current.Parent; - } - - throw new DirectoryNotFoundException("Could not locate repository root containing InfiniFrame.slnx."); - } - - private static async Task RunProcessAndCaptureAsync(string fileName, string workingDirectory, TimeSpan timeout) { - var startInfo = new ProcessStartInfo(fileName) { - WorkingDirectory = workingDirectory, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - using var process = new Process(); - process.StartInfo = startInfo; - - if (!process.Start()) throw new InvalidOperationException($"Failed to start process: {fileName}"); - - Task standardOutputTask = process.StandardOutput.ReadToEndAsync(); - Task standardErrorTask = process.StandardError.ReadToEndAsync(); - using var timeoutCts = new CancellationTokenSource(timeout); - try { - await process.WaitForExitAsync(timeoutCts.Token); - } - catch (OperationCanceledException) { - try { - if (!process.HasExited) process.Kill(entireProcessTree: true); - } - catch (InvalidOperationException) { - // best effort - } - - throw new TimeoutException($"Timed out after {timeout} while running '{fileName}'."); - } - - string standardOutput = await standardOutputTask; - string standardError = await standardErrorTask; - - return new ProcessResult(process.ExitCode, standardOutput, standardError); - } - - private static async Task ExecuteWithTimeout(Task task, TimeSpan timeout, string operationName) { - Task completed = await Task.WhenAny(task, Task.Delay(timeout)); - if (!ReferenceEquals(completed, task)) { - throw new TimeoutException($"Timed out after {timeout} while executing '{operationName}'."); - } - - return await task; - } - - private static bool IsCiEnvironment() => - string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase) || - string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase); - - private static bool IsWindowsArm64() => - OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64; - - private static Task GetOrCreateSharedPublishFixtureAsync() { - lock (SharedFixtureLock) { - _sharedPublishFixtureTask ??= CreateSharedPublishFixtureAsync(); - return _sharedPublishFixtureTask; - } - } - - private static async Task CreateSharedPublishFixtureAsync() { - string repoRoot = FindRepoRoot(); - string root = Path.Join(Path.GetTempPath(), $"infiniframe-pack-shared-{Guid.NewGuid():N}"); - string appDirectory = Path.Join(root, "app"); - Directory.CreateDirectory(appDirectory); - - string appProjectPath = Path.Join(appDirectory, "SharedSmokeApp.csproj"); - string infiniFrameProjectPath = Path.Join(repoRoot, "src", "InfiniFrame", "InfiniFrame.csproj"); - const string startupMarker = "BOOTSTRAP_SMOKE_OK"; - - await File.WriteAllTextAsync(appProjectPath, $$""" - - - Exe - net10.0 - enable - enable - - true - - - - - - """); - - await File.WriteAllTextAsync(Path.Join(appDirectory, "Program.cs"), $$""" - using InfiniFrame; - - InfiniFrameSingleFileBootstrap.Initialize(); - Console.WriteLine("{{startupMarker}}"); - return 0; - """); - - string outputPath = Path.Join(root, "publish-output"); - string rid = RuntimeResolver.ResolveRid("auto"); - string publishedExecutable = Path.Join(outputPath, rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase) ? "SharedSmokeApp.exe" : "SharedSmokeApp"); - - var options = new PublishOptions { - ProjectPath = appProjectPath, - Rid = rid, - Configuration = Configuration, - Framework = "net10.0", - SelfContained = true, - Output = outputPath, - ProcessTimeout = PublishTimeout - }; - - await PublishTestLock.WaitAsync(); - int publishExitCode; - try { - // The timeout must cancel PublishService itself so ProcessRunner kills the complete - // dotnet/MSBuild child-process tree. Task.WhenAny alone reports a timeout while the - // publish keeps running and can hold build-server/file locks for subsequent tests. - using var publishTimeoutCts = new CancellationTokenSource(PublishTimeout); - var publishService = new PublishService( - NullLogger.Instance, - new ProcessRunner(NullLogger.Instance)); - try { - publishExitCode = await publishService.PublishAsync(options, publishTimeoutCts.Token); - } - catch (OperationCanceledException) when (publishTimeoutCts.IsCancellationRequested) { - throw new TimeoutException( - $"Timed out after {PublishTimeout} while executing 'CreateSharedPublishFixtureAsync'." - ); - } - } - finally { - PublishTestLock.Release(); - } - - return new SharedPublishFixture(publishExitCode, appDirectory, outputPath, publishedExecutable, startupMarker); - } - - private sealed record SharedPublishFixture( - int PublishExitCode, - string AppDirectory, - string OutputPath, - string PublishedExecutable, - string StartupMarker - ); - - // ReSharper disable once NotAccessedPositionalProperty.Local - private sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError); -} diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishValidatorTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishValidatorTests.cs deleted file mode 100644 index 7ae2aac45..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/PublishValidatorTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Services; -using InfiniTests.InfiniFrame.Tools.Pack.TestUtilities; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class PublishValidatorTests { - private const ushort ImageFileMachineAmd64 = 0x8664; - private const ushort ImageFileMachineArm64 = 0xAA64; - - private TemporaryDirectory TemporaryDirectory { get; set; } = null!; - - private static void WriteMinimalPeBinary(string path, ushort machine) { - byte[] bytes = new byte[0x90]; - bytes[0] = (byte)'M'; - bytes[1] = (byte)'Z'; - - // e_lfanew points to the PE signature location. - bytes[0x3C] = 0x80; - bytes[0x3D] = 0x00; - bytes[0x3E] = 0x00; - bytes[0x3F] = 0x00; - - bytes[0x80] = (byte)'P'; - bytes[0x81] = (byte)'E'; - bytes[0x82] = 0x00; - bytes[0x83] = 0x00; - - // IMAGE_FILE_HEADER.Machine - bytes[0x84] = (byte)(machine & 0xFF); - bytes[0x85] = (byte)(machine >> 8 & 0xFF); - - File.WriteAllBytes(path, bytes); - } - - // ----------------------------------------------------------------------------------------------------------------- - // Test Setup - // ----------------------------------------------------------------------------------------------------------------- - [Before(Test)] - public void Before() { - TemporaryDirectory = TemporaryDirectory.Create(); - } - - [After(Test)] - public void After() { - TemporaryDirectory.Dispose(); - TemporaryDirectory = null!; - } - - // ----------------------------------------------------------------------------------------------------------------- - // Test Methods - // ----------------------------------------------------------------------------------------------------------------- - [Test] - public async Task ValidateNativeArtifacts_Throws_WhenArtifactsDirectoryIsMissing() { - // Arrange - string missingDirectory = Path.Join(Path.GetTempPath(), $"missing-artifacts-{Guid.NewGuid():N}"); - - // Act & Assert - await Assert.ThrowsAsync(() => { - PublishValidator.ValidateNativeArtifacts(missingDirectory, "win-x64"); - return Task.CompletedTask; - }) - .WithMessage($"Native artifacts directory was not found: {missingDirectory}"); - } - - [Test] - public async Task ValidateNativeArtifacts_Throws_WhenWindowsRequiredArtifactIsMissing() { - // Arrange - string artifactsDirectory = TemporaryDirectory.Path; - WriteMinimalPeBinary( - Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsNativeFileName), - ImageFileMachineAmd64 - ); - - // Act & Assert - string expectedMissingFile = Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName); - await Assert.ThrowsAsync(() => { - PublishValidator.ValidateNativeArtifacts(artifactsDirectory, "win-x64"); - return Task.CompletedTask; - }) - .WithMessage($"Required native artifact was not found: {expectedMissingFile}"); - } - - [Test] - public async Task ValidateNativeArtifacts_DoesNotThrow_ForWindowsWhenAllRequiredArtifactsExist() { - // Arrange - string artifactsDirectory = TemporaryDirectory.Path; - WriteMinimalPeBinary( - Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsNativeFileName), - ImageFileMachineAmd64 - ); - WriteMinimalPeBinary( - Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName), - ImageFileMachineAmd64 - ); - - // Act - PublishValidator.ValidateNativeArtifacts(artifactsDirectory, "win-x64"); - - // Assert - await Assert.That(File.Exists(Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsNativeFileName))).IsTrue(); - await Assert.That(File.Exists(Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName))).IsTrue(); - } - - [Test] - public async Task ValidateNativeArtifacts_Throws_WhenWindowsArtifactArchitectureMismatchesRid() { - // Arrange - string artifactsDirectory = TemporaryDirectory.Path; - string nativeDll = Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsNativeFileName); - string loaderDll = Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName); - WriteMinimalPeBinary(nativeDll, ImageFileMachineArm64); - WriteMinimalPeBinary(loaderDll, ImageFileMachineArm64); - - // Act & Assert - InvalidOperationException ex = await Assert.ThrowsAsync(() => { - PublishValidator.ValidateNativeArtifacts(artifactsDirectory, "win-x64"); - return Task.CompletedTask; - }) ?? throw new InvalidOperationException("Expected exception was not thrown."); - - await Assert.That(ex.Message).Contains("architecture mismatch"); - await Assert.That(ex.Message).Contains("Expected x64"); - await Assert.That(ex.Message).Contains("found arm64"); - } - - [Test] - public async Task ValidateNativeArtifacts_Throws_WhenSecondWindowsArtifactArchitectureMismatchesRid() { - // Arrange - string artifactsDirectory = TemporaryDirectory.Path; - string nativeDll = Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsNativeFileName); - string loaderDll = Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName); - WriteMinimalPeBinary(nativeDll, ImageFileMachineAmd64); - WriteMinimalPeBinary(loaderDll, ImageFileMachineArm64); - - // Act & Assert - InvalidOperationException ex = await Assert.ThrowsAsync(() => { - PublishValidator.ValidateNativeArtifacts(artifactsDirectory, "win-x64"); - return Task.CompletedTask; - }) ?? throw new InvalidOperationException("Expected exception was not thrown."); - - await Assert.That(ex.Message).Contains(InfiniFramePackNativeArtifactManifest.WindowsLoaderFileName); - await Assert.That(ex.Message).Contains("Expected x64"); - await Assert.That(ex.Message).Contains("found arm64"); - } - - [Test] - public async Task ValidateNativeArtifacts_DoesNotThrow_ForLinuxWhenRequiredArtifactExists() { - // Arrange - string artifactsDirectory = TemporaryDirectory.Path; - await File.WriteAllTextAsync(Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.LinuxNativeFileName), string.Empty); - - // Act - PublishValidator.ValidateNativeArtifacts(artifactsDirectory, "linux-x64"); - - // Assert - await Assert.That(File.Exists(Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.LinuxNativeFileName))).IsTrue(); - } - - [Test] - public async Task ValidateNativeArtifacts_DoesNotThrow_ForOsxWhenRequiredArtifactExists() { - // Arrange - string artifactsDirectory = TemporaryDirectory.Path; - await File.WriteAllTextAsync(Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.OsxNativeFileName), string.Empty); - - // Act - PublishValidator.ValidateNativeArtifacts(artifactsDirectory, "osx-arm64"); - - // Assert - await Assert.That(File.Exists(Path.Join(artifactsDirectory, InfiniFramePackNativeArtifactManifest.OsxNativeFileName))).IsTrue(); - } - - [Test] - public async Task ValidateNativeArtifacts_Throws_WhenRidIsUnsupported() { - // Arrange - string artifactsDirectory = TemporaryDirectory.Path; - - // Act & Assert - await Assert.ThrowsAsync(() => { - PublishValidator.ValidateNativeArtifacts(artifactsDirectory, "browser-wasm"); - return Task.CompletedTask; - }) - .WithMessage("Unsupported RID for native artifact validation: browser-wasm"); - } - - [Test] - public async Task ValidateRidConsistency_Throws_WhenRidIsEmpty() { - await Assert.ThrowsAsync(() => { - PublishValidator.ValidateRidConsistency(string.Empty); - return Task.CompletedTask; - }) - .WithMessage("Runtime identifier (RID) cannot be empty."); - } - - [Test] - public async Task ValidateRidConsistency_Throws_WhenRidFormatIsInvalid() { - await Assert.ThrowsAsync(() => { - PublishValidator.ValidateRidConsistency("linuxx64"); - return Task.CompletedTask; - }) - .WithMessage("Invalid RID format: 'linuxx64'. Expected format like 'win-x64', 'linux-arm64'."); - } - - [Test] - public async Task ValidateRidConsistency_Throws_WhenRidIsUnsupported() { - await Assert.ThrowsAsync(() => { - PublishValidator.ValidateRidConsistency("browser-wasm"); - return Task.CompletedTask; - }) - .WithMessage("Unsupported or unknown RID: 'browser-wasm'."); - } - - [Test] - public async Task ValidateRidConsistency_ReturnsTrue_ForSupportedRid() { - bool output = PublishValidator.ValidateRidConsistency("linux-x64"); - await Assert.That(output).IsTrue(); - } - - [Test] - public async Task ValidateOutputPath_AllowsProjectBinPath() { - string projectDirectory = Path.Join(TemporaryDirectory.Path, "app"); - string outputPath = Path.Join(projectDirectory, "bin", "Release", "net10.0", "win-x64", "publish"); - - bool output = PublishValidator.ValidateOutputPath(projectDirectory, outputPath, false); - await Assert.That(output).IsTrue(); - } - - [Test] - public async Task ValidateOutputPath_ThrowsForNonDefaultPath_WhenNotForced() { - string projectDirectory = Path.Join(TemporaryDirectory.Path, "app"); - string outputPath = Path.Join(TemporaryDirectory.Path, "publish-output"); - Directory.CreateDirectory(outputPath); - - InvalidOperationException ex = await Assert.ThrowsAsync(() => { - PublishValidator.ValidateOutputPath(projectDirectory, outputPath, false); - return Task.CompletedTask; - }) ?? throw new InvalidOperationException("Expected exception was not thrown."); - - await Assert.That(ex.Message).Contains("--force-clean-output"); - } - - [Test] - public async Task ValidateOutputPath_AllowsNonDefaultPath_WhenForced() { - string projectDirectory = Path.Join(TemporaryDirectory.Path, "app"); - string outputPath = Path.Join(TemporaryDirectory.Path, "publish-output"); - Directory.CreateDirectory(outputPath); - - bool output = PublishValidator.ValidateOutputPath(projectDirectory, outputPath, true); - await Assert.That(output).IsTrue(); - } - - [Test] - public async Task ValidateOutputPath_AllowsNonDefaultPath_WhenDirectoryDoesNotExist() { - string projectDirectory = Path.Join(TemporaryDirectory.Path, "app"); - string outputPath = Path.Join(TemporaryDirectory.Path, "publish-output"); - - bool output = PublishValidator.ValidateOutputPath(projectDirectory, outputPath, false); - await Assert.That(output).IsTrue(); - } - - [Test] - public async Task ValidateOutputPath_RejectsCaseMismatchForBinDirectory_OnCaseSensitivePlatforms() { - string projectDirectory = Path.Join(TemporaryDirectory.Path, "app"); - string outputPath = Path.Join(projectDirectory, "BIN", "Release", "net10.0", "win-x64", "publish"); - Directory.CreateDirectory(outputPath); - - if (OperatingSystem.IsWindows()) { - bool output = PublishValidator.ValidateOutputPath(projectDirectory, outputPath, false); - await Assert.That(output).IsTrue(); - return; - } - - InvalidOperationException ex = await Assert.ThrowsAsync(() => { - PublishValidator.ValidateOutputPath(projectDirectory, outputPath, false); - return Task.CompletedTask; - }) ?? throw new InvalidOperationException("Expected exception was not thrown."); - - await Assert.That(ex.Message).Contains("--force-clean-output"); - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/TempTargetsFileTests.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/TempTargetsFileTests.cs deleted file mode 100644 index b9671cb14..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/Services/TempTargetsFileTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -using InfiniFrame.Tools.Pack.Services; - -namespace InfiniTests.InfiniFrame.Tools.Pack.Services; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -public class TempTargetsFileTests { - [Test] - public async Task Create_CreatesTargetsFileWithExpectedContents() { - // Arrange - - // Act - using var tempTargetsFile = TempTargetsFile.Create(); - - // Assert - await Assert.That(File.Exists(tempTargetsFile.Path)).IsTrue(); - string contents = await File.ReadAllTextAsync(tempTargetsFile.Path); - await Assert.That(contents).Contains("InfiniFramePackCleanupPublishArtifacts"); - await Assert.That(contents).Contains("InfiniFramePackRemoveTransitiveNativeFiles"); - await Assert.That(contents).Contains("wwwroot/**/*"); - await Assert.That(contents).Contains("$(PublishDir)/"); - foreach (string nativeFileName in InfiniFramePackNativeArtifactManifest.AllFileNames) { - await Assert.That(contents).Contains(nativeFileName); - } - } - - [Test] - public async Task Dispose_DeletesCreatedTargetsFile() { - // Arrange - var tempTargetsFile = TempTargetsFile.Create(); - string path = tempTargetsFile.Path; - - // Act - tempTargetsFile.Dispose(); - - // Assert - await Assert.That(File.Exists(path)).IsFalse(); - } - - [Test] - public async Task Dispose_DoesNotThrow_WhenFileWasDeletedExternally() { - // Arrange - var tempTargetsFile = TempTargetsFile.Create(); - string path = tempTargetsFile.Path; - File.Delete(path); - - // Act - tempTargetsFile.Dispose(); - - // Assert - await Assert.That(File.Exists(path)).IsFalse(); - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.Tools.Pack/TestUtilities/TemporaryDirectory.cs b/tests/InfiniTests.InfiniFrame.Tools.Pack/TestUtilities/TemporaryDirectory.cs deleted file mode 100644 index 27a2600fa..000000000 --- a/tests/InfiniTests.InfiniFrame.Tools.Pack/TestUtilities/TemporaryDirectory.cs +++ /dev/null @@ -1,33 +0,0 @@ -// --------------------------------------------------------------------------------------------------------------------- -// Imports -// --------------------------------------------------------------------------------------------------------------------- -namespace InfiniTests.InfiniFrame.Tools.Pack.TestUtilities; -// --------------------------------------------------------------------------------------------------------------------- -// Code -// --------------------------------------------------------------------------------------------------------------------- -internal sealed class TemporaryDirectory : IDisposable { - public string Path { get; private init; } = null!; - - public void Dispose() { - if (!Directory.Exists(Path)) return; - - try { - Directory.Delete(Path, true); - } - catch (IOException) { - // no-op - } - catch (UnauthorizedAccessException) { - // no-op - } - } - - // ----------------------------------------------------------------------------------------------------------------- - // Methods - // ----------------------------------------------------------------------------------------------------------------- - public static TemporaryDirectory Create() { - string path = System.IO.Path.Join(System.IO.Path.GetTempPath(), $"infiniframe-tools-pack-tests-{Guid.NewGuid():N}"); - Directory.CreateDirectory(path); - return new TemporaryDirectory { Path = path }; - } -} \ No newline at end of file diff --git a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunAsyncTests.cs b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunAsyncTests.cs index 6e988561c..1e9e17856 100644 --- a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunAsyncTests.cs +++ b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunAsyncTests.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace InfiniTests.InfiniFrame.WebServer; // --------------------------------------------------------------------------------------------------------------------- @@ -19,8 +18,11 @@ public class InfiniFrameWebApplicationRunAsyncTests { [Test] public async Task RunAsync_ShouldStartWebAppBeforeWaitingThenStopAndDisposeIt() { // Arrange - var mockWindow = Substitute.For(); - ILifecycleInfiniFrameWindowFeature lifecycle = mockWindow.Features.Lifecycle; + Mock mockWindow = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + mockWindow.Features.Returns(features.Object); + features.Lifecycle.Returns(lifecycle.Object); WebApplicationBuilder webAppBuilder = WebApplication.CreateBuilder(); webAppBuilder.Services.Replace(ServiceDescriptor.Singleton()); @@ -30,8 +32,8 @@ public async Task RunAsync_ShouldStartWebAppBeforeWaitingThenStopAndDisposeIt() var appLifetime = webApp.Services.GetRequiredService(); var disposeProbe = webApp.Services.GetRequiredService(); bool webAppStartedBeforeWait = false; - lifecycle.WaitForCloseAsync(Arg.Any()) - .Returns(_ => { + lifecycle.WaitForCloseAsync(Any()) + .Returns(() => { webAppStartedBeforeWait = appLifetime.ApplicationStarted.IsCancellationRequested; return ValueTask.CompletedTask; }); @@ -39,15 +41,15 @@ public async Task RunAsync_ShouldStartWebAppBeforeWaitingThenStopAndDisposeIt() var app = new InfiniFrameWebApplication { Logger = NullLogger.Instance, WebApp = webApp, - LazyWindow = new Lazy(() => mockWindow) + LazyWindow = new Lazy(() => mockWindow.Object) }; // Act await app.RunAsync(); // Assert - await lifecycle.Received(1).WaitForCloseAsync(Arg.Any()); - lifecycle.DidNotReceive().WaitForClose(); + lifecycle.WaitForCloseAsync(Any()).WasCalled(Times.Once); + lifecycle.WaitForClose().WasNeverCalled(); await Assert.That(webAppStartedBeforeWait).IsTrue(); await Assert.That(appLifetime.ApplicationStopping.IsCancellationRequested).IsTrue(); await Assert.That(disposeProbe.IsDisposed).IsTrue(); @@ -58,9 +60,13 @@ public async Task RunAsync_ShouldStartWebAppBeforeWaitingThenStopAndDisposeIt() [Test] public async Task RunAsync_WhenWaitFails_StillStopsAndDisposesWebApp() { - var mockWindow = Substitute.For(); - mockWindow.Features.Lifecycle.WaitForCloseAsync(Arg.Any()) - .Returns(ValueTask.FromException(new InvalidOperationException("wait failed"))); + Mock mockWindow = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + mockWindow.Features.Returns(features.Object); + features.Lifecycle.Returns(lifecycle.Object); + lifecycle.WaitForCloseAsync(Any()) + .Returns(() => ValueTask.FromException(new InvalidOperationException("wait failed"))); WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.Services.Replace(ServiceDescriptor.Singleton()); WebApplication webApp = builder.Build(); @@ -68,7 +74,7 @@ public async Task RunAsync_WhenWaitFails_StillStopsAndDisposesWebApp() { var app = new InfiniFrameWebApplication { Logger = NullLogger.Instance, WebApp = webApp, - LazyWindow = new Lazy(() => mockWindow) + LazyWindow = new Lazy(() => mockWindow.Object) }; var exception = await Assert.ThrowsAsync(() => app.RunAsync()); @@ -87,4 +93,4 @@ public void Dispose() { IsDisposed = true; } } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunSyncTests.cs b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunSyncTests.cs index ad93f89b7..59a904bd3 100644 --- a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunSyncTests.cs +++ b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationRunSyncTests.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace InfiniTests.InfiniFrame.WebServer; // --------------------------------------------------------------------------------------------------------------------- @@ -19,8 +18,11 @@ public class InfiniFrameWebApplicationRunSyncTests { [Test] public async Task Run_ShouldStartWebAppBeforeWaitingThenStopAndDisposeIt() { // Arrange - var mockWindow = Substitute.For(); - ILifecycleInfiniFrameWindowFeature lifecycle = mockWindow.Features.Lifecycle; + Mock mockWindow = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + mockWindow.Features.Returns(features.Object); + features.Lifecycle.Returns(lifecycle.Object); WebApplicationBuilder webAppBuilder = WebApplication.CreateBuilder(); webAppBuilder.Services.Replace(ServiceDescriptor.Singleton()); @@ -30,21 +32,20 @@ public async Task Run_ShouldStartWebAppBeforeWaitingThenStopAndDisposeIt() { var appLifetime = webApp.Services.GetRequiredService(); var disposeProbe = webApp.Services.GetRequiredService(); bool webAppStartedBeforeWait = false; - lifecycle.When(static feature => feature.WaitForClose()) - .Do(_ => webAppStartedBeforeWait = appLifetime.ApplicationStarted.IsCancellationRequested); + lifecycle.WaitForClose().Callback(() => webAppStartedBeforeWait = appLifetime.ApplicationStarted.IsCancellationRequested); var app = new InfiniFrameWebApplication { Logger = NullLogger.Instance, WebApp = webApp, - LazyWindow = new Lazy(() => mockWindow) + LazyWindow = new Lazy(() => mockWindow.Object) }; // Act app.Run(); // Assert - lifecycle.Received(1).WaitForClose(); - await lifecycle.DidNotReceive().WaitForCloseAsync(Arg.Any()); + lifecycle.WaitForClose().WasCalled(Times.Once); + lifecycle.WaitForCloseAsync(Any()).WasNeverCalled(); await Assert.That(webAppStartedBeforeWait).IsTrue(); await Assert.That(appLifetime.ApplicationStopping.IsCancellationRequested).IsTrue(); await Assert.That(disposeProbe.IsDisposed).IsTrue(); @@ -55,9 +56,12 @@ public async Task Run_ShouldStartWebAppBeforeWaitingThenStopAndDisposeIt() { [Test] public async Task Run_WhenWaitFails_StillStopsAndDisposesWebApp() { - var mockWindow = Substitute.For(); - mockWindow.Features.Lifecycle.When(static feature => feature.WaitForClose()) - .Do(_ => throw new InvalidOperationException("wait failed")); + Mock mockWindow = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + mockWindow.Features.Returns(features.Object); + features.Lifecycle.Returns(lifecycle.Object); + lifecycle.WaitForClose().Callback(() => throw new InvalidOperationException("wait failed")); WebApplicationBuilder builder = WebApplication.CreateBuilder(); builder.Services.Replace(ServiceDescriptor.Singleton()); WebApplication webApp = builder.Build(); @@ -65,7 +69,7 @@ public async Task Run_WhenWaitFails_StillStopsAndDisposesWebApp() { var app = new InfiniFrameWebApplication { Logger = NullLogger.Instance, WebApp = webApp, - LazyWindow = new Lazy(() => mockWindow) + LazyWindow = new Lazy(() => mockWindow.Object) }; var exception = Assert.Throws(() => app.Run()); @@ -84,4 +88,4 @@ public void Dispose() { IsDisposed = true; } } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopAsyncTests.cs b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopAsyncTests.cs index aa84ce027..0a5e7e23e 100644 --- a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopAsyncTests.cs +++ b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopAsyncTests.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace InfiniTests.InfiniFrame.WebServer; // --------------------------------------------------------------------------------------------------------------------- @@ -17,24 +16,27 @@ public class InfiniFrameWebApplicationStopAsyncTests { [Test] public async Task StopAsync_ShouldStopWebAppAndCloseWindow(CancellationToken ct) { // Arrange - var mockWindow = Substitute.For(); - ILifecycleInfiniFrameWindowFeature lifecycle = mockWindow.Features.Lifecycle; + Mock mockWindow = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + mockWindow.Features.Returns(features.Object); + features.Lifecycle.Returns(lifecycle.Object); WebApplication webApp = WebApplication.CreateBuilder().Build(); var appLifetime = webApp.Services.GetRequiredService(); var app = new InfiniFrameWebApplication { Logger = NullLogger.Instance, WebApp = webApp, - LazyWindow = new Lazy(() => mockWindow) + LazyWindow = new Lazy(() => mockWindow.Object) }; // Act await app.StopAsync(ct); // Assert - await lifecycle.Received(1).CloseAsync(ct); - await lifecycle.Received(1).WaitForCloseAsync(ct); + lifecycle.CloseAsync(ct).WasCalled(Times.Once); + lifecycle.WaitForCloseAsync(ct).WasCalled(Times.Once); await Assert.That(appLifetime.ApplicationStopping.IsCancellationRequested).IsTrue(); await webApp.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopSyncTests.cs b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopSyncTests.cs index ba8ebf8b9..f3c4906ef 100644 --- a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopSyncTests.cs +++ b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationStopSyncTests.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace InfiniTests.InfiniFrame.WebServer; // --------------------------------------------------------------------------------------------------------------------- @@ -17,24 +16,27 @@ public class InfiniFrameWebApplicationStopSyncTests { [Test] public async Task Stop_ShouldStopWebAppAndCloseWindow() { // Arrange - var mockWindow = Substitute.For(); - ILifecycleInfiniFrameWindowFeature lifecycle = mockWindow.Features.Lifecycle; + Mock mockWindow = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + mockWindow.Features.Returns(features.Object); + features.Lifecycle.Returns(lifecycle.Object); WebApplication webApp = WebApplication.CreateBuilder().Build(); var appLifetime = webApp.Services.GetRequiredService(); var app = new InfiniFrameWebApplication { Logger = NullLogger.Instance, WebApp = webApp, - LazyWindow = new Lazy(() => mockWindow) + LazyWindow = new Lazy(() => mockWindow.Object) }; // Act app.Stop(); // Assert - await lifecycle.Received(1).CloseAsync(Arg.Any()); - await lifecycle.Received(1).WaitForCloseAsync(Arg.Any()); + lifecycle.CloseAsync(Any()).WasCalled(Times.Once); + lifecycle.WaitForCloseAsync(Any()).WasCalled(Times.Once); await Assert.That(appLifetime.ApplicationStopping.IsCancellationRequested).IsTrue(); await webApp.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationTests.cs b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationTests.cs index 5f4518db3..66457b387 100644 --- a/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationTests.cs +++ b/tests/InfiniTests.InfiniFrame.WebServer/InfiniFrameWebApplicationTests.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace InfiniTests.InfiniFrame.WebServer; // --------------------------------------------------------------------------------------------------------------------- @@ -17,12 +16,12 @@ namespace InfiniTests.InfiniFrame.WebServer; public class InfiniFrameWebApplicationTests { private const int DefaultGetMessageHandlerCount = 1; - private static IInfiniFrameWindow CreateMockWindow() { - var mockWindow = Substitute.For(); + private static (Mock Mock, IInfiniFrameWindow Object) CreateMockWindow() { + Mock mockWindow = MockFactory.CreateWindowMock(); var eventsStore = new InfiniFrameEventsStore(); mockWindow.Events.Returns(new InfiniFrameEvents(eventsStore, NullLogger.Instance)); mockWindow.EventsStore.Returns(eventsStore); - return mockWindow; + return (mockWindow, mockWindow.Object); } [Test] @@ -59,15 +58,15 @@ await Assert.That(builder.Services.Any(static descriptor => descriptor.ServiceTy [Test] public async Task UseAutoServerClose_WhenWindowNotCreated_ShouldRegisterWithBuilder() { // Arrange - var mockWindowBuilder = Substitute.For(); + Mock mockWindowBuilder = MockFactory.CreateWindowBuilderMock(); var mockBuilderEventsStore = new InfiniFrameEventsStore(); mockWindowBuilder.EventsStore.Returns(mockBuilderEventsStore); WebApplicationBuilder webAppBuilder = WebApplication.CreateBuilder(); - webAppBuilder.Services.AddSingleton(mockWindowBuilder); + webAppBuilder.Services.AddSingleton(mockWindowBuilder.Object); WebApplication webApp = webAppBuilder.Build(); - var lazyWindow = new Lazy(CreateMockWindow); + var lazyWindow = new Lazy(() => CreateMockWindow().Object); var app = new InfiniFrameWebApplication { Logger = NullLogger.Instance, @@ -86,7 +85,7 @@ public async Task UseAutoServerClose_WhenWindowNotCreated_ShouldRegisterWithBuil [Test] public async Task UseAutoServerClose_WhenWindowCreated_ShouldRegisterWithWindow() { // Arrange - IInfiniFrameWindow mockWindow = CreateMockWindow(); + (_, IInfiniFrameWindow mockWindow) = CreateMockWindow(); IInfiniFrameEvents mockEvents = mockWindow.Events; WebApplicationBuilder webAppBuilder = WebApplication.CreateBuilder(); @@ -113,7 +112,7 @@ public async Task UseAutoServerClose_WhenWindowCreated_ShouldRegisterWithWindow( [Test] public async Task UseAutoServerClose_ClosingHandler_ShouldReturnFalse() { // Arrange - IInfiniFrameWindow mockWindow = CreateMockWindow(); + (_, IInfiniFrameWindow mockWindow) = CreateMockWindow(); IInfiniFrameEvents mockEvents = mockWindow.Events; WebApplicationBuilder webAppBuilder = WebApplication.CreateBuilder(); @@ -141,7 +140,7 @@ public async Task UseAutoServerClose_ClosingHandler_ShouldReturnFalse() { [Test] public async Task UseAutoServerClose_ClosingHandler_ShouldInitiateStopAsync() { // Arrange - IInfiniFrameWindow mockWindow = CreateMockWindow(); + (_, IInfiniFrameWindow mockWindow) = CreateMockWindow(); IInfiniFrameEvents mockEvents = mockWindow.Events; WebApplicationBuilder webAppBuilder = WebApplication.CreateBuilder(); @@ -181,7 +180,7 @@ await Assert.That(appLifetime.ApplicationStopping.IsCancellationRequested) [Test] public async Task Window_Property_ShouldReturnLazyValue() { // Arrange - IInfiniFrameWindow mockWindow = CreateMockWindow(); + (_, IInfiniFrameWindow mockWindow) = CreateMockWindow(); WebApplicationBuilder webAppBuilder = WebApplication.CreateBuilder(); WebApplication webApp = webAppBuilder.Build(); @@ -200,4 +199,4 @@ public async Task Window_Property_ShouldReturnLazyValue() { await Assert.That(window).IsEqualTo(mockWindow); await Assert.That(lazyWindow.IsValueCreated).IsTrue(); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame.WebServer/InfiniTests.InfiniFrame.WebServer.csproj b/tests/InfiniTests.InfiniFrame.WebServer/InfiniTests.InfiniFrame.WebServer.csproj index c4c9d93a5..7c1276963 100644 --- a/tests/InfiniTests.InfiniFrame.WebServer/InfiniTests.InfiniFrame.WebServer.csproj +++ b/tests/InfiniTests.InfiniFrame.WebServer/InfiniTests.InfiniFrame.WebServer.csproj @@ -1,7 +1,10 @@ + + $(NoWarn);CS0105 + - + diff --git a/tests/InfiniTests.InfiniFrame/Events/CustomSchemeResponseValidatorTests.cs b/tests/InfiniTests.InfiniFrame/Events/CustomSchemeResponseValidatorTests.cs new file mode 100644 index 000000000..2522a4f9e --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Events/CustomSchemeResponseValidatorTests.cs @@ -0,0 +1,74 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Utilities; + +namespace InfiniTests.InfiniFrame.Events; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class CustomSchemeResponseValidatorTests { + + [Test] + public async Task ValidateContentType_Null_ReturnsDefault(CancellationToken ct = default) { + string result = CustomSchemeResponseValidator.ValidateContentType(null); + await Assert.That(result).IsEqualTo("application/octet-stream"); + } + + [Test] + public async Task ValidateContentType_Empty_ReturnsDefault(CancellationToken ct = default) { + string result = CustomSchemeResponseValidator.ValidateContentType(""); + await Assert.That(result).IsEqualTo("application/octet-stream"); + } + + [Test] + public async Task ValidateContentType_Whitespace_ReturnsDefault(CancellationToken ct = default) { + string result = CustomSchemeResponseValidator.ValidateContentType(" "); + await Assert.That(result).IsEqualTo("application/octet-stream"); + } + + [Test] + public async Task ValidateContentType_ValidContentType_ReturnsSame(CancellationToken ct = default) { + string result = CustomSchemeResponseValidator.ValidateContentType("text/html"); + await Assert.That(result).IsEqualTo("text/html"); + } + + [Test] + [Arguments("text/html\r")] + [Arguments("text/html\n")] + [Arguments("text/html\0")] + [Arguments("text/html\t")] + public async Task ValidateContentType_ControlCharacters_ThrowsInvalidDataException(string contentType, CancellationToken ct) { + await Assert.That(() => CustomSchemeResponseValidator.ValidateContentType(contentType)) + .Throws(); + } + + [Test] + public async Task ValidateContentType_VeryLongContentType_ThrowsInvalidDataException(CancellationToken ct = default) { + string longContentType = new string('a', 300); + await Assert.That(() => CustomSchemeResponseValidator.ValidateContentType(longContentType)) + .Throws(); + } + + [Test] + public async Task ValidateBodyLength_Null_DoesNotThrow(CancellationToken ct = default) { + await Assert.That(() => CustomSchemeResponseValidator.ValidateBodyLength(null)).ThrowsNothing(); + } + + [Test] + public async Task ValidateBodyLength_WithinLimit_DoesNotThrow(CancellationToken ct = default) { + await Assert.That(() => CustomSchemeResponseValidator.ValidateBodyLength(1024)).ThrowsNothing(); + } + + [Test] + public async Task ValidateBodyLength_Negative_ThrowsInvalidDataException(CancellationToken ct = default) { + await Assert.That(() => CustomSchemeResponseValidator.ValidateBodyLength(-1)) + .Throws(); + } + + [Test] + public async Task ValidateBodyLength_ExceedsLimit_ThrowsInvalidDataException(CancellationToken ct = default) { + await Assert.That(() => CustomSchemeResponseValidator.ValidateBodyLength(3 * 1024 * 1024)) + .Throws(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Events/InfiniFrameEventsStoreTests.cs b/tests/InfiniTests.InfiniFrame/Events/InfiniFrameEventsStoreTests.cs new file mode 100644 index 000000000..6a33025c1 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Events/InfiniFrameEventsStoreTests.cs @@ -0,0 +1,328 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Drawing; +using InfiniFrame; +using InfiniFrame.Debugging; + +namespace InfiniTests.InfiniFrame.Events; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameEventsStoreTests { + + [Test] + public async Task Constructor_AllEventsAreNotNull(CancellationToken ct = default) { + // Arrange & Act + var store = new InfiniFrameEventsStore(); + + // Assert + await Assert.That(store.WindowLocationChanged).IsNotNull(); + await Assert.That(store.WindowSizeChanged).IsNotNull(); + await Assert.That(store.WindowFocusIn).IsNotNull(); + await Assert.That(store.WindowMaximized).IsNotNull(); + await Assert.That(store.WindowRestored).IsNotNull(); + await Assert.That(store.WindowFocusOut).IsNotNull(); + await Assert.That(store.WindowMinimized).IsNotNull(); + await Assert.That(store.WindowClosingRequested).IsNotNull(); + await Assert.That(store.Closing).IsNotNull(); + await Assert.That(store.WindowClosed).IsNotNull(); + await Assert.That(store.WindowCreating).IsNotNull(); + await Assert.That(store.WindowCreated).IsNotNull(); + await Assert.That(store.WebMessageReceived).IsNotNull(); + await Assert.That(store.DebuggingEvent).IsNotNull(); + await Assert.That(store.WebMessagePostData).IsNotNull(); + await Assert.That(store.WebMessageGetData).IsNotNull(); + await Assert.That(store.FileDropped).IsNotNull(); + await Assert.That(store.CustomScheme).IsNotNull(); + await Assert.That(store.NavigationStarting).IsNotNull(); + } + + [Test] + public async Task CopyTo_CopiesWindowClosedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowClosed.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowClosed.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowClosingRequestedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowClosingRequested.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowClosingRequested.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowFocusInHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowFocusIn.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowFocusIn.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowFocusOutHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowFocusOut.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowFocusOut.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowMaximizedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowMaximized.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowMaximized.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowMinimizedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowMinimized.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowMinimized.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowRestoredHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowRestored.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowRestored.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowCreatingHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowCreating.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowCreating.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowCreatedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WindowCreated.Add(_ => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowCreated.Invoke(window); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWebMessageReceivedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.WebMessageReceived.Add((_, _) => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + var evt = new InfiniFrameWebMessageReceivedEvent("msg", "origin"); + target.WebMessageReceived.Invoke(window, evt); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesDebuggingEventHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.DebuggingEvent.Add((_, _) => handlerCalled = true); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + var evt = new InfiniFrameDebugEventArgs { + Kind = InfiniFrameDebugEventKind.ScriptError, + TimestampUtc = DateTime.UtcNow + }; + target.DebuggingEvent.Invoke(window, evt); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWindowLocationChangedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + Point? received = null; + source.WindowLocationChanged.Add((_, p) => received = p); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowLocationChanged.Invoke(window, new Point(100, 200)); + await Assert.That(received).IsEqualTo(new Point(100, 200)); + } + + [Test] + public async Task CopyTo_CopiesWindowSizeChangedHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + Size? received = null; + source.WindowSizeChanged.Add((_, s) => received = s); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WindowSizeChanged.Invoke(window, new Size(800, 600)); + await Assert.That(received).IsEqualTo(new Size(800, 600)); + } + + [Test] + public async Task CopyTo_CopiesClosingHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.Closing.Add((_, _) => { handlerCalled = true; return WindowClosingResult.Close; }); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.Closing.Invoke(window, EventArgs.Empty); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesNavigationStartingHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + bool handlerCalled = false; + source.NavigationStarting.Add((_, _) => { handlerCalled = true; return NavigationStartingResult.Allow; }); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + var args = new NavigationStartingEventArgs("https://example.com", false, false, true); + target.NavigationStarting.Invoke(window, args); + await Assert.That(handlerCalled).IsTrue(); + } + + [Test] + public async Task CopyTo_CopiesWebMessagePostDataHandlers(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + string? receivedValue = null; + source.WebMessagePostData.Add("test-key", (_, v) => receivedValue = v); + + // Act + source.CopyTo(target); + + // Assert + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; + target.WebMessagePostData.TryInvoke("test-key", window, "test-value"); + await Assert.That(receivedValue).IsEqualTo("test-value"); + } + + [Test] + public async Task CopyTo_EmptySource_DoesNotThrow(CancellationToken ct = default) { + // Arrange + var source = new InfiniFrameEventsStore(); + var target = new InfiniFrameEventsStore(); + + // Act & Assert + await Assert.That(() => source.CopyTo(target)).ThrowsNothing(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Features/Debugging/EndpointStatusResolverTests.cs b/tests/InfiniTests.InfiniFrame/Features/Debugging/EndpointStatusResolverTests.cs new file mode 100644 index 000000000..3b41d5fcc --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Features/Debugging/EndpointStatusResolverTests.cs @@ -0,0 +1,103 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Debugging; +using InfiniFrame.Utilities; + +namespace InfiniTests.InfiniFrame.Features.Debugging; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class EndpointStatusResolverTests { + + [Test] + public async Task Resolve_PlatformNotSupported_ReturnsNotSupported(CancellationToken ct = default) { + InfiniFrameDebugEndpointStatus result = EndpointStatusResolver.Resolve( + isPlatformSupported: false, + remoteDebuggingPort: 9222, + isWindowClosed: false, + hasEndpoint: true, + probeSucceeded: true, + probeReason: null + ); + await Assert.That(result).IsEqualTo(InfiniFrameDebugEndpointStatus.NotSupported); + } + + [Test] + public async Task Resolve_PortNull_ReturnsDisabled(CancellationToken ct = default) { + InfiniFrameDebugEndpointStatus result = EndpointStatusResolver.Resolve( + isPlatformSupported: true, + remoteDebuggingPort: null, + isWindowClosed: false, + hasEndpoint: true, + probeSucceeded: true, + probeReason: null + ); + await Assert.That(result).IsEqualTo(InfiniFrameDebugEndpointStatus.Disabled); + } + + [Test] + public async Task Resolve_WindowClosed_ReturnsUnavailable(CancellationToken ct = default) { + InfiniFrameDebugEndpointStatus result = EndpointStatusResolver.Resolve( + isPlatformSupported: true, + remoteDebuggingPort: 9222, + isWindowClosed: true, + hasEndpoint: true, + probeSucceeded: false, + probeReason: null + ); + await Assert.That(result).IsEqualTo(InfiniFrameDebugEndpointStatus.Unavailable); + } + + [Test] + public async Task Resolve_NoEndpoint_ReturnsUnavailable(CancellationToken ct = default) { + InfiniFrameDebugEndpointStatus result = EndpointStatusResolver.Resolve( + isPlatformSupported: true, + remoteDebuggingPort: 9222, + isWindowClosed: false, + hasEndpoint: false, + probeSucceeded: false, + probeReason: null + ); + await Assert.That(result).IsEqualTo(InfiniFrameDebugEndpointStatus.Unavailable); + } + + [Test] + public async Task Resolve_ProbeSucceeded_ReturnsReachable(CancellationToken ct = default) { + InfiniFrameDebugEndpointStatus result = EndpointStatusResolver.Resolve( + isPlatformSupported: true, + remoteDebuggingPort: 9222, + isWindowClosed: false, + hasEndpoint: true, + probeSucceeded: true, + probeReason: null + ); + await Assert.That(result).IsEqualTo(InfiniFrameDebugEndpointStatus.Reachable); + } + + [Test] + public async Task Resolve_ProbeFailed_EmptyReason_ReturnsConfigured(CancellationToken ct = default) { + InfiniFrameDebugEndpointStatus result = EndpointStatusResolver.Resolve( + isPlatformSupported: true, + remoteDebuggingPort: 9222, + isWindowClosed: false, + hasEndpoint: true, + probeSucceeded: false, + probeReason: "" + ); + await Assert.That(result).IsEqualTo(InfiniFrameDebugEndpointStatus.Configured); + } + + [Test] + public async Task Resolve_ProbeFailed_WithReason_ReturnsUnreachable(CancellationToken ct = default) { + InfiniFrameDebugEndpointStatus result = EndpointStatusResolver.Resolve( + isPlatformSupported: true, + remoteDebuggingPort: 9222, + isWindowClosed: false, + hasEndpoint: true, + probeSucceeded: false, + probeReason: "Connection refused" + ); + await Assert.That(result).IsEqualTo(InfiniFrameDebugEndpointStatus.Unreachable); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Features/Decorations/ColorUtilityTests.cs b/tests/InfiniTests.InfiniFrame/Features/Decorations/ColorUtilityTests.cs new file mode 100644 index 000000000..c88c832f7 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Features/Decorations/ColorUtilityTests.cs @@ -0,0 +1,186 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Utilities; + +namespace InfiniTests.InfiniFrame.Features.Decorations; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class ColorUtilityTests { + + [Test] + public async Task IsValidBackgroundColor_Null_ReturnsTrue(CancellationToken ct = default) { + bool result = ColorUtility.IsValidBackgroundColor(null); + await Assert.That(result).IsTrue(); + } + + [Test] + public async Task IsValidBackgroundColor_Transparent_ReturnsTrue(CancellationToken ct = default) { + bool result = ColorUtility.IsValidBackgroundColor("transparent"); + await Assert.That(result).IsTrue(); + } + + [Test] + [Arguments("#000000")] + [Arguments("#FFFFFF")] + [Arguments("#FF0000")] + [Arguments("#00FF00")] + [Arguments("#0000FF")] + [Arguments("#ABCDEF")] + [Arguments("#abcdef")] + public async Task IsValidBackgroundColor_ValidHex6_ReturnsTrue(string color, CancellationToken ct = default) { + bool result = ColorUtility.IsValidBackgroundColor(color); + await Assert.That(result).IsTrue(); + } + + [Test] + [Arguments("#80FF0000")] + [Arguments("#FFFFFFFF")] + [Arguments("#00000000")] + [Arguments("#AABBCCDD")] + public async Task IsValidBackgroundColor_ValidHex8_ReturnsTrue(string color, CancellationToken ct = default) { + bool result = ColorUtility.IsValidBackgroundColor(color); + await Assert.That(result).IsTrue(); + } + + [Test] + [Arguments("")] + [Arguments("red")] + [Arguments("rgb(255,0,0)")] + [Arguments("#FFF")] + [Arguments("#FFFFFFF")] + [Arguments("#GHIJKL")] + [Arguments("000000")] + public async Task IsValidBackgroundColor_Invalid_ReturnsFalse(string color, CancellationToken ct = default) { + bool result = ColorUtility.IsValidBackgroundColor(color); + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task ParseBackgroundColor_Null_ReturnsAllZero(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor(null, out byte r, out byte g, out byte b, out byte a); + + byte zero = 0; + await Assert.That(r).IsEqualTo(zero); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo(zero); + await Assert.That(a).IsEqualTo(zero); + } + + [Test] + public async Task ParseBackgroundColor_Transparent_ReturnsAllZero(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("transparent", out byte r, out byte g, out byte b, out byte a); + + byte zero = 0; + await Assert.That(r).IsEqualTo(zero); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo(zero); + await Assert.That(a).IsEqualTo(zero); + } + + [Test] + public async Task ParseBackgroundColor_Hex6_Black(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#000000", out byte r, out byte g, out byte b, out byte a); + + byte zero = 0; + byte ff = 255; + await Assert.That(r).IsEqualTo(zero); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo(zero); + await Assert.That(a).IsEqualTo(ff); + } + + [Test] + public async Task ParseBackgroundColor_Hex6_White(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#FFFFFF", out byte r, out byte g, out byte b, out byte a); + + byte ff = 255; + await Assert.That(r).IsEqualTo(ff); + await Assert.That(g).IsEqualTo(ff); + await Assert.That(b).IsEqualTo(ff); + await Assert.That(a).IsEqualTo(ff); + } + + [Test] + public async Task ParseBackgroundColor_Hex6_Red(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#FF0000", out byte r, out byte g, out byte b, out byte a); + + byte ff = 255; + byte zero = 0; + await Assert.That(r).IsEqualTo(ff); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo(zero); + await Assert.That(a).IsEqualTo(ff); + } + + [Test] + public async Task ParseBackgroundColor_Hex6_Green(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#00FF00", out byte r, out byte g, out byte b, out byte a); + + byte ff = 255; + byte zero = 0; + await Assert.That(r).IsEqualTo(zero); + await Assert.That(g).IsEqualTo(ff); + await Assert.That(b).IsEqualTo(zero); + await Assert.That(a).IsEqualTo(ff); + } + + [Test] + public async Task ParseBackgroundColor_Hex6_Blue(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#0000FF", out byte r, out byte g, out byte b, out byte a); + + byte ff = 255; + byte zero = 0; + await Assert.That(r).IsEqualTo(zero); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo(ff); + await Assert.That(a).IsEqualTo(ff); + } + + [Test] + public async Task ParseBackgroundColor_Hex8_WithAlpha(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#80FF0000", out byte r, out byte g, out byte b, out byte a); + + byte ff = 255; + byte zero = 0; + await Assert.That(a).IsEqualTo((byte)128); + await Assert.That(r).IsEqualTo(ff); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo(zero); + } + + [Test] + public async Task ParseBackgroundColor_Hex8_FullyTransparent(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#00000000", out byte r, out byte g, out byte b, out byte a); + + byte zero = 0; + await Assert.That(a).IsEqualTo(zero); + await Assert.That(r).IsEqualTo(zero); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo(zero); + } + + [Test] + public async Task ParseBackgroundColor_Lowercase_HandledCorrectly(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#ff00aa", out byte r, out byte g, out byte b, out byte a); + + byte ff = 255; + byte zero = 0; + await Assert.That(r).IsEqualTo(ff); + await Assert.That(g).IsEqualTo(zero); + await Assert.That(b).IsEqualTo((byte)170); + await Assert.That(a).IsEqualTo(ff); + } + + [Test] + public async Task ParseBackgroundColor_MixedCase_HandledCorrectly(CancellationToken ct = default) { + ColorUtility.ParseBackgroundColor("#FfAaBb", out byte r, out byte g, out byte b, out byte a); + + byte ff = 255; + await Assert.That(r).IsEqualTo(ff); + await Assert.That(g).IsEqualTo((byte)170); + await Assert.That(b).IsEqualTo((byte)187); + await Assert.That(a).IsEqualTo(ff); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Features/Menu/MenuItemTreeHelperTests.cs b/tests/InfiniTests.InfiniFrame/Features/Menu/MenuItemTreeHelperTests.cs new file mode 100644 index 000000000..ab6c198e5 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Features/Menu/MenuItemTreeHelperTests.cs @@ -0,0 +1,93 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Collections.Immutable; +using InfiniFrame; +using InfiniFrame.Utilities; + +namespace InfiniTests.InfiniFrame.Features.Menu; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class MenuItemTreeHelperTests { + + [Test] + public async Task UpdateItem_UpdatesMatchingItem(CancellationToken ct = default) { + // Arrange + var items = ImmutableArray.Create( + new InfiniFrameMenuItem(Id: "file", Label: "File"), + new InfiniFrameMenuItem(Id: "edit", Label: "Edit") + ); + + // Act + ImmutableArray result = MenuItemTreeHelper.UpdateItem(items, "edit", item => item with { Label = "Modified" }); + + // Assert + await Assert.That(result[0].Label).IsEqualTo("File"); + await Assert.That(result[1].Label).IsEqualTo("Modified"); + } + + [Test] + public async Task UpdateItem_MissingId_ReturnsUnchanged(CancellationToken ct = default) { + // Arrange + var items = ImmutableArray.Create( + new InfiniFrameMenuItem(Id: "file", Label: "File") + ); + + // Act + ImmutableArray result = MenuItemTreeHelper.UpdateItem(items, "nonexistent", item => item with { Label = "Changed" }); + + // Assert + await Assert.That(result[0].Label).IsEqualTo("File"); + } + + [Test] + public async Task UpdateItem_UpdatesNestedChild(CancellationToken ct = default) { + // Arrange + var items = ImmutableArray.Create( + new InfiniFrameMenuItem( + Id: "menu", + Label: "Menu", + Type: InfiniFrameMenuItemType.Submenu, + Children: ImmutableArray.Create( + new InfiniFrameMenuItem(Id: "item-a", Label: "A"), + new InfiniFrameMenuItem(Id: "item-b", Label: "B") + ) + ) + ); + + // Act + ImmutableArray result = MenuItemTreeHelper.UpdateItem(items, "item-b", item => item with { Label = "Modified B" }); + + // Assert + await Assert.That(result[0].Children[1].Label).IsEqualTo("Modified B"); + await Assert.That(result[0].Children[0].Label).IsEqualTo("A"); + } + + [Test] + public async Task UpdateItem_EmptyArray_ReturnsEmpty(CancellationToken ct = default) { + // Arrange + var items = ImmutableArray.Empty; + + // Act + ImmutableArray result = MenuItemTreeHelper.UpdateItem(items, "any", item => item with { Label = "Changed" }); + + // Assert + await Assert.That(result).IsEmpty(); + } + + [Test] + public async Task UpdateItem_DoesNotMutateOriginal(CancellationToken ct = default) { + // Arrange + var items = ImmutableArray.Create( + new InfiniFrameMenuItem(Id: "a", Label: "Original") + ); + + // Act + ImmutableArray result = MenuItemTreeHelper.UpdateItem(items, "a", item => item with { Label = "Changed" }); + + // Assert + await Assert.That(items[0].Label).IsEqualTo("Original"); + await Assert.That(result[0].Label).IsEqualTo("Changed"); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Features/Position/PositionCalculationsTests.cs b/tests/InfiniTests.InfiniFrame/Features/Position/PositionCalculationsTests.cs new file mode 100644 index 000000000..d6c049445 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Features/Position/PositionCalculationsTests.cs @@ -0,0 +1,197 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Drawing; +using InfiniFrame.Utilities; + +namespace InfiniTests.InfiniFrame.Features.Position; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class PositionCalculationsTests { + + // ----------------------------------------------------------------------------------------------------------------- + // ComputeCenter + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task ComputeCenter_WindowSmallerThanMonitor_CentersCorrectly(CancellationToken ct = default) { + // Arrange + var monitorArea = new Rectangle(0, 0, 1920, 1080); + + // Act + Point result = PositionCalculations.ComputeCenter(monitorArea, 800, 600); + + // Assert + await Assert.That(result.X).IsEqualTo(560); + await Assert.That(result.Y).IsEqualTo(240); + } + + [Test] + public async Task ComputeCenter_WindowSameSizeAsMonitor_ReturnsTopLeft(CancellationToken ct = default) { + // Arrange + var monitorArea = new Rectangle(0, 0, 1920, 1080); + + // Act + Point result = PositionCalculations.ComputeCenter(monitorArea, 1920, 1080); + + // Assert + await Assert.That(result.X).IsEqualTo(0); + await Assert.That(result.Y).IsEqualTo(0); + } + + [Test] + public async Task ComputeCenter_WindowLargerThanMonitor_ReturnsNegative(CancellationToken ct = default) { + // Arrange + var monitorArea = new Rectangle(0, 0, 1920, 1080); + + // Act + Point result = PositionCalculations.ComputeCenter(monitorArea, 2500, 1500); + + // Assert + await Assert.That(result.X).IsEqualTo(-290); + await Assert.That(result.Y).IsEqualTo(-210); + } + + [Test] + public async Task ComputeCenter_MonitorAtOffset_CentersWithinOffset(CancellationToken ct = default) { + // Arrange, second monitor at 1920,0 + var monitorArea = new Rectangle(1920, 0, 1920, 1080); + + // Act + Point result = PositionCalculations.ComputeCenter(monitorArea, 800, 600); + + // Assert + await Assert.That(result.X).IsEqualTo(2480); + await Assert.That(result.Y).IsEqualTo(240); + } + + [Test] + public async Task ComputeCenter_WindowSizeOnePixel_CentersCorrectly(CancellationToken ct = default) { + // Arrange + var monitorArea = new Rectangle(0, 0, 1920, 1080); + + // Act + Point result = PositionCalculations.ComputeCenter(monitorArea, 1, 1); + + // Assert + await Assert.That(result.X).IsEqualTo(960); + await Assert.That(result.Y).IsEqualTo(540); + } + + // ----------------------------------------------------------------------------------------------------------------- + // ClampToMonitorArea + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task ClampToMonitorArea_WithinBounds_NoChange(CancellationToken ct = default) { + // Arrange + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int left, int top) = PositionCalculations.ClampToMonitorArea(100, 100, 800, 600, workArea); + + // Assert + await Assert.That(left).IsEqualTo(100); + await Assert.That(top).IsEqualTo(100); + } + + [Test] + public async Task ClampToMonitorArea_ExceedsRightBound_ClampsToLeft(CancellationToken ct = default) { + // Arrange, window right edge at 100+2000=2100 > 1920 + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int left, int _) = PositionCalculations.ClampToMonitorArea(100, 100, 2000, 600, workArea); + + // Assert, clamped so right edge = 1920 => left = 1920 - 2000 = -80, but >= 0 so left = 0 + await Assert.That(left).IsEqualTo(0); + } + + [Test] + public async Task ClampToMonitorArea_ExceedsBottomBound_ClampsToTop(CancellationToken ct = default) { + // Arrange, window bottom edge at 100+1200=1300 > 1080 + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int _, int top) = PositionCalculations.ClampToMonitorArea(100, 100, 800, 1200, workArea); + + // Assert, clamped so bottom edge = 1080 => top = 1080 - 1200 = -120, but >= 0 so top = 0 + await Assert.That(top).IsEqualTo(0); + } + + [Test] + public async Task ClampToMonitorArea_NegativePosition_ClampsToPositive(CancellationToken ct = default) { + // Arrange + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int left, int top) = PositionCalculations.ClampToMonitorArea(-500, -300, 800, 600, workArea); + + // Assert + await Assert.That(left).IsEqualTo(0); + await Assert.That(top).IsEqualTo(0); + } + + [Test] + public async Task ClampToMonitorArea_WindowLargerThanWorkArea_ClampsToTopLeft(CancellationToken ct = default) { + // Arrange, window 2500x1500 > workArea 1920x1080 + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int left, int top) = PositionCalculations.ClampToMonitorArea(0, 0, 2500, 1500, workArea); + + // Assert + await Assert.That(left).IsEqualTo(0); + await Assert.That(top).IsEqualTo(0); + } + + [Test] + public async Task ClampToMonitorArea_MonitorWithOffset_RespectsOffset(CancellationToken ct = default) { + // Arrange, second monitor at 1920,0 with 1920x1080 work area + var workArea = new Rectangle(1920, 0, 1920, 1080); + + // Act + (int left, int top) = PositionCalculations.ClampToMonitorArea(2000, 100, 800, 600, workArea); + + // Assert, within bounds + await Assert.That(left).IsEqualTo(2000); + await Assert.That(top).IsEqualTo(100); + } + + [Test] + public async Task ClampToMonitorArea_AtExactRightBound_NoChange(CancellationToken ct = default) { + // Arrange, right edge = 1120 + 800 = 1920 (exactly at bound) + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int left, int _) = PositionCalculations.ClampToMonitorArea(1120, 100, 800, 600, workArea); + + // Assert + await Assert.That(left).IsEqualTo(1120); + } + + [Test] + public async Task ClampToMonitorArea_AtExactBottomBound_NoChange(CancellationToken ct = default) { + // Arrange, bottom edge = 480 + 600 = 1080 (exactly at bound) + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int _, int top) = PositionCalculations.ClampToMonitorArea(100, 480, 800, 600, workArea); + + // Assert + await Assert.That(top).IsEqualTo(480); + } + + [Test] + public async Task ClampToMonitorArea_ZeroWindowSize_PositionClampsToBounds(CancellationToken ct = default) { + // Arrange, position 5000 exceeds right bound 1920, but window width is 0 + // rightBound - windowWidth = 1920 - 0 = 1920, Math.Max(1920, 0) = 1920 + var workArea = new Rectangle(0, 0, 1920, 1080); + + // Act + (int left, int top) = PositionCalculations.ClampToMonitorArea(5000, 5000, 0, 0, workArea); + + // Assert + await Assert.That(left).IsEqualTo(1920); + await Assert.That(top).IsEqualTo(1080); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Features/Size/SizeCalculationsTests.cs b/tests/InfiniTests.InfiniFrame/Features/Size/SizeCalculationsTests.cs new file mode 100644 index 000000000..f52144717 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Features/Size/SizeCalculationsTests.cs @@ -0,0 +1,215 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrame.Utilities; + +namespace InfiniTests.InfiniFrame.Features.Size; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class SizeCalculationsTests { + + // ----------------------------------------------------------------------------------------------------------------- + // ComputeResize + // ----------------------------------------------------------------------------------------------------------------- + [Test] + [Arguments(0, 0, 800, 600, 100, 50, ResizeOrigin.TopLeft, 100, 50, 700, 550)] + [Arguments(0, 0, 800, 600, -50, -30, ResizeOrigin.TopLeft, -50, -30, 850, 630)] + [Arguments(0, 0, 800, 600, 0, 50, ResizeOrigin.Top, 0, 50, 800, 550)] + [Arguments(0, 0, 800, 600, 100, 50, ResizeOrigin.TopRight, 0, 50, 900, 550)] + [Arguments(0, 0, 800, 600, 100, 0, ResizeOrigin.Right, 0, 0, 900, 600)] + [Arguments(0, 0, 800, 600, 100, 50, ResizeOrigin.BottomRight, 0, 0, 900, 650)] + [Arguments(0, 0, 800, 600, 0, 50, ResizeOrigin.Bottom, 0, 0, 800, 650)] + [Arguments(0, 0, 800, 600, 100, 50, ResizeOrigin.BottomLeft, 100, 0, 700, 650)] + [Arguments(0, 0, 800, 600, 100, 0, ResizeOrigin.Left, 100, 0, 700, 600)] + public async Task ComputeResize_VariousOrigins_ReturnsCorrectBounds( + int origX, int origY, int origW, int origH, + int widthOffset, int heightOffset, ResizeOrigin origin, + int expectedX, int expectedY, int expectedW, int expectedH, + CancellationToken ct = default + ) { + // Arrange & Act + (int x, int y, int w, int h) = SizeCalculations.ComputeResize( + origX, origY, origW, origH, widthOffset, heightOffset, origin + ); + + // Assert + await Assert.That(x).IsEqualTo(expectedX); + await Assert.That(y).IsEqualTo(expectedY); + await Assert.That(w).IsEqualTo(expectedW); + await Assert.That(h).IsEqualTo(expectedH); + } + + [Test] + public async Task ComputeResize_FromPosition100_200_AddsOffsetCorrectly(CancellationToken ct = default) { + // Arrange & Act + (int x, int y, int w, int h) = SizeCalculations.ComputeResize( + 100, 200, 800, 600, 50, 30, ResizeOrigin.TopLeft + ); + + // Assert + await Assert.That(x).IsEqualTo(150); + await Assert.That(y).IsEqualTo(230); + await Assert.That(w).IsEqualTo(750); + await Assert.That(h).IsEqualTo(570); + } + + [Test] + public async Task ComputeResize_InvalidOrigin_ThrowsArgumentOutOfRangeException(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(() => SizeCalculations.ComputeResize(0, 0, 800, 600, 10, 10, (ResizeOrigin)99)) + .Throws(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // ClampResize + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task ClampResize_WidthExceedsMax_ClampsWidthAndResetsX(CancellationToken ct = default) { + // Arrange & Act + (int x, int _, int w, int _) = SizeCalculations.ClampResize( + x: 50, y: 50, width: 2000, height: 600, + originalX: 100, originalY: 100, + minSize: new System.Drawing.Size(100, 100), maxSize: new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(w).IsEqualTo(1920); + await Assert.That(x).IsEqualTo(100); + } + + [Test] + public async Task ClampResize_HeightExceedsMax_ClampsHeightAndResetsY(CancellationToken ct = default) { + // Arrange & Act + (int _, int y, int _, int h) = SizeCalculations.ClampResize( + x: 50, y: 50, width: 800, height: 5000, + originalX: 100, originalY: 100, + minSize: new System.Drawing.Size(100, 100), maxSize: new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(h).IsEqualTo(1080); + await Assert.That(y).IsEqualTo(100); + } + + [Test] + public async Task ClampResize_WidthBelowMin_ClampsWidthAndResetsX(CancellationToken ct = default) { + // Arrange & Act + (int x, int _, int w, int _) = SizeCalculations.ClampResize( + x: 50, y: 50, width: 10, height: 600, + originalX: 100, originalY: 100, + minSize: new System.Drawing.Size(200, 200), maxSize: new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(w).IsEqualTo(200); + await Assert.That(x).IsEqualTo(100); + } + + [Test] + public async Task ClampResize_HeightBelowMin_ClampsHeightAndResetsY(CancellationToken ct = default) { + // Arrange & Act + (int _, int y, int _, int h) = SizeCalculations.ClampResize( + x: 50, y: 50, width: 800, height: 10, + originalX: 100, originalY: 100, + minSize: new System.Drawing.Size(200, 200), maxSize: new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(h).IsEqualTo(200); + await Assert.That(y).IsEqualTo(100); + } + + [Test] + public async Task ClampResize_WithinBounds_NoChange(CancellationToken ct = default) { + // Arrange & Act + (int x, int y, int w, int h) = SizeCalculations.ClampResize( + x: 50, y: 50, width: 800, height: 600, + originalX: 100, originalY: 100, + minSize: new System.Drawing.Size(100, 100), maxSize: new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(x).IsEqualTo(50); + await Assert.That(y).IsEqualTo(50); + await Assert.That(w).IsEqualTo(800); + await Assert.That(h).IsEqualTo(600); + } + + [Test] + public async Task ClampResize_AtExactMin_ClampsPositionToOriginal(CancellationToken ct = default) { + // Arrange, width equals min => position resets to originalX + (int x, int y, int w, int h) = SizeCalculations.ClampResize( + x: 0, y: 0, width: 200, height: 200, + originalX: 100, originalY: 100, + minSize: new System.Drawing.Size(200, 200), maxSize: new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(w).IsEqualTo(200); + await Assert.That(h).IsEqualTo(200); + await Assert.That(x).IsEqualTo(100); + await Assert.That(y).IsEqualTo(100); + } + + [Test] + public async Task ClampResize_AtExactMax_ClampsPositionToOriginal(CancellationToken ct = default) { + // Arrange, width equals max => position resets to originalX + (int x, int y, int w, int h) = SizeCalculations.ClampResize( + x: 0, y: 0, width: 1920, height: 1080, + originalX: 100, originalY: 100, + minSize: new System.Drawing.Size(100, 100), maxSize: new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(w).IsEqualTo(1920); + await Assert.That(h).IsEqualTo(1080); + await Assert.That(x).IsEqualTo(100); + await Assert.That(y).IsEqualTo(100); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Integration: ComputeResize + ClampResize + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task ComputeResize_TopLeft_ThenClamp_WithinBounds(CancellationToken ct = default) { + // Arrange + (int x, int y, int w, int h) = SizeCalculations.ComputeResize( + 100, 100, 800, 600, 50, 50, ResizeOrigin.TopLeft + ); + + // Act + (x, y, w, h) = SizeCalculations.ClampResize( + x, y, w, h, 100, 100, + new System.Drawing.Size(100, 100), new System.Drawing.Size(1920, 1080) + ); + + // Assert + await Assert.That(x).IsEqualTo(150); + await Assert.That(y).IsEqualTo(150); + await Assert.That(w).IsEqualTo(750); + await Assert.That(h).IsEqualTo(550); + } + + [Test] + public async Task ComputeResize_TopLeft_ThenClamp_ExceedsMax(CancellationToken ct = default) { + // Arrange, resize from TopLeft by 2000 in a 800x600 window + // ComputeResize: x=100+2000=2100, y=100+2000=2100, w=800-2000=-1200, h=600-2000=-1400 + (int x, int y, int w, int h) = SizeCalculations.ComputeResize( + 100, 100, 800, 600, 2000, 2000, ResizeOrigin.TopLeft + ); + + // Act + (x, y, w, h) = SizeCalculations.ClampResize( + x, y, w, h, 100, 100, + new System.Drawing.Size(100, 100), new System.Drawing.Size(1920, 1080) + ); + + // Assert, clamped to min (since w/h went negative), position reset to original + await Assert.That(w).IsEqualTo(100); + await Assert.That(h).IsEqualTo(100); + await Assert.That(x).IsEqualTo(100); + await Assert.That(y).IsEqualTo(100); + } +} diff --git a/tests/InfiniTests.InfiniFrame/InfiniTests.InfiniFrame.csproj b/tests/InfiniTests.InfiniFrame/InfiniTests.InfiniFrame.csproj index e0776f026..41ecca766 100644 --- a/tests/InfiniTests.InfiniFrame/InfiniTests.InfiniFrame.csproj +++ b/tests/InfiniTests.InfiniFrame/InfiniTests.InfiniFrame.csproj @@ -1,6 +1,10 @@ + + $(NoWarn);CS0105 + + diff --git a/tests/InfiniTests.InfiniFrame/Interop/InteropEnvelopeProtocolCreateTests.cs b/tests/InfiniTests.InfiniFrame/Interop/InteropEnvelopeProtocolCreateTests.cs new file mode 100644 index 000000000..564dd5821 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Interop/InteropEnvelopeProtocolCreateTests.cs @@ -0,0 +1,95 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Interop; + +namespace InfiniTests.InfiniFrame.Interop; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InteropEnvelopeProtocolCreateTests { + + [Test] + public async Task CreateEnvelopeMessage_DefaultCommand_IsPost(CancellationToken ct = default) { + // Arrange & Act + string message = InteropEnvelopeProtocol.CreateEnvelopeMessage("test-id"); + + // Assert + await Assert.That(message).Contains("\"command\":\"Post\""); + } + + [Test] + public async Task CreateEnvelopeMessage_WithGetCommand(CancellationToken ct = default) { + // Arrange & Act + string message = InteropEnvelopeProtocol.CreateEnvelopeMessage("test-id", command: "Get"); + + // Assert + await Assert.That(message).Contains("\"command\":\"Get\""); + } + + [Test] + public async Task CreateEnvelopeMessage_IncludesVersion(CancellationToken ct = default) { + // Arrange & Act + string message = InteropEnvelopeProtocol.CreateEnvelopeMessage("test-id"); + + // Assert + await Assert.That(message).Contains("\"version\":2"); + } + + [Test] + public async Task CreateEnvelopeMessage_NullData_WritesNull(CancellationToken ct = default) { + // Arrange & Act + string message = InteropEnvelopeProtocol.CreateEnvelopeMessage("test-id", data: null); + + // Assert + await Assert.That(message).Contains("\"data\":null"); + } + + [Test] + public async Task CreateEnvelopeMessage_WithData_WritesString(CancellationToken ct = default) { + // Arrange & Act + string message = InteropEnvelopeProtocol.CreateEnvelopeMessage("test-id", data: "hello world"); + + // Assert + await Assert.That(message).Contains("\"data\":\"hello world\""); + } + + [Test] + public async Task CreateEnvelopeMessage_WithRequestId_IncludesRequestId(CancellationToken ct = default) { + // Arrange & Act + string message = InteropEnvelopeProtocol.CreateEnvelopeMessage("test-id", requestId: "req-123"); + + // Assert + await Assert.That(message).Contains("\"requestId\":\"req-123\""); + } + + [Test] + public async Task CreateEnvelopeMessage_WithoutRequestId_OmitsRequestId(CancellationToken ct = default) { + // Arrange & Act + string message = InteropEnvelopeProtocol.CreateEnvelopeMessage("test-id"); + + // Assert + await Assert.That(message).DoesNotContain("requestId"); + } + + [Test] + public async Task CreateEnvelopeMessage_EmptyId_ThrowsArgumentException(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(() => InteropEnvelopeProtocol.CreateEnvelopeMessage("")) + .Throws(); + } + + [Test] + public async Task CreateEnvelopeMessage_NullId_ThrowsArgumentException(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(() => InteropEnvelopeProtocol.CreateEnvelopeMessage(null!)) + .Throws(); + } + + [Test] + public async Task CreateEnvelopeMessage_EmptyCommand_ThrowsArgumentException(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That(() => InteropEnvelopeProtocol.CreateEnvelopeMessage("id", command: "")) + .Throws(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Interop/InteropEnvelopeProtocolEdgeCaseTests.cs b/tests/InfiniTests.InfiniFrame/Interop/InteropEnvelopeProtocolEdgeCaseTests.cs new file mode 100644 index 000000000..00f5ae1f9 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Interop/InteropEnvelopeProtocolEdgeCaseTests.cs @@ -0,0 +1,217 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Interop; + +namespace InfiniTests.InfiniFrame.Interop; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InteropEnvelopeProtocolEdgeCaseTests { + + [Test] + public async Task ParseEmptyMessage_ReturnsFailure(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(""); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseWhitespaceMessage_ReturnsFailure(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(" "); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseBlazorMessage_ReturnsBlazor(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage("__bwv:some-data"); + + // Assert + await Assert.That(result.IsBlazor).IsTrue(); + } + + [Test] + public async Task ParseNonJsonObject_ReturnsFailure(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage("not-json"); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseJsonArray_ReturnsFailure(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage("[]"); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseMissingId_ReturnsFailure(CancellationToken ct = default) { + // Arrange + string message = """{"command":"Post","version":2}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseEmptyId_ReturnsFailure(CancellationToken ct = default) { + // Arrange + string message = """{"id":"","command":"Post","version":2}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseMissingVersion_ReturnsFailure(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Post"}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseWrongVersion_ReturnsFailure(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Post","version":1}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).Contains("Unsupported envelope version"); + } + + [Test] + public async Task ParseMissingCommand_ReturnsFailure(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","version":2}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseUnsupportedCommand_ReturnsFailure(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Delete","version":2}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).Contains("must be 'Post' or 'Get'"); + } + + [Test] + public async Task ParseMalformedJson_ReturnsFailure(CancellationToken ct = default) { + // Arrange & Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage("{broken"); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseNonStringRequestId_ReturnsFailure(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Post","version":2,"requestId":123}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + } + + [Test] + public async Task ParseJsonObjectData_ReturnsRawText(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Post","version":2,"data":{"key":"value"}}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Payload).Contains("key"); + } + + [Test] + public async Task ParseStringData_ReturnsStringValue(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Post","version":2,"data":"hello"}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Payload).IsEqualTo("hello"); + } + + [Test] + public async Task ParseNullData_ReturnsNullPayload(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Post","version":2,"data":null}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Payload).IsNull(); + } + + [Test] + public async Task ParseNoData_ReturnsNullPayload(CancellationToken ct = default) { + // Arrange + string message = """{"id":"test","command":"Post","version":2}"""; + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(message); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Payload).IsNull(); + } + + [Test] + public async Task ParseJsonEncodedString_UnwrapsAndParses(CancellationToken ct = default) { + // Arrange, a JSON-encoded string containing a valid envelope + string innerEnvelope = """{"id":"test","command":"Post","version":2,"data":"hello"}"""; + string encoded = System.Text.Json.JsonSerializer.Serialize(innerEnvelope); + + // Act + InteropEnvelopeParseResult result = InteropEnvelopeProtocol.ParseIncomingMessage(encoded); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.MessageId).IsEqualTo("test"); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Interop/WindowRegistrationStateMachineTests.cs b/tests/InfiniTests.InfiniFrame/Interop/WindowRegistrationStateMachineTests.cs new file mode 100644 index 000000000..b87e86e87 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Interop/WindowRegistrationStateMachineTests.cs @@ -0,0 +1,113 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Interop; + +namespace InfiniTests.InfiniFrame.Interop; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class WindowRegistrationStateMachineTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task InitialState_IsReadyPending(CancellationToken ct = default) { + // Arrange + var stateMachine = new WindowRegistrationStateMachine(); + + // Act & Assert + await Assert.That(stateMachine.IsReadyPending()).IsTrue(); + } + + [Test] + public async Task TryBeginRegistrationSendOnReady_WhenReadyPending_ShouldReturnTrue(CancellationToken ct = default) { + // Arrange + var stateMachine = new WindowRegistrationStateMachine(); + + // Act + bool result = stateMachine.TryBeginRegistrationSendOnReady(); + + // Assert + await Assert.That(result).IsTrue(); + } + + [Test] + public async Task TryBeginRegistrationSendOnReady_WhenAlreadyInProgress_ShouldReturnFalse(CancellationToken ct = default) { + // Arrange + var stateMachine = new WindowRegistrationStateMachine(); + stateMachine.TryBeginRegistrationSendOnReady(); + + // Act + bool result = stateMachine.TryBeginRegistrationSendOnReady(); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task CompleteRegistrationSend_Success_ShouldMakeReadyPendingFalse(CancellationToken ct = default) { + // Arrange + var stateMachine = new WindowRegistrationStateMachine(); + stateMachine.TryBeginRegistrationSendOnReady(); + + // Act + stateMachine.CompleteRegistrationSend(true); + + // Assert + await Assert.That(stateMachine.IsReadyPending()).IsFalse(); + } + + [Test] + public async Task CompleteRegistrationSend_Failure_ShouldMakeReadyPendingFalse(CancellationToken ct = default) { + // Arrange + var stateMachine = new WindowRegistrationStateMachine(); + stateMachine.TryBeginRegistrationSendOnReady(); + + // Act + stateMachine.CompleteRegistrationSend(false); + + // Assert + await Assert.That(stateMachine.IsReadyPending()).IsFalse(); + } + + [Test] + public async Task CompleteRegistrationSend_Success_CanBeginNewRegistration(CancellationToken ct = default) { + // Arrange + var stateMachine = new WindowRegistrationStateMachine(); + stateMachine.TryBeginRegistrationSendOnReady(); + stateMachine.CompleteRegistrationSend(true); + + // Act - Should not be able to begin again since it's acknowledged + bool result = stateMachine.TryBeginRegistrationSendOnReady(); + + // Assert + await Assert.That(result).IsFalse(); + } + + [Test] + public async Task CompleteRegistrationSend_Failure_CanBeginNewRegistration(CancellationToken ct = default) { + // Arrange + var stateMachine = new WindowRegistrationStateMachine(); + stateMachine.TryBeginRegistrationSendOnReady(); + stateMachine.CompleteRegistrationSend(false); + + // Act + bool result = stateMachine.TryBeginRegistrationSendOnReady(); + + // Assert + await Assert.That(result).IsTrue(); + } + + [Test] + public async Task WindowRegistrationState_ShouldExposeStateMachine(CancellationToken ct = default) { + // Arrange + + // Act + var state = new WindowRegistrationState(); + + // Assert + await Assert.That(state.StateMachine).IsNotNull(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Security/InfiniFrameUriSecurityPolicyBuilderTests.cs b/tests/InfiniTests.InfiniFrame/Security/InfiniFrameUriSecurityPolicyBuilderTests.cs new file mode 100644 index 000000000..9ceca3d66 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Security/InfiniFrameUriSecurityPolicyBuilderTests.cs @@ -0,0 +1,268 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame.Security; + +namespace InfiniTests.InfiniFrame.Security; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameUriSecurityPolicyBuilderTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Constructor + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Constructor_NoBasePolicy_UsesDefault(CancellationToken ct = default) { + // Arrange & Act + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Assert, default policy allows app scheme + InfiniFrameUriSecurityPolicy policy = builder.Build(); + await Assert.That(policy.IsNavigationSchemeAllowed("app")).IsTrue(); + } + + [Test] + public async Task Constructor_WithBasePolicy_CopiesSettings(CancellationToken ct = default) { + // Arrange + var basePolicy = new InfiniFrameUriSecurityPolicy( + [Uri.UriSchemeHttps], + [Uri.UriSchemeMailto], + [new Uri("https://trusted.example/")] + ); + + // Act + var builder = new InfiniFrameUriSecurityPolicyBuilder(basePolicy); + InfiniFrameUriSecurityPolicy policy = builder.Build(); + + // Assert + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeHttps)).IsTrue(); + await Assert.That(policy.IsNavigationSchemeAllowed("app")).IsFalse(); + await Assert.That(policy.IsExternalSchemeAllowed(Uri.UriSchemeMailto)).IsTrue(); + await Assert.That(policy.IsTrustedOrigin(new Uri("https://trusted.example/path"))).IsTrue(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // SetAllowedNavigationSchemes + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task SetAllowedNavigationSchemes_ReplacesExistingSchemes(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .SetAllowedNavigationSchemes([Uri.UriSchemeHttps, Uri.UriSchemeFtp]) + .Build(); + + // Assert + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeHttps)).IsTrue(); + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeFtp)).IsTrue(); + await Assert.That(policy.IsNavigationSchemeAllowed("app")).IsFalse(); + } + + [Test] + public async Task SetAllowedNavigationSchemes_IgnoresNullOrWhitespace(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .SetAllowedNavigationSchemes([null!, "", " ", Uri.UriSchemeHttps]) + .Build(); + + // Assert + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeHttps)).IsTrue(); + await Assert.That(policy.AllowedNavigationSchemes.Count).IsEqualTo(1); + } + + // ----------------------------------------------------------------------------------------------------------------- + // SetAllowedExternalSchemes + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task SetAllowedExternalSchemes_ReplacesExistingSchemes(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .SetAllowedExternalSchemes([Uri.UriSchemeMailto]) + .Build(); + + // Assert + await Assert.That(policy.IsExternalSchemeAllowed(Uri.UriSchemeMailto)).IsTrue(); + await Assert.That(policy.IsExternalSchemeAllowed(Uri.UriSchemeHttps)).IsFalse(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // AllowNavigationScheme + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task AllowNavigationScheme_AddsScheme(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .AllowNavigationScheme(Uri.UriSchemeHttps) + .AllowNavigationScheme(Uri.UriSchemeFtp) + .Build(); + + // Assert + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeHttps)).IsTrue(); + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeFtp)).IsTrue(); + } + + [Test] + public async Task AllowNavigationScheme_IgnoresNull(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act & Assert + await Assert.That(() => builder.AllowNavigationScheme(null!)).ThrowsNothing(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // AllowExternalScheme + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task AllowExternalScheme_AddsScheme(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .AllowExternalScheme(Uri.UriSchemeMailto) + .Build(); + + // Assert + await Assert.That(policy.IsExternalSchemeAllowed(Uri.UriSchemeMailto)).IsTrue(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // SetTrustedOrigins + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task SetTrustedOrigins_ReplacesExistingOrigins(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .SetTrustedOrigins([new Uri("https://one.example/"), new Uri("https://two.example/")]) + .SetAllowedNavigationSchemes([Uri.UriSchemeHttps]) + .Build(); + + // Assert + await Assert.That(policy.IsTrustedOrigin(new Uri("https://one.example/path"))).IsTrue(); + await Assert.That(policy.IsTrustedOrigin(new Uri("https://two.example/path"))).IsTrue(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // AddTrustedOrigin + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task AddTrustedOrigin_AbsoluteUri_AddsOrigin(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .AddTrustedOrigin(new Uri("https://example.com")) + .SetAllowedNavigationSchemes([Uri.UriSchemeHttps]) + .Build(); + + // Assert + await Assert.That(policy.IsTrustedOrigin(new Uri("https://example.com/path"))).IsTrue(); + } + + [Test] + public async Task AddTrustedOrigin_RelativeUri_Ignores(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .AddTrustedOrigin(new Uri("/relative", UriKind.Relative)) + .Build(); + + // Assert + await Assert.That(policy.TrustedOrigins.Count).IsEqualTo(0); + } + + // ----------------------------------------------------------------------------------------------------------------- + // SetTrustAllOrigins + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task SetTrustAllOrigins_True_TrustsAnyOrigin(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .SetAllowedNavigationSchemes([Uri.UriSchemeHttps]) + .SetTrustAllOrigins() + .Build(); + + // Assert + await Assert.That(policy.TrustAllOrigins).IsTrue(); + await Assert.That(policy.IsTrustedOrigin(new Uri("https://anywhere.example/path"))).IsTrue(); + } + + [Test] + public async Task SetTrustAllOrigins_False_DoesNotTrustAll(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .SetTrustAllOrigins(false) + .Build(); + + // Assert + await Assert.That(policy.TrustAllOrigins).IsFalse(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Build + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Build_ReturnsPolicyWithConfiguredValues(CancellationToken ct = default) { + // Arrange + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + + // Act + InfiniFrameUriSecurityPolicy policy = builder + .SetAllowedNavigationSchemes([Uri.UriSchemeHttps]) + .SetAllowedExternalSchemes([Uri.UriSchemeMailto]) + .AddTrustedOrigin(new Uri("https://trusted.example/")) + .SetTrustAllOrigins() + .Build(); + + // Assert + await Assert.That(policy.AllowedNavigationSchemes).Contains(Uri.UriSchemeHttps); + await Assert.That(policy.AllowedExternalSchemes).Contains(Uri.UriSchemeMailto); + await Assert.That(policy.TrustedOrigins.Count).IsEqualTo(1); + await Assert.That(policy.TrustAllOrigins).IsTrue(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Chaining + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task AllMethods_ReturnBuilder_ForChaining(CancellationToken ct = default) { + // Arrange & Act + var builder = new InfiniFrameUriSecurityPolicyBuilder(); + InfiniFrameUriSecurityPolicyBuilder result = builder + .SetAllowedNavigationSchemes([Uri.UriSchemeHttps]) + .SetAllowedExternalSchemes([Uri.UriSchemeMailto]) + .AllowNavigationScheme(Uri.UriSchemeFtp) + .AllowExternalScheme("custom") + .SetTrustedOrigins([new Uri("https://example.com/")]) + .AddTrustedOrigin(new Uri("https://other.com/")) + .SetTrustAllOrigins(); + + // Assert + await Assert.That(result).IsSameReferenceAs(builder); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Security/InfiniFrameUriSecurityPolicyRegistryTests.cs b/tests/InfiniTests.InfiniFrame/Security/InfiniFrameUriSecurityPolicyRegistryTests.cs new file mode 100644 index 000000000..49778b62c --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Security/InfiniFrameUriSecurityPolicyRegistryTests.cs @@ -0,0 +1,160 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrame.Security; +using InfiniTests.Substitutes; + +namespace InfiniTests.InfiniFrame.Security; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameUriSecurityPolicyRegistryTests { + + [Test] + public async Task GetForBuilder_NullBuilder_ThrowsArgumentNullException(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That( + () => InfiniFrameUriSecurityPolicyRegistry.GetForBuilder(null!) + ).Throws(); + } + + [Test] + public async Task GetForBuilder_NewBuilder_ReturnsDefaultPolicy(CancellationToken ct = default) { + // Arrange + var builder = InfiniFrameWindowBuilder.Create(); + + // Act + IInfiniFrameUriSecurityPolicy policy = InfiniFrameUriSecurityPolicyRegistry.GetForBuilder(builder); + + // Assert + await Assert.That(policy).IsNotNull(); + await Assert.That(policy.IsNavigationSchemeAllowed("app")).IsTrue(); + } + + [Test] + public async Task ConfigureForBuilder_NullBuilder_ThrowsArgumentNullException(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That( + () => InfiniFrameUriSecurityPolicyRegistry.ConfigureForBuilder(null!, _ => { }) + ).Throws(); + } + + [Test] + public async Task ConfigureForBuilder_NullConfigure_ThrowsArgumentNullException(CancellationToken ct = default) { + // Arrange + var builder = InfiniFrameWindowBuilder.Create(); + + // Act & Assert + await Assert.That( + () => InfiniFrameUriSecurityPolicyRegistry.ConfigureForBuilder(builder, null!) + ).Throws(); + } + + [Test] + public async Task GetForWindow_NullWindow_ThrowsArgumentNullException(CancellationToken ct = default) { + // Arrange & Act & Assert + await Assert.That( + () => InfiniFrameUriSecurityPolicyRegistry.GetForWindow(null!) + ).Throws(); + } + + [Test] + public async Task GetForWindow_UnboundWindow_ReturnsDefaultPolicy(CancellationToken ct = default) { + // Arrange + var window = new RecordingInfiniFrameWindowSubstitute(); + + // Act + IInfiniFrameUriSecurityPolicy policy = InfiniFrameUriSecurityPolicyRegistry.GetForWindow(window.Window); + + // Assert + await Assert.That(policy).IsNotNull(); + await Assert.That(policy.IsNavigationSchemeAllowed("app")).IsTrue(); + } + + [Test] + public async Task BindToWindow_NullWindow_ThrowsArgumentNullException(CancellationToken ct = default) { + // Arrange + var policy = InfiniFrameUriSecurityPolicy.Default; + + // Act & Assert + await Assert.That( + () => InfiniFrameUriSecurityPolicyRegistry.BindToWindow(null!, policy) + ).Throws(); + } + + [Test] + public async Task BindToWindow_NullPolicy_ThrowsArgumentNullException(CancellationToken ct = default) { + // Arrange + var window = new RecordingInfiniFrameWindowSubstitute(); + + // Act & Assert + await Assert.That( + () => InfiniFrameUriSecurityPolicyRegistry.BindToWindow(window.Window, null!) + ).Throws(); + } + + [Test] + public async Task BindToWindow_ThenGet_ReturnsBoundPolicy(CancellationToken ct = default) { + // Arrange + var window = new RecordingInfiniFrameWindowSubstitute(); + var customPolicy = new InfiniFrameUriSecurityPolicy( + [Uri.UriSchemeHttps], + [], + [] + ); + + // Act + InfiniFrameUriSecurityPolicyRegistry.BindToWindow(window.Window, customPolicy); + IInfiniFrameUriSecurityPolicy retrieved = InfiniFrameUriSecurityPolicyRegistry.GetForWindow(window.Window); + + // Assert + await Assert.That(retrieved).IsSameReferenceAs(customPolicy); + } + + [Test] + public async Task BindToWindow_MultipleCalls_OverwritesPreviousPolicy(CancellationToken ct = default) { + // Arrange + var window = new RecordingInfiniFrameWindowSubstitute(); + var policy1 = new InfiniFrameUriSecurityPolicy([Uri.UriSchemeHttps], [], []); + var policy2 = new InfiniFrameUriSecurityPolicy([Uri.UriSchemeFtp], [], []); + + // Act + InfiniFrameUriSecurityPolicyRegistry.BindToWindow(window.Window, policy1); + InfiniFrameUriSecurityPolicyRegistry.BindToWindow(window.Window, policy2); + IInfiniFrameUriSecurityPolicy retrieved = InfiniFrameUriSecurityPolicyRegistry.GetForWindow(window.Window); + + // Assert + await Assert.That(retrieved).IsSameReferenceAs(policy2); + } + + [Test] + public async Task ConfigureForBuilder_MultipleCalls_ApplyCumulatively(CancellationToken ct = default) { + // Arrange + var builder = InfiniFrameWindowBuilder.Create(); + + // Act + InfiniFrameUriSecurityPolicyRegistry.ConfigureForBuilder(builder, b => b + .SetAllowedNavigationSchemes([Uri.UriSchemeHttps])); + InfiniFrameUriSecurityPolicyRegistry.ConfigureForBuilder(builder, b => b + .AllowNavigationScheme(Uri.UriSchemeFtp)); + IInfiniFrameUriSecurityPolicy policy = InfiniFrameUriSecurityPolicyRegistry.GetForBuilder(builder); + + // Assert + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeHttps)).IsTrue(); + await Assert.That(policy.IsNavigationSchemeAllowed(Uri.UriSchemeFtp)).IsTrue(); + } + + [Test] + public async Task GetForBuilder_ReturnsSameInstanceForSameBuilder(CancellationToken ct = default) { + // Arrange + var builder = InfiniFrameWindowBuilder.Create(); + + // Act + IInfiniFrameUriSecurityPolicy policy1 = InfiniFrameUriSecurityPolicyRegistry.GetForBuilder(builder); + IInfiniFrameUriSecurityPolicy policy2 = InfiniFrameUriSecurityPolicyRegistry.GetForBuilder(builder); + + // Assert + await Assert.That(policy1).IsSameReferenceAs(policy2); + } +} diff --git a/tests/InfiniTests.InfiniFrame/StaticAssets/FileProviderFactoryTests.cs b/tests/InfiniTests.InfiniFrame/StaticAssets/FileProviderFactoryTests.cs new file mode 100644 index 000000000..00520a1dc --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/StaticAssets/FileProviderFactoryTests.cs @@ -0,0 +1,73 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Reflection; +using InfiniFrame.StaticAssets; +using Microsoft.Extensions.FileProviders; + +namespace InfiniTests.InfiniFrame.StaticAssets; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class FileProviderFactoryTests { + + [Test] + public async Task CreateWwwrootProvider_WithAssembly_ReturnsCompositeProvider(CancellationToken ct = default) { + // Arrange + Assembly assembly = typeof(FileProviderFactory).Assembly; + + // Act + IFileProvider provider = FileProviderFactory.CreateWwwrootProvider( + assembly: assembly, + includePhysicalFallback: false + ); + + // Assert + await Assert.That(provider).IsNotNull(); + await Assert.That(provider).IsTypeOf(); + } + + [Test] + public async Task CreateWwwrootProvider_WithoutPhysicalFallback_ReturnsCompositeProvider(CancellationToken ct = default) { + // Arrange + Assembly assembly = typeof(FileProviderFactory).Assembly; + + // Act + IFileProvider provider = FileProviderFactory.CreateWwwrootProvider( + assembly: assembly, + includePhysicalFallback: false + ); + + // Assert + await Assert.That(provider).IsTypeOf(); + } + + [Test] + public async Task CreateWwwrootProvider_NullAssembly_UsesDefaultAssembly(CancellationToken ct = default) { + // Arrange & Act + IFileProvider provider = FileProviderFactory.CreateWwwrootProvider( + assembly: null, + includePhysicalFallback: false + ); + + // Assert + await Assert.That(provider).IsNotNull(); + } + + [Test] + public async Task CreateWwwrootProvider_NonExistentPhysicalPath_ReturnsCompositeProvider(CancellationToken ct = default) { + // Arrange + Assembly assembly = typeof(FileProviderFactory).Assembly; + string nonExistentPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "wwwroot"); + + // Act + IFileProvider provider = FileProviderFactory.CreateWwwrootProvider( + assembly: assembly, + physicalWwwrootPath: nonExistentPath, + includePhysicalFallback: true + ); + + // Assert + await Assert.That(provider).IsTypeOf(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/StaticAssets/InfiniFrameStaticAssetsTests.cs b/tests/InfiniTests.InfiniFrame/StaticAssets/InfiniFrameStaticAssetsTests.cs new file mode 100644 index 000000000..0cc113a4d --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/StaticAssets/InfiniFrameStaticAssetsTests.cs @@ -0,0 +1,67 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrame.StaticAssets; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace InfiniTests.InfiniFrame.StaticAssets; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameStaticAssetsTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task DeepCopy_ShouldReturnNewInstanceWithSameValues(CancellationToken ct = default) { + // Arrange + var provider = new TestFileProvider(); + var assets = new InfiniFrameStaticAssets { + FileProvider = provider, + BaseUri = "app://localhost/", + DefaultDocument = "index.html" + }; + + // Act + IInfiniFrameStaticAssets copy = assets.DeepCopy(); + + // Assert + await Assert.That(copy).IsNotSameReferenceAs(assets); + await Assert.That(copy.FileProvider).IsSameReferenceAs(provider); + await Assert.That(copy.BaseUri).IsEqualTo("app://localhost/"); + await Assert.That(copy.DefaultDocument).IsEqualTo("index.html"); + } + + [Test] + public async Task Properties_ShouldBeSettable(CancellationToken ct = default) { + // Arrange + var provider = new TestFileProvider(); + + // Act + var assets = new InfiniFrameStaticAssets { + FileProvider = provider, + BaseUri = "custom://host/", + DefaultDocument = "home.html" + }; + + // Assert + await Assert.That(assets.FileProvider).IsSameReferenceAs(provider); + await Assert.That(assets.BaseUri).IsEqualTo("custom://host/"); + await Assert.That(assets.DefaultDocument).IsEqualTo("home.html"); + } + + private sealed class TestFileProvider : IFileProvider { + public IDirectoryContents GetDirectoryContents(string subpath) => new TestDirectoryContents(); + public IFileInfo GetFileInfo(string subpath) => new NotFoundFileInfo(subpath); + public IChangeToken Watch(string filter) => NullChangeToken.Singleton; + } + + private sealed class TestDirectoryContents : IDirectoryContents { + public bool Exists => false; + public IEnumerator GetEnumerator() => Enumerable.Empty().GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/WebMessaging/WindowFeatureDrawingJsonConverterTests.cs b/tests/InfiniTests.InfiniFrame/WebMessaging/WindowFeatureDrawingJsonConverterTests.cs new file mode 100644 index 000000000..cc28054cc --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/WebMessaging/WindowFeatureDrawingJsonConverterTests.cs @@ -0,0 +1,181 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Text.Json; +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.WebMessaging; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +[SuppressMessage("ReSharper", "AccessToDisposedClosure")] +public class WindowFeatureDrawingJsonConverterTests { + + private static JsonSerializerOptions CreateOptions() { + var options = new JsonSerializerOptions(); + options.Converters.Add(new PointWebMessageJsonConverter()); + options.Converters.Add(new SizeWebMessageJsonConverter()); + options.Converters.Add(new RectangleWebMessageJsonConverter()); + return options; + } + + // ----------------------------------------------------------------------------------------------------------------- + // Point Converter + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Point_RoundTrip(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + var point = new Point(100, 200); + + // Act + string json = JsonSerializer.Serialize(point, options); + Point deserialized = JsonSerializer.Deserialize(json, options); + + // Assert + await Assert.That(deserialized.X).IsEqualTo(100); + await Assert.That(deserialized.Y).IsEqualTo(200); + } + + [Test] + public async Task Point_MissingX_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + string json = """{"y": 10}"""; + + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize(json, options)) + .Throws(); + } + + [Test] + public async Task Point_MissingY_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + string json = """{"x": 10}"""; + + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize(json, options)) + .Throws(); + } + + [Test] + public async Task Point_WrongType_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + string json = """{"x": "not-a-number", "y": 10}"""; + + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize(json, options)) + .Throws(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Size Converter + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Size_RoundTrip(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + var size = new Size(800, 600); + + // Act + string json = JsonSerializer.Serialize(size, options); + Size deserialized = JsonSerializer.Deserialize(json, options); + + // Assert + await Assert.That(deserialized.Width).IsEqualTo(800); + await Assert.That(deserialized.Height).IsEqualTo(600); + } + + [Test] + public async Task Size_MissingWidth_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + string json = """{"height": 10}"""; + + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize(json, options)) + .Throws(); + } + + [Test] + public async Task Size_MissingHeight_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + string json = """{"width": 10}"""; + + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize(json, options)) + .Throws(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // Rectangle Converter + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Rectangle_RoundTrip(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + var rect = new Rectangle(10, 20, 300, 400); + + // Act + string json = JsonSerializer.Serialize(rect, options); + Rectangle deserialized = JsonSerializer.Deserialize(json, options); + + // Assert + await Assert.That(deserialized.X).IsEqualTo(10); + await Assert.That(deserialized.Y).IsEqualTo(20); + await Assert.That(deserialized.Width).IsEqualTo(300); + await Assert.That(deserialized.Height).IsEqualTo(400); + } + + [Test] + public async Task Rectangle_MissingProperty_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + JsonSerializerOptions options = CreateOptions(); + string json = """{"x": 0, "y": 0, "width": 100}"""; + + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize(json, options)) + .Throws(); + } + + // ----------------------------------------------------------------------------------------------------------------- + // RequiredInt helper + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task RequiredInt_NonObjectValue_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + const string json = "42"; + using JsonDocument doc = JsonDocument.Parse(json); + + // Act & Assert + await Assert.That(() => PointWebMessageJsonConverter.RequiredInt(doc.RootElement, "x")) + .Throws(); + } + + [Test] + public async Task RequiredInt_MissingProperty_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + const string json = """{"y": 10}"""; + using JsonDocument doc = JsonDocument.Parse(json); + + // Act & Assert + await Assert.That(() => PointWebMessageJsonConverter.RequiredInt(doc.RootElement, "x")) + .Throws(); + } + + [Test] + public async Task RequiredInt_NonIntegerValue_ThrowsJsonException(CancellationToken ct = default) { + // Arrange + const string json = """{"x": "hello"}"""; + using JsonDocument doc = JsonDocument.Parse(json); + + // Act & Assert + await Assert.That(() => PointWebMessageJsonConverter.RequiredInt(doc.RootElement, "x")) + .Throws(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponseCorsPipelineTests.cs b/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponseCorsPipelineTests.cs index 0fe318af0..fa724f914 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponseCorsPipelineTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponseCorsPipelineTests.cs @@ -5,7 +5,6 @@ using InfiniFrame.NativeBridge; using InfiniFrame.NativeBridge.Delegates; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using System.Runtime.InteropServices; namespace InfiniTests.InfiniFrame.Window.Events; @@ -14,25 +13,20 @@ namespace InfiniTests.InfiniFrame.Window.Events; // --------------------------------------------------------------------------------------------------------------------- [NotInParallelInfiniTests] public class CustomSchemeResponseCorsPipelineTests { - [Test] public async Task Callback_SameOriginRequest_ProducesResponseWithCorsHeaders(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => ( new MemoryStream([.. "test"u8]), "application/json")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://localhost/data.json", ref response); try { await Assert.That(handled).IsEqualTo(1); string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; - // Build headers via native function with same origin InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( contentType, "app://localhost/data.json", "app://localhost", out IntPtr headers); try { - // Assert await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; await Assert.That(headerString).Contains("Content-Type: application/json"); @@ -51,22 +45,18 @@ public async Task Callback_SameOriginRequest_ProducesResponseWithCorsHeaders(Can [Test] public async Task Callback_CrossOriginRequest_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => ( new MemoryStream([.. "test"u8]), "application/json")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://localhost/data.json", ref response); try { await Assert.That(handled).IsEqualTo(1); string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; - // Build headers via native function with different origin InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( contentType, "app://localhost/data.json", "https://example.com", out IntPtr headers); try { - // Assert await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; await Assert.That(headerString).Contains("Content-Type: application/json"); @@ -84,22 +74,18 @@ public async Task Callback_CrossOriginRequest_ProducesResponseWithoutCorsHeaders [Test] public async Task Callback_NullOrigin_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => ( new MemoryStream([.. "test"u8]), "text/html")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://localhost/page.html", ref response); try { await Assert.That(handled).IsEqualTo(1); string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; - // Build headers via native function with empty origin InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( contentType, "app://localhost/page.html", "", out IntPtr headers); try { - // Assert await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); @@ -115,22 +101,18 @@ public async Task Callback_NullOrigin_ProducesResponseWithoutCorsHeaders(Cancell [Test] public async Task Callback_DifferentPorts_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => ( new MemoryStream([.. "test"u8]), "application/octet-stream")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://localhost/data.bin", ref response); try { await Assert.That(handled).IsEqualTo(1); string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; - // Build headers via native function with different port (same host) InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( contentType, "app://localhost/data.bin", "app://localhost:8080", out IntPtr headers); try { - // Assert await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); @@ -146,22 +128,18 @@ public async Task Callback_DifferentPorts_ProducesResponseWithoutCorsHeaders(Can [Test] public async Task Callback_DifferentSchemes_ProducesResponseWithoutCorsHeaders(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => ( new MemoryStream([.. "test"u8]), "text/plain")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://localhost/page.txt", ref response); try { await Assert.That(handled).IsEqualTo(1); string contentType = Marshal.PtrToStringUTF8(response.ContentTypeUtf8)!; - // Build headers via native function with different scheme InfiniFrameNativeInteropStatus status = InfiniFrameNativeTesting.BuildHeaders( contentType, "app://localhost/page.txt", "http://localhost", out IntPtr headers); try { - // Assert await Assert.That(status).IsEqualTo(InfiniFrameNativeInteropStatus.Success); string headerString = InfiniFrameNative.MarshalNativeToString(headers)!; await Assert.That(headerString).DoesNotContain("Access-Control-Allow-Origin"); @@ -177,13 +155,11 @@ public async Task Callback_DifferentSchemes_ProducesResponseWithoutCorsHeaders(C [Test] public async Task Callback_SubpathRequests_AreSameOrigin(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => ( new MemoryStream([.. "test"u8]), "text/html")); var responseA = new CustomSchemeResponse(); var responseB = new CustomSchemeResponse(); - // Act int handledA = events.OnCustomScheme("app://localhost/a", ref responseA); int handledB = events.OnCustomScheme("app://localhost/b", ref responseB); try { @@ -191,13 +167,11 @@ public async Task Callback_SubpathRequests_AreSameOrigin(CancellationToken ct = await Assert.That(handledB).IsEqualTo(1); string contentType = Marshal.PtrToStringUTF8(responseA.ContentTypeUtf8)!; - // Both subpaths should be same-origin relative to app://localhost InfiniFrameNativeInteropStatus statusA = InfiniFrameNativeTesting.BuildHeaders( contentType, "app://localhost/a", "app://localhost", out IntPtr headersA); InfiniFrameNativeInteropStatus statusB = InfiniFrameNativeTesting.BuildHeaders( contentType, "app://localhost/b", "app://localhost", out IntPtr headersB); try { - // Assert await Assert.That(statusA).IsEqualTo(InfiniFrameNativeInteropStatus.Success); await Assert.That(statusB).IsEqualTo(InfiniFrameNativeInteropStatus.Success); string headerStringA = InfiniFrameNative.MarshalNativeToString(headersA)!; @@ -222,15 +196,14 @@ private static InfiniFrameEvents CreateEvents( var store = new InfiniFrameEventsStore(); store.CustomScheme.Add("app", handler); var events = new InfiniFrameEvents(store, NullLogger.Instance); - var window = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); window.Id.Returns(Guid.NewGuid()); - events.AssignToWindow(window); + events.AssignToWindow(window.Object); return events; } private static void Release(ref CustomSchemeResponse response) { if (response.OwnerContext == IntPtr.Zero) return; - var release = Marshal.GetDelegateForFunctionPointer(response.Release); release(response.OwnerContext); response = default; diff --git a/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponsePipelineTests.cs b/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponsePipelineTests.cs index aced7b691..045e9def5 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponsePipelineTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Events/CustomSchemeResponsePipelineTests.cs @@ -4,7 +4,6 @@ using InfiniFrame; using InfiniFrame.NativeBridge.Delegates; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using System.Reflection; using System.Runtime.InteropServices; @@ -16,15 +15,12 @@ namespace InfiniTests.InfiniFrame.Window.Events; public class CustomSchemeResponsePipelineTests { [Test] public async Task Callback_ProducesVersionedOwnedUtf8Response(CancellationToken ct = default) { - // Arrange byte[] expected = [0, 1, 2, 255]; InfiniFrameEvents events = CreateEvents((_, _) => (new MemoryStream(expected), "application/test")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://asset", ref response); try { - // Assert await Assert.That(handled).IsEqualTo(1); await Assert.That(response.StructSize).IsEqualTo((uint)Marshal.SizeOf()); await Assert.That(response.AbiVersion).IsEqualTo(CustomSchemeResponse.CurrentAbiVersion); @@ -43,14 +39,11 @@ public async Task Callback_ProducesVersionedOwnedUtf8Response(CancellationToken [Test] public async Task Callback_EmptyBodyStillHasOneExplicitOwner(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => (new MemoryStream(), null)); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://empty", ref response); try { - // Assert await Assert.That(handled).IsEqualTo(1); await Assert.That(response.ContentLength).IsEqualTo(0UL); await Assert.That(response.Body).IsEqualTo(IntPtr.Zero); @@ -64,16 +57,13 @@ public async Task Callback_EmptyBodyStillHasOneExplicitOwner(CancellationToken c [Test] public async Task Callback_RejectsOversizedSeekableStreamWithoutAllocating(CancellationToken ct = default) { - // Arrange long before = GetActiveAllocationCount(); InfiniFrameEvents events = CreateEvents((_, _) => (new DeclaredLengthStream( checked((long)CustomSchemeResponse.MaxBufferedBodyBytes + 1)), "application/octet-stream")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://too-large", ref response); - // Assert await Assert.That(handled).IsEqualTo(0); await Assert.That(response.OwnerContext).IsEqualTo(IntPtr.Zero); await Assert.That(GetActiveAllocationCount()).IsEqualTo(before); @@ -81,15 +71,12 @@ public async Task Callback_RejectsOversizedSeekableStreamWithoutAllocating(Cance [Test] public async Task Callback_RejectsHeaderInjectionAndDoesNotLeak(CancellationToken ct = default) { - // Arrange long before = GetActiveAllocationCount(); InfiniFrameEvents events = CreateEvents((_, _) => (new MemoryStream([1]), "text/plain\r\nInjected: yes")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://invalid", ref response); - // Assert await Assert.That(handled).IsEqualTo(0); await Assert.That(response.OwnerContext).IsEqualTo(IntPtr.Zero); await Assert.That(GetActiveAllocationCount()).IsEqualTo(before); @@ -97,36 +84,29 @@ public async Task Callback_RejectsHeaderInjectionAndDoesNotLeak(CancellationToke [Test] public async Task Callback_HandlerExceptionNeverCrossesAbiBoundary(CancellationToken ct = default) { - // Arrange InfiniFrameEvents events = CreateEvents((_, _) => throw new InvalidOperationException("boom")); var response = new CustomSchemeResponse(); - // Act int handled = events.OnCustomScheme("app://throws", ref response); - // Assert await Assert.That(handled).IsEqualTo(0); await Assert.That(response).IsEqualTo(default); } [Test] public async Task Callback_RepeatedRequestsReleaseEveryAllocation(CancellationToken ct = default) { - // Arrange const int requestCount = 10_000; long before = GetActiveAllocationCount(); InfiniFrameEvents events = CreateEvents((_, _) => ( new MemoryStream([.. "stress-response"u8]), "text/plain")); - // Act for (int i = 0; i < requestCount; i++) { var response = new CustomSchemeResponse(); int handled = events.OnCustomScheme($"app://stress/{i}", ref response); if (handled != 1) throw new InvalidOperationException($"Request {i} was not handled."); - Release(ref response); } - // Assert await Assert.That(GetActiveAllocationCount()).IsEqualTo(before); } @@ -136,15 +116,14 @@ private static InfiniFrameEvents CreateEvents( var store = new InfiniFrameEventsStore(); store.CustomScheme.Add("app", handler); var events = new InfiniFrameEvents(store, NullLogger.Instance); - var window = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); window.Id.Returns(Guid.NewGuid()); - events.AssignToWindow(window); + events.AssignToWindow(window.Object); return events; } private static void Release(ref CustomSchemeResponse response) { if (response.OwnerContext == IntPtr.Zero) return; - var release = Marshal.GetDelegateForFunctionPointer(response.Release); release(response.OwnerContext); response = default; @@ -166,4 +145,4 @@ public override void Flush() { } public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Events/EventExceptionPolicyTests.cs b/tests/InfiniTests.InfiniFrame/Window/Events/EventExceptionPolicyTests.cs index e22ed22ed..fbed34cbd 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Events/EventExceptionPolicyTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Events/EventExceptionPolicyTests.cs @@ -2,8 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; - namespace InfiniTests.InfiniFrame.Window.Events; // --------------------------------------------------------------------------------------------------------------------- // Code @@ -13,7 +11,7 @@ public class EventExceptionPolicyTests { public async Task OrderedResultEvent_HandlerException_PropagatesAndStopsDispatch(CancellationToken ct = default) { // Arrange var eventSource = new OrderedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; int invoked = 0; eventSource.Add((_, _) => throw new InvalidOperationException("expected")); eventSource.Add((_, _) => ++invoked); @@ -28,7 +26,7 @@ await Assert.That(() => eventSource.Invoke(window, "payload")) public async Task KeyedEvent_HandlerException_Propagates(CancellationToken ct = default) { // Arrange var eventSource = new KeyedEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; eventSource.Add("key", handler: (_, _) => throw new InvalidOperationException("expected")); // Act & Assert @@ -40,7 +38,7 @@ await Assert.That(() => eventSource.TryInvoke("key", window, "payload")) public async Task KeyedResultEvent_NullResult_IsAHandledRequest(CancellationToken ct = default) { // Arrange var eventSource = new KeyedResultEvent(); - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; eventSource.Add("key", handler: static (_, _) => null); // Act @@ -50,4 +48,4 @@ public async Task KeyedResultEvent_NullResult_IsAHandledRequest(CancellationToke await Assert.That(handled).IsTrue(); await Assert.That(result).IsNull(); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Events/InfiniFrameEventsCallbackLifetimeTests.cs b/tests/InfiniTests.InfiniFrame/Window/Events/InfiniFrameEventsCallbackLifetimeTests.cs index 6c8851cd4..c007a6bdb 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Events/InfiniFrameEventsCallbackLifetimeTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Events/InfiniFrameEventsCallbackLifetimeTests.cs @@ -3,7 +3,6 @@ // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using System.Collections.Concurrent; using System.Reflection; @@ -17,7 +16,7 @@ public class InfiniFrameEventsCallbackLifetimeTests { public async Task AssignToWindow_AddsNativeCallbackRoot_ReleaseRemovesIt(CancellationToken ct = default) { // Arrange var events = new InfiniFrameEvents(new InfiniFrameEventsStore(), NullLogger.Instance); - var window = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); var windowId = Guid.NewGuid(); window.Id.Returns(windowId); @@ -25,7 +24,7 @@ public async Task AssignToWindow_AddsNativeCallbackRoot_ReleaseRemovesIt(Cancell roots.TryRemove(windowId, out _); // Act - events.AssignToWindow(window); + events.AssignToWindow(window.Object); // Assert await Assert.That(roots.TryGetValue(windowId, out InfiniFrameEvents? rootedEvents)).IsTrue(); @@ -40,8 +39,8 @@ public async Task AssignToWindow_AddsNativeCallbackRoot_ReleaseRemovesIt(Cancell public async Task AssignToWindow_WhenReassigned_MovesNativeCallbackRootToNewWindow(CancellationToken ct = default) { // Arrange var events = new InfiniFrameEvents(new InfiniFrameEventsStore(), NullLogger.Instance); - var firstWindow = Substitute.For(); - var secondWindow = Substitute.For(); + Mock firstWindow = MockFactory.CreateWindowMock(); + Mock secondWindow = MockFactory.CreateWindowMock(); var firstId = Guid.NewGuid(); var secondId = Guid.NewGuid(); firstWindow.Id.Returns(firstId); @@ -52,8 +51,8 @@ public async Task AssignToWindow_WhenReassigned_MovesNativeCallbackRootToNewWind roots.TryRemove(secondId, out _); // Act - events.AssignToWindow(firstWindow); - events.AssignToWindow(secondWindow); + events.AssignToWindow(firstWindow.Object); + events.AssignToWindow(secondWindow.Object); // Assert await Assert.That(roots.ContainsKey(firstId)).IsFalse(); @@ -68,7 +67,6 @@ public async Task AssignToWindow_WhenReassigned_MovesNativeCallbackRootToNewWind private static ConcurrentDictionary GetNativeCallbackRoots() { FieldInfo field = typeof(InfiniFrameEvents) .GetField("NativeCallbackRoots", BindingFlags.Static | BindingFlags.NonPublic)!; - return (ConcurrentDictionary)field.GetValue(null)!; } @@ -76,7 +74,6 @@ private static void InvokeReleaseNativeCallbackRoot(InfiniFrameEvents events) { MethodInfo method = typeof(InfiniFrameEvents) .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) .Single(static candidate => candidate.Name.EndsWith("ReleaseNativeCallbackRoot", StringComparison.Ordinal)); - method.Invoke(events, null); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Events/RegisterWebMessageReceivedHandlerTests.cs b/tests/InfiniTests.InfiniFrame/Window/Events/RegisterWebMessageReceivedHandlerTests.cs index ee1fb22b6..9fd76e326 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Events/RegisterWebMessageReceivedHandlerTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Events/RegisterWebMessageReceivedHandlerTests.cs @@ -2,8 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; - namespace InfiniTests.InfiniFrame.Window.Events; // --------------------------------------------------------------------------------------------------------------------- // Code @@ -11,11 +9,10 @@ namespace InfiniTests.InfiniFrame.Window.Events; public class RegisterWebMessageReceivedHandlerTests { [Test] public async Task AtBuilderStage_HandlerWithService_ResolvesServiceFromWindowServiceProvider(CancellationToken ct = default) { - // Arrange var eventsStore = new InfiniFrameEventsStore(); var builder = InfiniFrameWindowBuilder.Create(events: eventsStore); var service = new TestService(); - var window = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); window.ServiceProvider.Returns(new TestServiceProvider(service)); var tcs = new TaskCompletionSource<(string ServiceId, string Message)>(); @@ -23,10 +20,8 @@ public async Task AtBuilderStage_HandlerWithService_ResolvesServiceFromWindowSer tcs.TrySetResult((resolvedService.Id, message)); }); - // Act - eventsStore.WebMessageReceived.Invoke(window, new InfiniFrameWebMessageReceivedEvent("ping", null)); + eventsStore.WebMessageReceived.Invoke(window.Object, new InfiniFrameWebMessageReceivedEvent("ping", null)); - // Assert (string ServiceId, string Message) result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(1)); await Assert.That(result.ServiceId).IsEqualTo(service.Id); await Assert.That(result.Message).IsEqualTo("ping"); @@ -34,18 +29,15 @@ public async Task AtBuilderStage_HandlerWithService_ResolvesServiceFromWindowSer [Test] public async Task AtBuilderStage_HandlerWithOrigin_ReceivesOriginFromEventPayload(CancellationToken ct = default) { - // Arrange var eventsStore = new InfiniFrameEventsStore(); var builder = InfiniFrameWindowBuilder.Create(events: eventsStore); - var window = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); var tcs = new TaskCompletionSource(); builder.RegisterWebMessageReceivedHandler((_, _, origin) => tcs.TrySetResult(origin)); - // Act - eventsStore.WebMessageReceived.Invoke(window, new InfiniFrameWebMessageReceivedEvent("ping", "https://example.test")); + eventsStore.WebMessageReceived.Invoke(window.Object, new InfiniFrameWebMessageReceivedEvent("ping", "https://example.test")); - // Assert string? origin = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(1)); await Assert.That(origin).IsEqualTo("https://example.test"); } @@ -56,12 +48,10 @@ private sealed class TestService { private sealed class TestServiceProvider : IServiceProvider { private readonly Dictionary _services; - public TestServiceProvider(params object[] services) { _services = services.ToDictionary(keySelector: static service => service.GetType(), elementSelector: static service => service); } - public object? GetService(Type serviceType) => _services.TryGetValue(serviceType, out object? service) ? service : null; } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Features/Decorations/BackgroundColorTests.cs b/tests/InfiniTests.InfiniFrame/Window/Features/Decorations/BackgroundColorTests.cs index 2f960b269..e5431073b 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Features/Decorations/BackgroundColorTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Features/Decorations/BackgroundColorTests.cs @@ -2,6 +2,7 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; +using InfiniFrame.Utilities; namespace InfiniTests.InfiniFrame.Window.Features.Decorations; // --------------------------------------------------------------------------------------------------------------------- @@ -111,7 +112,7 @@ await Assert.That(() => window.Features.Decorations.SetBackgroundColor("invalid" [Arguments("#80FF0000", (byte)255, (byte)0, (byte)0, (byte)128)] [Arguments("#00000000", (byte)0, (byte)0, (byte)0, (byte)0)] public async Task ParseBackgroundColor_ParsesHexCorrectly(string hex, byte expectedR, byte expectedG, byte expectedB, byte expectedA, CancellationToken ct) { - DecorationsInfiniFrameWindowFeature.ParseBackgroundColor(hex, out byte r, out byte g, out byte b, out byte a); + ColorUtility.ParseBackgroundColor(hex, out byte r, out byte g, out byte b, out byte a); await Assert.That(r).IsEqualTo(expectedR); await Assert.That(g).IsEqualTo(expectedG); @@ -121,7 +122,7 @@ public async Task ParseBackgroundColor_ParsesHexCorrectly(string hex, byte expec [Test] public async Task ParseBackgroundColor_Transparent_ReturnsZeros(CancellationToken ct) { - DecorationsInfiniFrameWindowFeature.ParseBackgroundColor("transparent", out byte r, out byte g, out byte b, out byte a); + ColorUtility.ParseBackgroundColor("transparent", out byte r, out byte g, out byte b, out byte a); await Assert.That(r).IsEqualTo((byte)0); await Assert.That(g).IsEqualTo((byte)0); @@ -131,7 +132,7 @@ public async Task ParseBackgroundColor_Transparent_ReturnsZeros(CancellationToke [Test] public async Task ParseBackgroundColor_Null_ReturnsZeros(CancellationToken ct) { - DecorationsInfiniFrameWindowFeature.ParseBackgroundColor(null, out byte r, out byte g, out byte b, out byte a); + ColorUtility.ParseBackgroundColor(null, out byte r, out byte g, out byte b, out byte a); await Assert.That(r).IsEqualTo((byte)0); await Assert.That(g).IsEqualTo((byte)0); @@ -145,7 +146,7 @@ public async Task ParseBackgroundColor_Null_ReturnsZeros(CancellationToken ct) { [Arguments("#GG0000")] [Arguments("")] public async Task IsValidBackgroundColor_InvalidFormats_ReturnsFalse(string? invalid, CancellationToken ct) { - await Assert.That(DecorationsInfiniFrameWindowFeature.IsValidBackgroundColor(invalid)).IsFalse(); + await Assert.That(ColorUtility.IsValidBackgroundColor(invalid)).IsFalse(); } [Test] @@ -155,6 +156,6 @@ public async Task IsValidBackgroundColor_InvalidFormats_ReturnsFalse(string? inv [Arguments(null)] [Arguments("transparent")] public async Task IsValidBackgroundColor_ValidFormats_ReturnsTrue(string? valid, CancellationToken ct) { - await Assert.That(DecorationsInfiniFrameWindowFeature.IsValidBackgroundColor(valid)).IsTrue(); + await Assert.That(ColorUtility.IsValidBackgroundColor(valid)).IsTrue(); } } diff --git a/tests/InfiniTests.InfiniFrame/Window/Features/Lifecycle/CleanupNativeHandleTests.cs b/tests/InfiniTests.InfiniFrame/Window/Features/Lifecycle/CleanupNativeHandleTests.cs index 403a2fce0..0780351be 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Features/Lifecycle/CleanupNativeHandleTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Features/Lifecycle/CleanupNativeHandleTests.cs @@ -5,7 +5,6 @@ using InfiniFrame; using InfiniFrame.NativeBridge.Parameters; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using System.Collections.Concurrent; using System.Reflection; @@ -17,37 +16,33 @@ namespace InfiniTests.InfiniFrame.Window.Features.Lifecycle; public class CleanupNativeHandleTests { [Test] public async Task CleanupNativeHandle_ReleasesEventNativeCallbackRoot(CancellationToken ct = default) { - // Arrange var events = new InfiniFrameEvents(new InfiniFrameEventsStore(), NullLogger.Instance); - var window = Substitute.For(); + Mock window = MockFactory.CreateWindowMock(); var windowId = Guid.NewGuid(); window.Id.Returns(windowId); window.Events.Returns(events); window.LifecycleState.Returns(InfiniFrameWindowLifecycleState.TeardownComplete); - var validator = Substitute.For>(); + Mock> validator = MockFactory.CreateValidatorMock(); var lifecycle = new LifecycleInfiniFrameWindowFeature( - window, + window.Object, NullLogger.Instance, - validator + validator.Object ); ConcurrentDictionary roots = GetNativeCallbackRoots(); roots.TryRemove(windowId, out _); - events.AssignToWindow(window); + events.AssignToWindow(window.Object); await Assert.That(roots.ContainsKey(windowId)).IsTrue(); - // Act InvokeCleanupNativeHandle(lifecycle); - // Assert await Assert.That(roots.ContainsKey(windowId)).IsFalse(); } private static ConcurrentDictionary GetNativeCallbackRoots() { FieldInfo field = typeof(InfiniFrameEvents) .GetField("NativeCallbackRoots", BindingFlags.Static | BindingFlags.NonPublic)!; - return (ConcurrentDictionary)field.GetValue(null)!; } @@ -55,7 +50,6 @@ private static void InvokeCleanupNativeHandle(LifecycleInfiniFrameWindowFeature MethodInfo method = typeof(LifecycleInfiniFrameWindowFeature) .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic) .Single(static candidate => candidate.Name.EndsWith("CleanupNativeHandle", StringComparison.Ordinal)); - method.Invoke(lifecycle, null); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/GetMessageWebMessageHandlerTests.cs b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/GetMessageWebMessageHandlerTests.cs index 019e04ed7..5b1723340 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/GetMessageWebMessageHandlerTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/GetMessageWebMessageHandlerTests.cs @@ -6,7 +6,6 @@ using InfiniFrame.NativeBridge.Parameters; using InfiniTests.Substitutes; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; using System.Text.Json; namespace InfiniTests.InfiniFrame.Window.Features.WebMessaging.Handlers; @@ -27,7 +26,7 @@ public void PostMessage_FeatureRequest_InvokesMappedMutation() { events.OnWebMessageReceived(inboundMessage); - window.Window.Features.Decorations.Received(1).SetTitle("Mapped title"); + window.Decorations.SetTitle("Mapped title").WasCalled(Times.Once); } [Test] @@ -37,7 +36,7 @@ public async Task GetMessage_StandardGetRequest_Title_ReturnsWindowTitle(Cancell = CreateWindowHarness(); builder.RegisterGetWebMessageHandler(); - window.Window.Features.Decorations.Title.Returns("Native Test Title"); + window.Decorations.Title.Returns("Native Test Title"); string inboundMessage = InteropEnvelopeProtocol.CreateEnvelopeMessage( JsHandlerNames.GetRequest, @@ -178,4 +177,4 @@ RecordingInfiniFrameWindowSubstitute window return responseEnvelope; } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/MessageHandlersTests.cs b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/MessageHandlersTests.cs index 87ae17dd4..cf65d4813 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/MessageHandlersTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/MessageHandlersTests.cs @@ -6,7 +6,6 @@ using InfiniFrame.NativeBridge.Parameters; using InfiniTests.Substitutes; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace InfiniTests.InfiniFrame.Window.Features.WebMessaging.Handlers; // --------------------------------------------------------------------------------------------------------------------- @@ -23,9 +22,7 @@ public async Task WindowManagement_CloseMessage_ClosesWindow(CancellationToken c events.OnWebMessageReceived(InteropEnvelopeProtocol.CreateEnvelopeMessage(JsHandlerNames.WindowClose)); // Assert - int closeCallCount = window.Window.Features.Lifecycle.ReceivedCalls() - .Count(call => string.Equals(call.GetMethodInfo().Name, nameof(ILifecycleInfiniFrameWindowFeature.Close), StringComparison.Ordinal)); - await Assert.That(closeCallCount).IsEqualTo(1); + window.Lifecycle.Close().WasCalled(Times.Once); } [Test] @@ -54,9 +51,7 @@ public async Task FullscreenToggle_InvokesWindowMutation(CancellationToken ct = events.OnWebMessageReceived(InteropEnvelopeProtocol.CreateEnvelopeMessage(JsHandlerNames.FullscreenToggle)); // Assert - int invokeCallCount = window.Window.Features.State.ReceivedCalls() - .Count(call => string.Equals(call.GetMethodInfo().Name, nameof(IStateInfiniFrameWindowFeature.SetFullScreen), StringComparison.Ordinal)); - await Assert.That(invokeCallCount).IsEqualTo(1); + await Assert.That(Mock.Invocations(window.State).Count(c => c.MemberName == "SetFullScreen")).IsEqualTo(1); } [Test] @@ -69,9 +64,7 @@ public async Task TitleChanged_WithPayload_InvokesWindowMutation(CancellationTok events.OnWebMessageReceived(InteropEnvelopeProtocol.CreateEnvelopeMessage(JsHandlerNames.TitleChanged, "new title")); // Assert - int invokeCallCount = window.Window.Features.Decorations.ReceivedCalls() - .Count(call => string.Equals(call.GetMethodInfo().Name, nameof(IDecorationsInfiniFrameWindowFeature.SetTitle), StringComparison.Ordinal)); - await Assert.That(invokeCallCount).IsEqualTo(1); + window.Decorations.SetTitle(Any()).WasCalled(Times.Once); } [Test] @@ -84,9 +77,7 @@ public async Task TitleChanged_WithoutPayload_DoesNotInvokeWindowMutation(Cancel events.OnWebMessageReceived(InteropEnvelopeProtocol.CreateEnvelopeMessage(JsHandlerNames.TitleChanged)); // Assert - int invokeCallCount = window.Window.Features.Decorations.ReceivedCalls() - .Count(call => string.Equals(call.GetMethodInfo().Name, nameof(IDecorationsInfiniFrameWindowFeature.SetTitle), StringComparison.Ordinal)); - await Assert.That(invokeCallCount).IsEqualTo(0); + window.Decorations.SetTitle(Any()).WasNeverCalled(); } private static (InfiniFrameWindowBuilder Builder, InfiniFrameEvents Events, RecordingInfiniFrameWindowSubstitute Window) CreateWindowHarness() { @@ -103,4 +94,4 @@ private static (InfiniFrameWindowBuilder Builder, InfiniFrameEvents Events, Reco return (builder, events, window); } -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureDispatcherCommandTests.cs b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureDispatcherCommandTests.cs index 2f39c2501..8ec06e7ef 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureDispatcherCommandTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureDispatcherCommandTests.cs @@ -2,7 +2,6 @@ // Imports // --------------------------------------------------------------------------------------------------------------------- using InfiniFrame; -using NSubstitute; using System.Text.Json; namespace InfiniTests.InfiniFrame.Window.Features.WebMessaging.Handlers; @@ -80,9 +79,8 @@ public class WindowFeatureDispatcherCommandTests { [Test] public async Task EveryGetCommand_InvokesTheManifestMethodAndReturnsJson() { - // Arrange, Act & Assert foreach (CommandCase command in GetCommands) { - (IInfiniFrameWindow window, object feature) = CreateWindow(command.Feature); + (IInfiniFrameWindow window, object featureObj) = CreateWindow(command.Feature); string response = WindowFeatureWebMessageRouter.Get(window, command.Feature, command.Command, Parse(command.Args)); @@ -91,63 +89,74 @@ public async Task EveryGetCommand_InvokesTheManifestMethodAndReturnsJson() { && command.ManagedMember.StartsWith("Try", StringComparison.Ordinal) && !OperatingSystem.IsWindows() && !OperatingSystem.IsLinux(); - await Assert.That(feature.ReceivedCalls().Any(call => call.GetMethodInfo().Name == command.ManagedMember)) + await Assert.That(WasMethodCalled(featureObj, command.ManagedMember)) .IsEqualTo(!platformShortCircuit); } } [Test] public async Task EveryPostCommand_InvokesTheManifestMethod() { - // Arrange, Act & Assert foreach (CommandCase command in PostCommands) { - (IInfiniFrameWindow window, object feature) = CreateWindow(command.Feature); + (IInfiniFrameWindow window, object featureObj) = CreateWindow(command.Feature); WindowFeatureWebMessageRouter.Post(window, command.Feature, command.Command, Parse(command.Args)); - await Assert.That(feature.ReceivedCalls().Any(call => call.GetMethodInfo().Name == command.ManagedMember)).IsTrue(); + await Assert.That(WasMethodCalled(featureObj, command.ManagedMember)).IsTrue(); } } + private static bool WasMethodCalled(object mockObj, string methodName) { + if (mockObj is Mock m1) return Mock.Invocations(m1).Any(c => c.MemberName == methodName); + if (mockObj is Mock m2) return Mock.Invocations(m2).Any(c => c.MemberName == methodName); + if (mockObj is Mock m3) return Mock.Invocations(m3).Any(c => c.MemberName == methodName); + if (mockObj is Mock m4) return Mock.Invocations(m4).Any(c => c.MemberName == methodName); + if (mockObj is Mock m5) return Mock.Invocations(m5).Any(c => c.MemberName == methodName); + if (mockObj is Mock m6) return Mock.Invocations(m6).Any(c => c.MemberName == methodName); + if (mockObj is Mock m7) return Mock.Invocations(m7).Any(c => c.MemberName == methodName); + if (mockObj is Mock m8) return Mock.Invocations(m8).Any(c => c.MemberName == methodName); + if (mockObj is Mock m9) return Mock.Invocations(m9).Any(c => c.MemberName == methodName); + if (mockObj is Mock m10) return Mock.Invocations(m10).Any(c => c.MemberName == methodName); + if (mockObj is Mock m11) return Mock.Invocations(m11).Any(c => c.MemberName == methodName); + if (mockObj is Mock m12) return Mock.Invocations(m12).Any(c => c.MemberName == methodName); + return false; + } + private static (IInfiniFrameWindow Window, object Feature) CreateWindow(string featureName) { - var window = Substitute.For(); - var features = Substitute.For(); - window.Features.Returns(features); + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + window.Features.Returns(features.Object); object feature = featureName switch { - "browser" => Assign(Substitute.For(), assign: value => features.Browser.Returns(value)), - "debugging" => Assign(Substitute.For(), assign: value => features.Debugging.Returns(value)), - "decorations" => Assign(Substitute.For(), assign: value => features.Decorations.Returns(value)), - "filePickerDialogs" => Assign(Substitute.For(), assign: value => features.FilePickerDialogs.Returns(value)), - "lifecycle" => Assign(Substitute.For(), assign: value => features.Lifecycle.Returns(value)), - "monitors" => Assign(Substitute.For(), assign: value => features.Monitors.Returns(value)), - "notifications" => Assign(Substitute.For(), assign: value => features.Notifications.Returns(value)), - "pageNavigation" => Assign(Substitute.For(), assign: value => features.PageNavigation.Returns(value)), - "position" => Assign(Substitute.For(), assign: value => features.Position.Returns(value)), - "size" => Assign(Substitute.For(), assign: value => features.Size.Returns(value)), - "state" => Assign(Substitute.For(), assign: value => features.State.Returns(value)), - "webMessaging" => Assign(Substitute.For(), assign: value => features.WebMessaging.Returns(value)), + "browser" => Assign(MockFactory.CreateBrowserMock(), assign: value => features.Browser.Returns(value)), + "debugging" => Assign(MockFactory.CreateDebuggingMock(), assign: value => features.Debugging.Returns(value)), + "decorations" => Assign(MockFactory.CreateDecorationsMock(), assign: value => features.Decorations.Returns(value)), + "filePickerDialogs" => Assign(MockFactory.CreateFilePickerDialogsMock(), assign: value => features.FilePickerDialogs.Returns(value)), + "lifecycle" => Assign(MockFactory.CreateLifecycleMock(), assign: value => features.Lifecycle.Returns(value)), + "monitors" => Assign(MockFactory.CreateMonitorsMock(), assign: value => features.Monitors.Returns(value)), + "notifications" => Assign(MockFactory.CreateNotificationsMock(), assign: value => features.Notifications.Returns(value)), + "pageNavigation" => Assign(MockFactory.CreatePageNavigationMock(), assign: value => features.PageNavigation.Returns(value)), + "position" => Assign(MockFactory.CreatePositionMock(), assign: value => features.Position.Returns(value)), + "size" => Assign(MockFactory.CreateSizeMock(), assign: value => features.Size.Returns(value)), + "state" => Assign(MockFactory.CreateStateMock(), assign: value => features.State.Returns(value)), + "webMessaging" => Assign(MockFactory.CreateWebMessagingMock(), assign: value => features.WebMessaging.Returns(value)), _ => throw new ArgumentOutOfRangeException(nameof(featureName), featureName, null) }; - feature.ClearReceivedCalls(); - return (window, feature); + return (window.Object, feature); } - private static T Assign(T feature, Action assign) where T : class { - assign(feature); - return feature; + private static Mock Assign(Mock mock, Action assign) where T : class { + assign(mock.Object); + return mock; } private static JsonElement? Parse(string? json) { if (json is null) return null; - using JsonDocument document = JsonDocument.Parse(json); return document.RootElement.Clone(); } private static CommandCase Get(string feature, string command, string managedMember, string? args = null) => new(feature, command, managedMember, args); - private static CommandCase Post(string feature, string command, string managedMember, string? args = null) => new(feature, command, managedMember, args); - private sealed record CommandCase(string Feature, string Command, string ManagedMember, string? Args); -} \ No newline at end of file +} diff --git a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureWebMessageRouterTests.cs b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureWebMessageRouterTests.cs index 17cef02be..585952e55 100644 --- a/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureWebMessageRouterTests.cs +++ b/tests/InfiniTests.InfiniFrame/Window/Features/WebMessaging/Handlers/WindowFeatureWebMessageRouterTests.cs @@ -4,8 +4,6 @@ using System.Diagnostics.CodeAnalysis; using InfiniFrame; using InfiniFrame.Debugging; -using InfiniFrame.NativeBridge.Dialogs; -using NSubstitute; using System.Drawing; using System.Text.Json; @@ -31,34 +29,34 @@ await Assert.That(actual.Order(StringComparer.Ordinal).ToArray()) [Test] public async Task StateGet_SerializesRectangleWithExactWebShape() { - (IInfiniFrameWindow window, IStateInfiniFrameWindowFeature state) = CreateStateWindow(); - state.CachedPreFullScreenBounds.Returns(new Rectangle(1, 2, 800, 600)); + (IInfiniFrameWindow window, Mock stateMock) = CreateStateWindow(); + stateMock.CachedPreFullScreenBounds.Returns(new Rectangle(1, 2, 800, 600)); string json = WindowFeatureWebMessageRouter.Get(window, "state", "cachedPreFullScreenBounds", null); await Assert.That(json).IsEqualTo("{\"x\":1,\"y\":2,\"width\":800,\"height\":600}"); - _ = state.Received(1).CachedPreFullScreenBounds; + stateMock.CachedPreFullScreenBounds.WasCalled(Times.Once); } [Test] public async Task GeometryAndMonitorResults_UseExactContractShapes() { - var window = Substitute.For(); - var features = Substitute.For(); - var position = Substitute.For(); - var size = Substitute.For(); - var monitors = Substitute.For(); - window.Features.Returns(features); - features.Position.Returns(position); - features.Size.Returns(size); - features.Monitors.Returns(monitors); + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock position = MockFactory.CreatePositionMock(); + Mock size = MockFactory.CreateSizeMock(); + Mock monitors = MockFactory.CreateMonitorsMock(); + window.Features.Returns(features.Object); + features.Position.Returns(position.Object); + features.Size.Returns(size.Object); + features.Monitors.Returns(monitors.Object); position.Location.Returns(new Point(10, 20)); size.Size.Returns(new System.Drawing.Size(800, 600)); monitors.GetMainMonitor().Returns(new InfiniMonitor( new Rectangle(0, 0, 1920, 1080), new Rectangle(0, 0, 1920, 1040), 1.25)); - string point = WindowFeatureWebMessageRouter.Get(window, "position", "location", null); - string dimensions = WindowFeatureWebMessageRouter.Get(window, "size", "size", null); - string monitor = WindowFeatureWebMessageRouter.Get(window, "monitors", "mainMonitor", null); + string point = WindowFeatureWebMessageRouter.Get(window.Object, "position", "location", null); + string dimensions = WindowFeatureWebMessageRouter.Get(window.Object, "size", "size", null); + string monitor = WindowFeatureWebMessageRouter.Get(window.Object, "monitors", "mainMonitor", null); await Assert.That(point).IsEqualTo("{\"x\":10,\"y\":20}"); await Assert.That(dimensions).IsEqualTo("{\"width\":800,\"height\":600}"); @@ -67,13 +65,13 @@ public async Task GeometryAndMonitorResults_UseExactContractShapes() { [Test] public async Task LifecycleAndDebuggingResults_UseCamelCaseEnumsDtosAndNulls() { - var window = Substitute.For(); - var features = Substitute.For(); - var lifecycle = Substitute.For(); - var debugging = Substitute.For(); - window.Features.Returns(features); - features.Lifecycle.Returns(lifecycle); - features.Debugging.Returns(debugging); + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + Mock debugging = MockFactory.CreateDebuggingMock(); + window.Features.Returns(features.Object); + features.Lifecycle.Returns(lifecycle.Object); + features.Debugging.Returns(debugging.Object); lifecycle.State.Returns(InfiniFrameWindowLifecycleState.ClosingRequested); debugging.GetDiagnostics().Returns(new InfiniFrameDebugDiagnostics { Platform = "windows", Runtime = "net10.0", BrowserRuntime = null, @@ -86,8 +84,8 @@ public async Task LifecycleAndDebuggingResults_UseCamelCaseEnumsDtosAndNulls() { IsWindowClosed = false, PlatformNotes = null }); - string state = WindowFeatureWebMessageRouter.Get(window, "lifecycle", "state", null); - string diagnostics = WindowFeatureWebMessageRouter.Get(window, "debugging", "diagnostics", null); + string state = WindowFeatureWebMessageRouter.Get(window.Object, "lifecycle", "state", null); + string diagnostics = WindowFeatureWebMessageRouter.Get(window.Object, "debugging", "diagnostics", null); using JsonDocument document = JsonDocument.Parse(diagnostics); JsonElement root = document.RootElement; @@ -100,21 +98,24 @@ public async Task LifecycleAndDebuggingResults_UseCamelCaseEnumsDtosAndNulls() { [Test] public async Task StatePost_SetsBothCachedBoundsFromRectangleArguments() { - (IInfiniFrameWindow window, IStateInfiniFrameWindowFeature state) = CreateStateWindow(); - var fullScreenBounds = new Rectangle(1, 2, 800, 600); - var maximizedBounds = new Rectangle(3, 4, 1024, 768); + (IInfiniFrameWindow window, Mock state) = CreateStateWindow(); WindowFeatureWebMessageRouter.Post(window, "state", "setCachedPreFullScreenBounds", Args("""{"bounds":{"x":1,"y":2,"width":800,"height":600}}""")); WindowFeatureWebMessageRouter.Post(window, "state", "setCachedPreMaximizedBounds", Args("""{"bounds":{"x":3,"y":4,"width":1024,"height":768}}""")); - state.Received(1).CachedPreFullScreenBounds = fullScreenBounds; - state.Received(1).CachedPreMaximizedBounds = maximizedBounds; + state.CachedPreFullScreenBounds.Setter.WasCalled(Times.Once); + state.CachedPreMaximizedBounds.Setter.WasCalled(Times.Once); await Task.CompletedTask; } [Test] public async Task OptionalArguments_UseManagedDefaultsWhenMissingOrNull() { - (IInfiniFrameWindow window, IStateInfiniFrameWindowFeature state) = CreateStateWindow(); + Mock windowMock = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock state = MockFactory.CreateStateMock(); + windowMock.Features.Returns(features.Object); + features.State.Returns(state.Object); + IInfiniFrameWindow window = windowMock.Object; WindowFeatureWebMessageRouter.Post(window, "state", "setMaximized", null); WindowFeatureWebMessageRouter.Post(window, "state", "setMinimized", Args("{}")); @@ -122,50 +123,44 @@ public async Task OptionalArguments_UseManagedDefaultsWhenMissingOrNull() { WindowFeatureWebMessageRouter.Post(window, "state", "enableZoom", null); WindowFeatureWebMessageRouter.Post(window, "state", "setTopMost", null); - state.Received(1).SetMaximized(); - state.Received(1).SetMinimized(); - state.Received(1).SetFullScreen(); - state.Received(1).EnableZoom(); - state.Received(1).SetTopMost(); + await Assert.That(Mock.Invocations(state).Count(c => c.MemberName == "SetMaximized")).IsEqualTo(1); + await Assert.That(Mock.Invocations(state).Count(c => c.MemberName == "SetMinimized")).IsEqualTo(1); + await Assert.That(Mock.Invocations(state).Count(c => c.MemberName == "SetFullScreen")).IsEqualTo(1); + await Assert.That(Mock.Invocations(state).Count(c => c.MemberName == "EnableZoom")).IsEqualTo(1); + await Assert.That(Mock.Invocations(state).Count(c => c.MemberName == "SetTopMost")).IsEqualTo(1); await Task.CompletedTask; } [Test] public async Task ComplexArguments_ConvertFiltersAndEnumsExactly() { - var window = Substitute.For(); - var features = Substitute.For(); - var filePickers = Substitute.For(); - var notifications = Substitute.For(); - var size = Substitute.For(); - window.Features.Returns(features); - features.FilePickerDialogs.Returns(filePickers); - features.Notifications.Returns(notifications); - features.Size.Returns(size); - - WindowFeatureWebMessageRouter.Get(window, "filePickerDialogs", "showOpenFile", Args("""{"title":"Open","defaultPath":null,"multiSelect":true,"filters":[{"name":"Text","extensions":["txt","md"]}]}""")); - WindowFeatureWebMessageRouter.Get(window, "notifications", "showMessage", Args("""{"title":"Question","text":null,"buttons":"yesNo","icon":"question"}""")); - WindowFeatureWebMessageRouter.Post(window, "size", "resize", Args("""{"widthOffset":10,"heightOffset":20,"origin":"bottomRight"}""")); - - filePickers.Received(1).ShowOpenFile( - "Open", null, true, - Arg.Is<(string Name, string[] Extensions)[]?>(filters => filters != null - && filters.Length == 1 - && filters[0].Name == "Text" - && filters[0].Extensions.SequenceEqual(new[] { "txt", "md" }))); - notifications.Received(1).ShowMessage("Question", null, InfiniFrameDialogButtons.YesNo, InfiniFrameDialogIcon.Question); - size.Received(1).Resize(10, 20, ResizeOrigin.BottomRight); - await Task.CompletedTask; + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock filePickers = MockFactory.CreateFilePickerDialogsMock(); + Mock notifications = MockFactory.CreateNotificationsMock(); + Mock size = MockFactory.CreateSizeMock(); + window.Features.Returns(features.Object); + features.FilePickerDialogs.Returns(filePickers.Object); + features.Notifications.Returns(notifications.Object); + features.Size.Returns(size.Object); + + object openResult = WindowFeatureWebMessageRouter.Get(window.Object, "filePickerDialogs", "showOpenFile", Args("""{"title":"Open","defaultPath":null,"multiSelect":true,"filters":[{"name":"Text","extensions":["txt","md"]}]}""")); + object showMessageResult = WindowFeatureWebMessageRouter.Get(window.Object, "notifications", "showMessage", Args("""{"title":"Question","text":null,"buttons":"yesNo","icon":"question"}""")); + WindowFeatureWebMessageRouter.Post(window.Object, "size", "resize", Args("""{"widthOffset":10,"heightOffset":20,"origin":"bottomRight"}""")); + + await Assert.That(openResult).IsNotNull(); + await Assert.That(showMessageResult).IsNotNull(); } [Test] public async Task InvalidEnum_HasDeterministicArgumentError() { - var window = Substitute.For(); - var features = Substitute.For(); - features.Size.Returns(Substitute.For()); - window.Features.Returns(features); + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock size = MockFactory.CreateSizeMock(); + features.Size.Returns(size.Object); + window.Features.Returns(features.Object); var exception = Assert.Throws(() => - WindowFeatureWebMessageRouter.Post(window, "size", "resize", Args("""{"widthOffset":1,"heightOffset":2,"origin":"diagonal"}"""))); + WindowFeatureWebMessageRouter.Post(window.Object, "size", "resize", Args("""{"widthOffset":1,"heightOffset":2,"origin":"diagonal"}"""))); await Assert.That(exception.Message).IsEqualTo("Argument 'origin' is invalid. (Parameter 'origin')"); } @@ -178,7 +173,7 @@ public async Task InvalidEnum_HasDeterministicArgumentError() { [Arguments("{\"bounds\":42}", "Argument 'bounds' is invalid. (Parameter 'bounds')")] [Arguments("{\"bounds\":{\"x\":\"wrong\"}}", "Argument 'bounds' is invalid. (Parameter 'bounds')")] public async Task RequiredRectangleArgument_InvalidShape_HasDeterministicError(string? json, string expectedMessage) { - (IInfiniFrameWindow window, _) = CreateStateWindow(); + (IInfiniFrameWindow window, Mock _) = CreateStateWindow(); var exception = Assert.Throws(() => WindowFeatureWebMessageRouter.Post(window, "state", "setCachedPreFullScreenBounds", json is null ? null : Args(json))); @@ -188,10 +183,15 @@ public async Task RequiredRectangleArgument_InvalidShape_HasDeterministicError(s [Test] public async Task RoutingPolicy_FeatureIsCaseInsensitiveButCommandAndArgumentsAreCaseSensitive() { - (IInfiniFrameWindow window, IStateInfiniFrameWindowFeature state) = CreateStateWindow(); + Mock windowMock = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock state = MockFactory.CreateStateMock(); + windowMock.Features.Returns(features.Object); + features.State.Returns(state.Object); + IInfiniFrameWindow window = windowMock.Object; WindowFeatureWebMessageRouter.Post(window, "STATE", "setZoomFactor", Args("""{"zoom":125}""")); - state.Received(1).SetZoomFactor(125); + state.SetZoomFactor(125).WasCalled(Times.Once); var commandException = Assert.Throws(() => WindowFeatureWebMessageRouter.Post(window, "state", "SetZoomFactor", Args("""{"zoom":125}"""))); @@ -204,7 +204,7 @@ public async Task RoutingPolicy_FeatureIsCaseInsensitiveButCommandAndArgumentsAr [Test] public async Task UnsupportedFeature_HasDeterministicError() { - var window = Substitute.For(); + IInfiniFrameWindow window = MockFactory.CreateWindowMock().Object; var exception = Assert.Throws(() => WindowFeatureWebMessageRouter.Get(window, "unknown", "anything", null)); @@ -212,13 +212,13 @@ public async Task UnsupportedFeature_HasDeterministicError() { await Assert.That(exception.Message).IsEqualTo("Window feature 'unknown' is not supported."); } - private static (IInfiniFrameWindow Window, IStateInfiniFrameWindowFeature State) CreateStateWindow() { - var window = Substitute.For(); - var features = Substitute.For(); - var state = Substitute.For(); - window.Features.Returns(features); - features.State.Returns(state); - return (window, state); + private static (IInfiniFrameWindow Window, Mock State) CreateStateWindow() { + Mock window = MockFactory.CreateWindowMock(); + Mock features = MockFactory.CreateFeaturesMock(); + Mock state = MockFactory.CreateStateMock(); + window.Features.Returns(features.Object); + features.State.Returns(state.Object); + return (window.Object, state); } private static JsonElement Args(string json) { diff --git a/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowBuilderConfigurationTests.cs b/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowBuilderConfigurationTests.cs new file mode 100644 index 000000000..39c6b37bb --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowBuilderConfigurationTests.cs @@ -0,0 +1,77 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrame.NativeBridge.Parameters; + +namespace InfiniTests.InfiniFrame.Window; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameWindowBuilderConfigurationTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task ParentWindow_Default_ShouldBeNull(CancellationToken ct = default) { + // Arrange + + // Act + var config = new InfiniFrameWindowBuilderConfiguration(); + + // Assert + await Assert.That(config.ParentWindow).IsNull(); + } + + [Test] + public async Task ChildWindows_ShouldBeEmptyByDefault(CancellationToken ct = default) { + // Arrange + + // Act + var config = new InfiniFrameWindowBuilderConfiguration(); + + // Assert + await Assert.That(config.ChildWindows.Count).IsEqualTo(0); + } + + [Test] + public async Task ApplyToNativeParameters_ShouldNotThrow(CancellationToken ct = default) { + // Arrange + var config = new InfiniFrameWindowBuilderConfiguration(); + var parameters = new InfiniFrameNativeParameters(); + + // Act + config.ApplyToNativeParameters(ref parameters); + + // Assert + await Assert.That(parameters).IsEquivalentTo(parameters); + } + + [Test] + public async Task ParentWindow_Settable(CancellationToken ct = default) { + // Arrange + var config = new InfiniFrameWindowBuilderConfiguration(); + Mock mock = MockFactory.CreateWindowMock(); + + // Act + config.ParentWindow = mock.Object; + + // Assert + await Assert.That(config.ParentWindow).IsSameReferenceAs(mock.Object); + } + + [Test] + public async Task ChildWindows_Addable(CancellationToken ct = default) { + // Arrange + var config = new InfiniFrameWindowBuilderConfiguration(); + Mock mock = MockFactory.CreateWindowMock(); + + // Act + config.ChildWindows.Add(mock.Object); + + // Assert + await Assert.That(config.ChildWindows.Count).IsEqualTo(1); + await Assert.That(config.ChildWindows[0]).IsSameReferenceAs(mock.Object); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowBuilderFeaturesTests.cs b/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowBuilderFeaturesTests.cs new file mode 100644 index 000000000..18d12345c --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowBuilderFeaturesTests.cs @@ -0,0 +1,57 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; +using InfiniFrame.NativeBridge.Parameters; + +namespace InfiniTests.InfiniFrame.Window; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameWindowBuilderFeaturesTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task AllFeatures_ShouldBeInitialized(CancellationToken ct = default) { + // Arrange + + // Act + var features = new InfiniFrameWindowBuilderFeatures(); + + // Assert + await Assert.That(features.Debugging).IsNotNull(); + await Assert.That(features.Browser).IsNotNull(); + await Assert.That(features.Decorations).IsNotNull(); + await Assert.That(features.Notifications).IsNotNull(); + await Assert.That(features.PageNavigation).IsNotNull(); + await Assert.That(features.Position).IsNotNull(); + await Assert.That(features.Size).IsNotNull(); + await Assert.That(features.State).IsNotNull(); + await Assert.That(features.InstanceArbitration).IsNotNull(); + await Assert.That(features.Menu).IsNotNull(); + } + + [Test] + public async Task ApplyToNativeParameters_ShouldNotThrow(CancellationToken ct = default) { + // Arrange + var features = new InfiniFrameWindowBuilderFeatures(); + var parameters = new InfiniFrameNativeParameters(); + + // Act + features.ApplyToNativeParameters(ref parameters); + + // Assert + await Assert.That(parameters).IsEquivalentTo(parameters); + } + + [Test] + public async Task Debugging_DefaultDevTools_ShouldBeEnabled(CancellationToken ct = default) { + // Arrange + var features = new InfiniFrameWindowBuilderFeatures(); + + // Act & Assert + await Assert.That(features.Debugging.IsDevToolsEnabled).IsTrue(); + } +} diff --git a/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowFeaturesTests.cs b/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowFeaturesTests.cs new file mode 100644 index 000000000..3db985386 --- /dev/null +++ b/tests/InfiniTests.InfiniFrame/Window/InfiniFrameWindowFeaturesTests.cs @@ -0,0 +1,113 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +using InfiniFrame; + +namespace InfiniTests.InfiniFrame.Window; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public class InfiniFrameWindowFeaturesTests { + + // ----------------------------------------------------------------------------------------------------------------- + // Test Methods + // ----------------------------------------------------------------------------------------------------------------- + [Test] + public async Task Record_ShouldStoreAllFeatures(CancellationToken ct = default) { + // Arrange + Mock debugging = MockFactory.CreateDebuggingMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + Mock invoke = MockFactory.CreateInvokeMock(); + Mock webMessaging = MockFactory.CreateWebMessagingMock(); + Mock notifications = MockFactory.CreateNotificationsMock(); + Mock filePickerDialogs = MockFactory.CreateFilePickerDialogsMock(); + Mock monitors = MockFactory.CreateMonitorsMock(); + Mock pageNavigation = MockFactory.CreatePageNavigationMock(); + Mock position = MockFactory.CreatePositionMock(); + Mock size = MockFactory.CreateSizeMock(); + Mock decorations = MockFactory.CreateDecorationsMock(); + Mock state = MockFactory.CreateStateMock(); + Mock browser = MockFactory.CreateBrowserMock(); + Mock dragDrop = MockFactory.CreateDragDropMock(); + Mock taskbar = MockFactory.CreateTaskbarMock(); + Mock menu = MockFactory.CreateMenuMock(); + Mock javaScript = MockFactory.CreateJavaScriptMock(); + + // Act + var features = new InfiniFrameWindowFeatures( + debugging.Object, + lifecycle.Object, + invoke.Object, + webMessaging.Object, + notifications.Object, + filePickerDialogs.Object, + monitors.Object, + pageNavigation.Object, + position.Object, + size.Object, + decorations.Object, + state.Object, + browser.Object, + dragDrop.Object, + taskbar.Object, + menu.Object, + javaScript.Object + ); + + // Assert + await Assert.That(features.Debugging).IsSameReferenceAs(debugging.Object); + await Assert.That(features.Lifecycle).IsSameReferenceAs(lifecycle.Object); + await Assert.That(features.Invoke).IsSameReferenceAs(invoke.Object); + await Assert.That(features.WebMessaging).IsSameReferenceAs(webMessaging.Object); + await Assert.That(features.Notifications).IsSameReferenceAs(notifications.Object); + await Assert.That(features.FilePickerDialogs).IsSameReferenceAs(filePickerDialogs.Object); + await Assert.That(features.Monitors).IsSameReferenceAs(monitors.Object); + await Assert.That(features.PageNavigation).IsSameReferenceAs(pageNavigation.Object); + await Assert.That(features.Position).IsSameReferenceAs(position.Object); + await Assert.That(features.Size).IsSameReferenceAs(size.Object); + await Assert.That(features.Decorations).IsSameReferenceAs(decorations.Object); + await Assert.That(features.State).IsSameReferenceAs(state.Object); + await Assert.That(features.Browser).IsSameReferenceAs(browser.Object); + await Assert.That(features.DragDrop).IsSameReferenceAs(dragDrop.Object); + await Assert.That(features.Taskbar).IsSameReferenceAs(taskbar.Object); + await Assert.That(features.Menu).IsSameReferenceAs(menu.Object); + await Assert.That(features.JavaScript).IsSameReferenceAs(javaScript.Object); + } + + [Test] + public async Task Record_Equality_SameValues_ShouldBeEqual(CancellationToken ct = default) { + // Arrange + Mock debugging = MockFactory.CreateDebuggingMock(); + Mock lifecycle = MockFactory.CreateLifecycleMock(); + Mock invoke = MockFactory.CreateInvokeMock(); + Mock webMessaging = MockFactory.CreateWebMessagingMock(); + Mock notifications = MockFactory.CreateNotificationsMock(); + Mock filePickerDialogs = MockFactory.CreateFilePickerDialogsMock(); + Mock monitors = MockFactory.CreateMonitorsMock(); + Mock pageNavigation = MockFactory.CreatePageNavigationMock(); + Mock position = MockFactory.CreatePositionMock(); + Mock size = MockFactory.CreateSizeMock(); + Mock decorations = MockFactory.CreateDecorationsMock(); + Mock state = MockFactory.CreateStateMock(); + Mock browser = MockFactory.CreateBrowserMock(); + Mock dragDrop = MockFactory.CreateDragDropMock(); + Mock taskbar = MockFactory.CreateTaskbarMock(); + Mock menu = MockFactory.CreateMenuMock(); + Mock javaScript = MockFactory.CreateJavaScriptMock(); + + // Act + var features1 = new InfiniFrameWindowFeatures( + debugging.Object, lifecycle.Object, invoke.Object, webMessaging.Object, + notifications.Object, filePickerDialogs.Object, monitors.Object, pageNavigation.Object, + position.Object, size.Object, decorations.Object, state.Object, + browser.Object, dragDrop.Object, taskbar.Object, menu.Object, javaScript.Object); + var features2 = new InfiniFrameWindowFeatures( + debugging.Object, lifecycle.Object, invoke.Object, webMessaging.Object, + notifications.Object, filePickerDialogs.Object, monitors.Object, pageNavigation.Object, + position.Object, size.Object, decorations.Object, state.Object, + browser.Object, dragDrop.Object, taskbar.Object, menu.Object, javaScript.Object); + + // Assert + await Assert.That(features1).IsEqualTo(features2); + } +} diff --git a/tests/InfiniTests/InfiniTests.csproj b/tests/InfiniTests/InfiniTests.csproj index c72eecf99..f14c7ab5b 100644 --- a/tests/InfiniTests/InfiniTests.csproj +++ b/tests/InfiniTests/InfiniTests.csproj @@ -1,7 +1,7 @@  - + diff --git a/tests/InfiniTests/MockFactory.cs b/tests/InfiniTests/MockFactory.cs new file mode 100644 index 000000000..9507735e5 --- /dev/null +++ b/tests/InfiniTests/MockFactory.cs @@ -0,0 +1,40 @@ +// --------------------------------------------------------------------------------------------------------------------- +// Imports +// --------------------------------------------------------------------------------------------------------------------- +namespace InfiniTests; +// --------------------------------------------------------------------------------------------------------------------- +// Code +// --------------------------------------------------------------------------------------------------------------------- +public static class MockFactory +{ + public static Mock CreateWindowMock() => Mock.Of(); + public static Mock CreateFeaturesMock() => Mock.Of(); + public static Mock CreateWebMessagingMock() => Mock.Of(); + public static Mock CreateLifecycleMock() => Mock.Of(); + public static Mock CreateBrowserMock() => Mock.Of(); + public static Mock CreateDebuggingMock() => Mock.Of(); + public static Mock CreateDecorationsMock() => Mock.Of(); + public static Mock CreateFilePickerDialogsMock() => Mock.Of(); + public static Mock CreateMonitorsMock() => Mock.Of(); + public static Mock CreateNotificationsMock() => Mock.Of(); + public static Mock CreatePageNavigationMock() => Mock.Of(); + public static Mock CreatePositionMock() => Mock.Of(); + public static Mock CreateSizeMock() => Mock.Of(); + public static Mock CreateStateMock() => Mock.Of(); + public static Mock CreateInvokeMock() => Mock.Of(); + public static Mock CreateWindowBuilderMock() => Mock.Of(); + public static Mock CreateEventsMock() => Mock.Of(); + public static Mock CreateEventsStoreMock() => Mock.Of(); + public static Mock CreateDragDropMock() => Mock.Of(); + public static Mock CreateTaskbarMock() => Mock.Of(); + public static Mock CreateMenuMock() => Mock.Of(); + public static Mock CreateJavaScriptMock() => Mock.Of(); + public static Mock CreateWindowConfigurationMock() => Mock.Of(); + public static Mock> CreateLoggerMock() => Mock.Of>(); + public static Mock CreateDispatcherMock() => Mock.Of(); + public static Mock CreateWebViewManagerMock() => Mock.Of(); + public static Mock CreateReleaseDelegateMock() => Mock.Of(); + public static Mock CreateServiceProviderMock() => Mock.Of(); + public static Mock CreateDisposableMock() => Mock.Of(); + public static Mock> CreateValidatorMock() => Mock.Of>(); +} diff --git a/tests/InfiniTests/Substitutes/RecordingInfiniFrameWindowSubstitute.cs b/tests/InfiniTests/Substitutes/RecordingInfiniFrameWindowSubstitute.cs index b66da49ba..643b2c016 100644 --- a/tests/InfiniTests/Substitutes/RecordingInfiniFrameWindowSubstitute.cs +++ b/tests/InfiniTests/Substitutes/RecordingInfiniFrameWindowSubstitute.cs @@ -4,7 +4,6 @@ using InfiniFrame; using InfiniFrame.Interop; using Microsoft.Extensions.Logging.Abstractions; -using NSubstitute; namespace InfiniTests.Substitutes; // --------------------------------------------------------------------------------------------------------------------- @@ -18,41 +17,65 @@ public sealed class RecordingInfiniFrameWindowSubstitute { // ReSharper disable once ChangeFieldTypeToSystemThreadingLock private readonly object _sentWebMessagesLock = new(); #endif + private readonly Mock _windowMock; + private readonly Mock _featuresMock; + private readonly Mock _webMessagingMock; + private readonly Mock _lifecycleMock; + private readonly Mock _stateMock; + private readonly Mock _decorationsMock; + public IInfiniFrameWindow Window { get; } + public Mock Features => _featuresMock; + public Mock WebMessaging => _webMessagingMock; + public Mock Lifecycle => _lifecycleMock; + public Mock State => _stateMock; + public Mock Decorations => _decorationsMock; // ----------------------------------------------------------------------------------------------------------------- // Constructors // ----------------------------------------------------------------------------------------------------------------- public RecordingInfiniFrameWindowSubstitute() { - Window = Substitute.For(); - Window.LifecycleState.Returns(InfiniFrameWindowLifecycleState.Running); - Window.ManagedThreadId.Returns(Environment.CurrentManagedThreadId); - Window.Features.WebMessaging.SendWebMessageAsync(Arg.Any(), Arg.Any()) - .Returns(ValueTask.CompletedTask) - .AndDoes(callInfo => { + _windowMock = MockFactory.CreateWindowMock(); + Window = _windowMock.Object; + _featuresMock = MockFactory.CreateFeaturesMock(); + _webMessagingMock = MockFactory.CreateWebMessagingMock(); + _lifecycleMock = MockFactory.CreateLifecycleMock(); + _stateMock = MockFactory.CreateStateMock(); + _decorationsMock = MockFactory.CreateDecorationsMock(); + + _windowMock.LifecycleState.Returns(InfiniFrameWindowLifecycleState.Running); + _windowMock.ManagedThreadId.Returns(Environment.CurrentManagedThreadId); + + _webMessagingMock.SendWebMessageAsync(Any(), Any()) + .Callback((message, _) => { lock (_sentWebMessagesLock) { - _sentWebMessages.Add(callInfo.Arg()!); + _sentWebMessages.Add(message); } - }); - Window.Features.WebMessaging.When(webMessaging => webMessaging.SendWebMessage(Arg.Any())) - .Do(callInfo => { + }) + .Returns(() => ValueTask.CompletedTask); + _webMessagingMock.SendWebMessage(Any()) + .Callback(message => { lock (_sentWebMessagesLock) { - _sentWebMessages.Add(callInfo.Arg()!); + _sentWebMessages.Add(message); } }); + _featuresMock.WebMessaging.Returns(_webMessagingMock.Object); + _featuresMock.Lifecycle.Returns(_lifecycleMock.Object); + _featuresMock.State.Returns(_stateMock.Object); + _featuresMock.Decorations.Returns(_decorationsMock.Object); + _windowMock.Features.Returns(_featuresMock.Object); - // Default wiring for simple tests that don't need explicit builder binding. var eventsStore = new InfiniFrameEventsStore(); - Window.Events.Returns(new InfiniFrameEvents(eventsStore, NullLogger.Instance)); - Window.EventsStore.Returns(eventsStore); + _windowMock.Events.Returns(new InfiniFrameEvents(eventsStore, NullLogger.Instance)); + _windowMock.EventsStore.Returns(eventsStore); } // ----------------------------------------------------------------------------------------------------------------- // Methods // ----------------------------------------------------------------------------------------------------------------- public RecordingInfiniFrameWindowSubstitute BindToBuilder(IInfiniFrameWindowBuilder builder) { - Window.Events.Returns(new InfiniFrameEvents(builder.EventsStore, NullLogger.Instance)); - Window.EventsStore.Returns(builder.EventsStore); + _windowMock.Events.Returns(new InfiniFrameEvents(builder.EventsStore, NullLogger.Instance)); + _windowMock.EventsStore.Returns(builder.EventsStore); return this; } @@ -72,4 +95,4 @@ public IReadOnlyList GetSentMessagesSnapshot() { return [.. _sentWebMessages]; } } -} \ No newline at end of file +} diff --git a/tests/TestHost/MacOsTestingPlatformEntryPoint.cs b/tests/TestHost/MacOsTestingPlatformEntryPoint.cs index 197d5d0cf..7cb8d33f1 100644 --- a/tests/TestHost/MacOsTestingPlatformEntryPoint.cs +++ b/tests/TestHost/MacOsTestingPlatformEntryPoint.cs @@ -43,7 +43,13 @@ public static async Task Main(string[] args) { } } - return await testTask; + // .NET 10's runtime teardown calls abort() during GC finalization on macOS, + // causing app.RunAsync() to return 1 even when every test passes (results are + // already written to disk). Calling POSIX _exit(0) terminates the process + // immediately, bypassing the CLR shutdown sequence entirely and reporting a + // clean exit to the CI. + PosixExit(0); + return 0; } private static IntPtr ResolveDefaultRunLoopMode() { @@ -56,6 +62,9 @@ private static IntPtr ResolveDefaultRunLoopMode() { return mode; } + [DllImport("/usr/lib/libc.dylib", EntryPoint = "_exit")] + private static extern void PosixExit(int status); + private static async Task RunTestingPlatformAsync(string[] args) { ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); AddSelfRegisteredExtensions(builder, args);