diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94ca1885c..8633dfaac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,46 +73,6 @@ jobs: with: unity-version: ${{ matrix.unity-version }} - package-validation: - name: UPM Package validation - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.head_ref || github.ref }} - ssh-key: ${{ secrets.CI_DEPLOY_KEY }} - - - name: Download UPM package - uses: ./.github/actions/wait-for-artifact - with: - name: package-release - - - name: Check snapshot - id: snapshot-check - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - & ./test/Scripts.Tests/test-pack-contents.ps1 - "result=$LASTEXITCODE" >> $env:GITHUB_OUTPUT - - - name: Update snapshot for dependency bumps - if: steps.snapshot-check.outputs.result != '0' && github.event_name == 'pull_request' && startsWith(github.head_ref, 'deps/') - run: | - ./test/Scripts.Tests/test-pack-contents.ps1 accept - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add test/Scripts.Tests/package-release.zip.snapshot - git commit -m "Update package release snapshot" - git push - - - name: Fail on unexpected snapshot changes - if: steps.snapshot-check.outputs.result != '0' && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'deps/')) - run: | - Write-Error "Package contents do not match snapshot. Run './test/Scripts.Tests/test-pack-contents.ps1 accept' locally and commit the update." - exit 1 - - # This produces the `samples/IntegrationTest` as `test-${{ matrix.unity-version }}`. test-create: name: Create ${{ matrix.unity-version }} Test Project if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} @@ -189,26 +149,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ matrix.unity-version }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-${{ matrix.build_platform }}-${{ matrix.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -BuildDirName "Build-NoSentry" - env: - BUILD_PLATFORM: ${{ matrix.build_platform }} - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-${{ matrix.build_platform }}-${{ matrix.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -220,25 +160,16 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - if: ${{ !startsWith(matrix.unity-version, '2021') }} - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" - - - name: Disable DependencyConflict (WebGL 2021) - if: ${{ startsWith(matrix.unity-version, '2021') }} + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" env: BUILD_PLATFORM: ${{ matrix.build_platform }} - SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md - name: Build Project run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -UnityVersion "$env:UNITY_VERSION" @@ -246,20 +177,6 @@ jobs: BUILD_PLATFORM: ${{ matrix.build_platform }} UNITY_VERSION: ${{ matrix.unity-version }} - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform "$env:BUILD_PLATFORM" -UnityVersion "$env:UNITY_VERSION" - env: - BUILD_PLATFORM: ${{ matrix.build_platform }} - UNITY_VERSION: ${{ matrix.unity-version }} - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-${{ matrix.platform }}-${{ matrix.unity-version }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -294,7 +211,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: ${{ fromJSON(needs.create-unity-matrix.outputs.unity-matrix) }} @@ -308,7 +225,7 @@ jobs: needs: [test-build-android, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md uses: ./.github/workflows/test-run-android.yml with: unity-version: ${{ matrix.unity-version }} @@ -329,7 +246,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: ${{ fromJSON(needs.create-unity-matrix.outputs.unity-matrix) }} @@ -362,7 +279,7 @@ jobs: init-type: ${{ matrix.init-type }} secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -386,7 +303,7 @@ jobs: needs: [test-build-webgl, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -402,7 +319,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -418,7 +335,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -434,7 +351,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -449,7 +366,7 @@ jobs: needs: [test-build-linux, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -467,7 +384,7 @@ jobs: needs: [test-build-windows, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -485,7 +402,7 @@ jobs: needs: [test-build-macos, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -497,21 +414,40 @@ jobs: platform: macos backend: ${{ matrix.backend }} - build-size-summary: - name: Build Size + # Merges the per-job envelope artifacts into a single `envelopes-all` download. + # See docs/envelope-capture.md. + collect-envelopes: + name: Collect captured envelopes runs-on: ubuntu-latest - if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} - needs: [test-build-webgl, test-build-android, test-compile-ios, test-build-linux, test-build-windows, test-build-macos] + if: ${{ always() }} + needs: [test-run-android, test-run-ios, test-run-webgl, test-run-linux, test-run-windows, test-run-macos] steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Download all build size measurements + - name: Download all envelope artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: build-size-* - path: build-size-measurements + pattern: envelopes-* + merge-multiple: true + path: envelopes + + - name: Summarize corpus + run: | + "## Captured envelopes`n" >> $env:GITHUB_STEP_SUMMARY + "| Platform | Payloads | Test actions |" >> $env:GITHUB_STEP_SUMMARY + "| --- | --- | --- |" >> $env:GITHUB_STEP_SUMMARY + $total = 0 + Get-ChildItem -Path envelopes -Recurse -Filter index.jsonl | Sort-Object { $_.Directory.Name } | ForEach-Object { + $entries = Get-Content $_.FullName | ForEach-Object { $_ | ConvertFrom-Json } + $total += $entries.Count + $actions = ($entries | ForEach-Object { $_.label } | Sort-Object -Unique) -join ", " + "| $($_.Directory.Name) | $($entries.Count) | $actions |" >> $env:GITHUB_STEP_SUMMARY + } + "`nTotal: $total payloads" >> $env:GITHUB_STEP_SUMMARY + + - name: Upload combined corpus + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-all + path: envelopes/ + if-no-files-found: error + retention-days: 14 - - name: Create consolidated summary - shell: pwsh - run: ./scripts/create-build-size-summary.ps1 \ No newline at end of file diff --git a/.github/workflows/test-build-android.yml b/.github/workflows/test-build-android.yml index 1524018ed..1e1c472f5 100644 --- a/.github/workflows/test-build-android.yml +++ b/.github/workflows/test-build-android.yml @@ -25,6 +25,9 @@ jobs: GITHUB_ACTOR: ${{ github.actor }} UNITY_PATH: docker exec unity unity-editor UNITY_VERSION: ${{ inputs.unity-version }} + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -60,24 +63,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Android-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Android-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -89,14 +74,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" @@ -104,21 +85,13 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Export APK - Runtime Initialization - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Runtime) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath samples/IntegrationTest/Logs/sentry-symbols-upload.log - - name: Compare build sizes (Runtime) - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-Android-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - name: Bundle build & symbol-upload logs with the APK (Runtime) run: | Copy-Item unity.log samples/IntegrationTest/Build/ -ErrorAction SilentlyContinue @@ -143,7 +116,9 @@ jobs: Set-Content $optionsPath $content - name: Export APK - Build-Time Initialization - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Build-Time) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath samples/IntegrationTest/Logs/sentry-symbols-upload.log @@ -176,3 +151,29 @@ jobs: !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 # Lower retention period - we only need this to retry CI. + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-android-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-build-ios.yml b/.github/workflows/test-build-ios.yml index 9dab592ec..7fe7aa7fe 100644 --- a/.github/workflows/test-build-ios.yml +++ b/.github/workflows/test-build-ios.yml @@ -66,37 +66,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-iOS-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "iOS" -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-iOS-${{ inputs.unity-version }} - - - name: Create archive for build without Sentry - shell: bash - run: | - rm -rf samples/IntegrationTest/Build-NoSentry/*_BackUpThisFolder_ButDontShipItWithYourGame - tar -cvzf test-app-no-sentry.tar.gz samples/IntegrationTest/Build-NoSentry - - - name: Upload build without Sentry - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: testapp-ios-no-sentry-${{ env.UNITY_VERSION }} - path: test-app-no-sentry.tar.gz - retention-days: 1 - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -108,14 +77,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" diff --git a/.github/workflows/test-build-linux.yml b/.github/workflows/test-build-linux.yml index 5535dc050..5efef1061 100644 --- a/.github/workflows/test-build-linux.yml +++ b/.github/workflows/test-build-linux.yml @@ -26,6 +26,9 @@ jobs: UNITY_PATH: docker exec unity unity-editor UNITY_VERSION: ${{ inputs.unity-version }} BUILD_PLATFORM: Linux + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -71,24 +74,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Linux-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Linux-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -100,14 +85,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux @@ -115,22 +96,13 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Build with Sentry SDK (Breakpad backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "linux-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Breakpad backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform Linux -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-Linux-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive (Breakpad backend) run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -153,7 +125,9 @@ jobs: Set-Content $optionsPath $content - name: Build with Sentry SDK (Native backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "linux-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Native backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log @@ -203,3 +177,30 @@ jobs: unity.log !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-linux-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-build-macos.yml b/.github/workflows/test-build-macos.yml index 318f71bd5..01b832fc7 100644 --- a/.github/workflows/test-build-macos.yml +++ b/.github/workflows/test-build-macos.yml @@ -24,6 +24,9 @@ jobs: env: UNITY_VERSION: ${{ inputs.unity-version }} BUILD_PLATFORM: MacOS + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -66,24 +69,6 @@ jobs: restore-keys: | it-library-macos-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-MacOS-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-MacOS-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -95,14 +80,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS @@ -110,24 +91,15 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Build with Sentry SDK (Cocoa backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "macos-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Assert symbols and sources were uploaded (Cocoa backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-MacOS-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive (Cocoa backend) run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -150,7 +122,9 @@ jobs: Set-Content $optionsPath $content - name: Build with Sentry SDK (Native backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "macos-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -189,3 +163,30 @@ jobs: unity.log !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-macos-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-build-windows.yml b/.github/workflows/test-build-windows.yml index 89dedc474..cabccb30c 100644 --- a/.github/workflows/test-build-windows.yml +++ b/.github/workflows/test-build-windows.yml @@ -24,6 +24,9 @@ jobs: env: UNITY_VERSION: ${{ inputs.unity-version }} BUILD_PLATFORM: Windows + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -66,24 +69,6 @@ jobs: restore-keys: | it-library-windows-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Windows-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Windows-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -95,14 +80,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows @@ -110,24 +91,15 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Build with Sentry SDK (Crashpad backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "windows-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - name: Assert symbols and sources were uploaded (Crashpad backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform Windows -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-Windows-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive (Crashpad backend) run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -150,7 +122,9 @@ jobs: Set-Content $optionsPath $content - name: Build with Sentry SDK (Native backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "windows-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -189,3 +163,30 @@ jobs: unity.log !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-windows-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-compile-ios.yml b/.github/workflows/test-compile-ios.yml index 74f58a129..fe33d4a1b 100644 --- a/.github/workflows/test-compile-ios.yml +++ b/.github/workflows/test-compile-ios.yml @@ -20,50 +20,14 @@ jobs: env: UNITY_VERSION: ${{ inputs.unity-version }} INIT_TYPE: ${{ inputs.init-type }} + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Restore cached compiled iOS build without Sentry - if: ${{ inputs.init-type == 'runtime' }} - id: cache-compiled-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: IntegrationTest-NoSentry.app - key: build-nosentry-iOS-compiled-${{ inputs.unity-version }} - - - name: Download build without Sentry (for size comparison) - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: testapp-ios-no-sentry-${{ env.UNITY_VERSION }} - - - name: Extract and compile build without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - run: | - tar -xvzf test-app-no-sentry.tar.gz - Move-Item -Path "samples/IntegrationTest/Build-NoSentry" -Destination "samples/IntegrationTest/Build" - - - name: Compile Xcode project without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - run: ./scripts/compile-xcode-project.ps1 -iOSMinVersion "17.0" - timeout-minutes: 20 - - - name: Save compiled app without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - run: | - Copy-Item -Path "samples/IntegrationTest/Build/archive/Unity-iPhone/Build/Products/Release-iphonesimulator/IntegrationTest.app" ` - -Destination "IntegrationTest-NoSentry.app" -Recurse - Remove-Item -Path "samples/IntegrationTest/Build" -Recurse -Force - - - name: Save cached compiled iOS build without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' }} - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: IntegrationTest-NoSentry.app - key: build-nosentry-iOS-compiled-${{ inputs.unity-version }} - - name: Download app project uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -73,7 +37,9 @@ jobs: run: tar -xvzf "test-app-$env:INIT_TYPE.tar.gz" - name: iOS test - run: ./scripts/compile-xcode-project.ps1 -iOSMinVersion "17.0" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" -Output "test/IntegrationTest/symbols/ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" + ./scripts/compile-xcode-project.ps1 -iOSMinVersion "17.0" timeout-minutes: 20 - name: Assert symbols and sources were uploaded @@ -106,19 +72,28 @@ jobs: # Lower retention period - we only need this to retry CI. retention-days: 14 - - name: Compare build sizes - if: ${{ inputs.init-type == 'runtime' }} + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured run: | - ./test/Scripts.Integration.Test/measure-build-size.ps1 ` - -Path1 "IntegrationTest-NoSentry.app" ` - -Path2 "samples/IntegrationTest/Build/archive/Unity-iPhone/Build/Products/Release-iphonesimulator/IntegrationTest.app" ` - -Platform "iOS" ` - -UnityVersion "$env:UNITY_VERSION" + $dir = "test/IntegrationTest/symbols/ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } - - name: Upload build size measurement - if: ${{ inputs.init-type == 'runtime' }} + - name: Upload captured debug files + if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: build-size-iOS-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 + name: symbols-ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/test-run-android.yml b/.github/workflows/test-run-android.yml index 67b6471f6..176528713 100644 --- a/.github/workflows/test-run-android.yml +++ b/.github/workflows/test-run-android.yml @@ -84,6 +84,13 @@ jobs: mkdir -p $HOME/.android/avd touch $HOME/.android/repositories.cfg + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8787. `adb reverse` below tunnels that + # port from the device to the runner. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ inputs.api-level }}-${{ inputs.unity-version }}-${{ inputs.init-type }}" + - name: Run Android Integration Tests (emulator) if: ${{ steps.device.outputs.device == 'emulator' }} uses: reactivecircus/android-emulator-runner@0a638108440efd5c7f980e6ba145dbcdd8f32009 # v2.37.0 @@ -111,6 +118,7 @@ jobs: adb wait-for-device adb shell input keyevent 82 adb devices -l + adb reverse tcp:8787 tcp:8787 pwsh -Command '$env:SENTRY_TEST_PLATFORM = "Android"; $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.apk"; Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI' # --- arm64 path: Redroid container on the arm64 runner (no hypervisor needed) --- @@ -168,10 +176,26 @@ jobs: Start-Sleep -Seconds 5 } adb devices -l + adb reverse tcp:8787 tcp:8787 $env:SENTRY_TEST_PLATFORM = "Android" $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.apk" Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-android-${{ inputs.api-level }}-${{ inputs.unity-version }}-${{ inputs.init-type }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test-run-desktop.yml b/.github/workflows/test-run-desktop.yml index 979ee9a45..14b1312a1 100644 --- a/.github/workflows/test-run-desktop.yml +++ b/.github/workflows/test-run-desktop.yml @@ -58,6 +58,12 @@ jobs: run: chmod +x samples/IntegrationTest/Build/test.app/Contents/MacOS/IntegrationTest shell: bash + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8787. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "${{ inputs.platform }}${{ inputs.backend && format('-{0}', inputs.backend) || '' }}-${{ inputs.unity-version }}" + - name: Run Integration Tests (Linux) if: inputs.platform == 'linux' timeout-minutes: 20 @@ -86,6 +92,21 @@ jobs: $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.exe" Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-${{ inputs.platform }}${{ inputs.backend && format('-{0}', inputs.backend) || '' }}-${{ inputs.unity-version }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test-run-ios.yml b/.github/workflows/test-run-ios.yml index 7d1e0bd89..5510181a9 100644 --- a/.github/workflows/test-run-ios.yml +++ b/.github/workflows/test-run-ios.yml @@ -65,6 +65,13 @@ jobs: with: xcode-version: '15.0' # to run iOS 17.0 we need Xcode 15.0 + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8787. The simulator shares the host + # loopback, so no port forwarding is needed. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "ios-${{ env.IOS_VERSION }}-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" + - name: Run iOS Integration Tests id: integration-test timeout-minutes: 20 @@ -76,6 +83,21 @@ jobs: Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI echo "status=success" >> $env:GITHUB_OUTPUT + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-ios-${{ env.IOS_VERSION }}-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test-run-webgl.yml b/.github/workflows/test-run-webgl.yml index cff15cb81..f2626708f 100644 --- a/.github/workflows/test-run-webgl.yml +++ b/.github/workflows/test-run-webgl.yml @@ -43,6 +43,14 @@ jobs: run: pip3 install --upgrade selenium shell: bash + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8787. The capture server answers CORS + # preflights so the browser can post to it cross-origin, and stays off port 8000 because + # webgl-server.py serves the build there. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "webgl-${{ inputs.unity-version }}" + - name: Run Integration Tests timeout-minutes: 20 run: | @@ -51,6 +59,21 @@ jobs: $env:SENTRY_TEST_UNITY_VERSION = "${{ inputs.unity-version }}" Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-webgl-${{ inputs.unity-version }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/docs/envelope-capture.md b/docs/envelope-capture.md new file mode 100644 index 000000000..c21897253 --- /dev/null +++ b/docs/envelope-capture.md @@ -0,0 +1,110 @@ +# Capturing a raw envelope + debug file corpus from the integration tests + +The integration tests exercise every managed error, native crash and app hang path the SDK has, on +every platform we ship. Capture mode records what those runs actually put on the wire - raw +envelopes, minidump uploads, and the debug files sentry-cli uploads at build time - so the whole lot +can be replayed against a local Sentry instead of going to sentry.io. + +| | | +|---|---| +| [`test/Scripts.Integration.Test/envelope-capture-server.py`](../test/Scripts.Integration.Test/envelope-capture-server.py) | Stands in for both Sentry endpoints: envelope ingest, and the chunk-upload API sentry-cli uses for debug files. Writes everything to disk. | +| [`scripts/replay-envelopes.py`](../scripts/replay-envelopes.py) | Posts a captured envelope corpus to a DSN of your choice. | + +**This is a temporary branch change, not a feature.** `ci.yml` hardcodes the DSN to +`http://capture@127.0.0.1:8787/1` in place of `SENTRY_TEST_DSN`. That one switch redirects both +halves of the capture, because the DSN is what decides where each goes: + +- the SDK sends envelopes there at **run** time, +- sentry-cli derives its upload URL from that same DSN ([`SentryCli.UrlOverride`](../src/Sentry.Unity.Editor/SentryCli.cs)), + so debug files land there at **build** time. + +There is nothing to toggle: push the branch, open the PR, wait for CI, fetch the artifacts. Revert +the commit once you have the corpus. + +## What CI produces + +| Artifact | From | Contents | +|---|---|---| +| `envelopes-all` | run jobs, merged | every envelope and minidump upload, one directory per platform | +| `symbols--` | build jobs | the debug files and source bundles sentry-cli uploaded for that build | + +```bash +gh run download -n envelopes-all -D ./corpus +gh run download -p 'symbols-*' -D ./corpus/symbols +``` + +Debug files are big (IL2CPP `GameAssembly.pdb` and friends), so they stay per-platform rather than +being merged into one download. + +**The integration tests fail by design in capture mode.** There is no backend to verify against, so +`Integration.Tests.ps1` skips the Sentry API lookups and every event assertion fails. The artifacts +are the deliverable; a red run is expected. The symbol-upload assertions are the exception and still +mean something: they pass only if sentry-cli really did upload debug files to the capture server. + +Coverage per matrix entry: `message-capture`, `exception-capture`, `crash-capture` (+ the +`crash-send` relaunch that flushes the crash envelope) and `app-hang-capture`, each of which also +emits logs, metrics, sessions and a transaction. Windows/macOS/Linux run twice, once per crash +backend (`crashpad`/`breakpad`/`native`/`cocoa`), so the corpus covers each native payload shape. + +Two details the capture DSN would otherwise break, both handled: `webgl-server.py` serves the WebGL +build on port 8000, so capture listens on **8787**; and the Linux/Android/WebGL builds run Unity +inside a container, so [`ci-docker.sh`](../scripts/ci-docker.sh) uses `--network host` to let the +in-container sentry-cli reach the capture server. + +### What CI no longer does on this branch + +Stripped to keep the run short and the failures meaningful: the UPM package snapshot validation, all +build-size measurement (including every "build without Sentry" pass and the `build-size-summary` +job), and the dependency-conflict package steps. + +## What lands on disk + +One directory per matrix entry, so the merged corpus stays collision-free: + +``` +macos-cocoa-6000.5/001-macos-cocoa-6000.5-exception-capture-event_attachment.envelope # raw bytes, gunzipped +macos-cocoa-6000.5/001-macos-cocoa-6000.5-exception-capture-event_attachment.meta.json # path, headers, item types +windows-crashpad-6000.5/003-...-crash-capture-minidump.multipart.bin # crashpad minidump upload +windows-crashpad-6000.5/index.jsonl # one line per request +symbols/macos-6000.5/debug-files/--GameAssembly.dylib # debug companion +symbols/macos-6000.5/debug-files/--GameAssembly.dylib.src # source bundle +symbols/macos-6000.5/debug-files/index.jsonl # debug id -> file +``` + +A dif and its source bundle share a debug id *and* a name, so the checksum in the file name is what +keeps them apart. + +## Replaying into a local Sentry + +```bash +# events, crashes, sessions, logs +python3 scripts/replay-envelopes.py ./corpus --dsn http://@localhost:9000/1 + +# the debug files that symbolicate them +sentry-cli --url http://localhost:9000 --auth-token debug-files upload \ + -o -p ./corpus/symbols/macos-6000.5/debug-files +``` + +sentry-cli reads the captured files straight out of the artifact and re-uploads them under their +original debug ids, which is what lets the replayed crashes symbolicate. + +Each envelope is rewritten before it is posted: the DSN in the envelope header is swapped for the +target, `sent_at` is set to now, event ids are regenerated and all timestamps are shifted to now +while keeping their relative offsets (breadcrumbs, spans, session start). That keeps a corpus +replayable indefinitely without deduplicating against itself or falling outside the ingest window. +Pass `--keep-ids` / `--keep-timestamps` to replay the bytes as they were captured. + +Minidump uploads are replayed verbatim to `/api//minidump/` with only the ingest key +swapped - the event ids inside the multipart body are left alone. + +## Capturing locally + +```bash +python3 test/Scripts.Integration.Test/envelope-capture-server.py --output ./out --platform macos +SENTRY_DSN="http://capture@127.0.0.1:8787/1" \ + ./test/Scripts.Integration.Test/dev-integration-test.ps1 -UnityVersion 6000.5 -Platform MacOS +``` + +Any DSN whose host is `127.0.0.1`, `localhost` or `10.0.2.2` puts `Integration.Tests.ps1` into +capture mode. Note that this also applies when you point the tests straight at a locally running +Sentry - the run works, but the API verification is skipped. diff --git a/scripts/ci-docker.sh b/scripts/ci-docker.sh index a9330c0b1..79ccae4da 100755 --- a/scripts/ci-docker.sh +++ b/scripts/ci-docker.sh @@ -34,7 +34,7 @@ uniqueHostname=$(echo "$uniqueHostname" | tr '[:upper:]_ ' '[:lower:]--' | tr -s # We use the host dotnet installation - it's much faster than installing inside the docker container. set -x docker run -td --name $container \ - --hostname $uniqueHostname \ + --network host \ --user $uid:$gid \ -v "$cwd":/sentry-unity \ -v $ANDROID_HOME:$ANDROID_HOME \ @@ -44,6 +44,7 @@ docker run -td --name $container \ -e UNITY_VERSION=$unityVersion \ -e GITHUB_ACTIONS="${GITHUB_ACTIONS}" \ -e SENTRY_AUTH_TOKEN="${SENTRY_AUTH_TOKEN:-}" \ + -e SENTRY_URL="${SENTRY_URL:-}" \ --workdir /sentry-unity $image # Generate unique machine-id to avoid any hardcoded values and license-fetch congestion diff --git a/scripts/replay-envelopes.py b/scripts/replay-envelopes.py new file mode 100644 index 000000000..9ab2141a3 --- /dev/null +++ b/scripts/replay-envelopes.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Replays a captured envelope corpus into a Sentry instance. + +Takes the output of test/Scripts.Integration.Test/envelope-capture-server.py (envelopes and +crashpad minidump uploads produced by the Unity integration tests on every platform) and posts +it to the DSN of your choice - typically a local Sentry. + +By default every replay gets fresh event ids and timestamps shifted to now, so the same corpus +can be replayed repeatedly without events deduplicating or falling outside the ingest window. + +Usage: + replay-envelopes.py --dsn http://@localhost:9000/1 + replay-envelopes.py --dsn ... --include '*crash*' --dry-run +""" + +import argparse +import fnmatch +import json +import sys +import urllib.error +import urllib.request +import uuid +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import parse_qsl, urlencode, urlparse + +TIMESTAMP_KEYS = {"timestamp", "start_timestamp", "started", "received", "time"} +JSON_ITEM_TYPES = {"event", "transaction", "session", "sessions", "check_in", "log", "feedback", + "user_report", "replay_event", "profile", "client_report"} + + +def parse_envelope(data): + """Splits envelope bytes into (header, [(item_header, payload)]).""" + newline = data.find(b"\n") + if newline == -1: + raise ValueError("no envelope header") + header = json.loads(data[:newline]) + items = [] + pos = newline + 1 + while pos < len(data): + if data[pos:pos + 1] == b"\n": + pos += 1 + continue + newline = data.find(b"\n", pos) + if newline == -1: + break + item_header = json.loads(data[pos:newline]) + pos = newline + 1 + if "length" in item_header: + end = pos + int(item_header["length"]) + else: + end = data.find(b"\n", pos) + if end == -1: + end = len(data) + items.append((item_header, data[pos:end])) + pos = end + return header, items + + +def serialize_envelope(header, items): + out = [json.dumps(header, separators=(",", ":")).encode(), b"\n"] + for item_header, payload in items: + item_header = dict(item_header, length=len(payload)) + out += [json.dumps(item_header, separators=(",", ":")).encode(), b"\n", payload, b"\n"] + return b"".join(out) + + +def to_epoch(value): + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + +def from_epoch(epoch, template): + if isinstance(template, (int, float)): + return epoch + return datetime.fromtimestamp(epoch, timezone.utc).isoformat().replace("+00:00", "Z") + + +def collect_timestamps(node, found): + if isinstance(node, dict): + for key, value in node.items(): + if key in TIMESTAMP_KEYS: + epoch = to_epoch(value) + if epoch: + found.append(epoch) + collect_timestamps(value, found) + elif isinstance(node, list): + for value in node: + collect_timestamps(value, found) + + +def shift_timestamps(node, delta): + if isinstance(node, dict): + for key, value in node.items(): + if key in TIMESTAMP_KEYS: + epoch = to_epoch(value) + if epoch: + node[key] = from_epoch(epoch + delta, value) + continue + shift_timestamps(value, delta) + elif isinstance(node, list): + for value in node: + shift_timestamps(value, delta) + + +def rewrite(data, dsn, new_ids, fresh_timestamps): + header, items = parse_envelope(data) + header["dsn"] = dsn.url + header["sent_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + parsed = [] + for item_header, payload in items: + if item_header.get("type") in JSON_ITEM_TYPES: + try: + parsed.append((item_header, json.loads(payload), True)) + continue + except (ValueError, UnicodeDecodeError): + pass + parsed.append((item_header, payload, False)) + + if fresh_timestamps: + found = [] + for _, payload, is_json in parsed: + if is_json: + collect_timestamps(payload, found) + if found: + delta = datetime.now(timezone.utc).timestamp() - max(found) + for _, payload, is_json in parsed: + if is_json: + shift_timestamps(payload, delta) + + if new_ids: + event_id = uuid.uuid4().hex + if "event_id" in header: + header["event_id"] = event_id + for _, payload, is_json in parsed: + if is_json and isinstance(payload, dict) and "event_id" in payload: + payload["event_id"] = event_id + + rebuilt = [ + (item_header, json.dumps(payload, separators=(",", ":")).encode() if is_json else payload) + for item_header, payload, is_json in parsed + ] + return serialize_envelope(header, rebuilt) + + +class Dsn: + def __init__(self, url): + parsed = urlparse(url) + if not parsed.username or not parsed.hostname or len(parsed.path) < 2: + raise ValueError(f"not a valid DSN: {url}") + self.url = url + self.key = parsed.username + self.project = parsed.path.strip("/") + port = f":{parsed.port}" if parsed.port else "" + self.base = f"{parsed.scheme}://{parsed.hostname}{port}/api/{self.project}" + + def endpoint(self, name): + return f"{self.base}/{name}/" + + +def post(url, body, content_type, dsn, timeout): + auth = f"Sentry sentry_version=7, sentry_client=replay-envelopes/1.0, sentry_key={dsn.key}" + request = urllib.request.Request( + url, data=body, method="POST", + headers={"Content-Type": content_type, "X-Sentry-Auth": auth}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read(200).decode("utf-8", "replace") + except urllib.error.HTTPError as error: + return error.code, error.read(400).decode("utf-8", "replace") + except urllib.error.URLError as error: + return None, str(error.reason) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("corpus", help="directory with captured .envelope / .multipart.bin files") + parser.add_argument("--dsn", required=True, help="target DSN, e.g. http://key@localhost:9000/1") + parser.add_argument("--include", default="*", help="glob filter on the file name") + parser.add_argument("--keep-ids", action="store_true", help="replay original event ids") + parser.add_argument("--keep-timestamps", action="store_true", help="do not shift timestamps to now") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--timeout", type=float, default=30) + args = parser.parse_args() + + dsn = Dsn(args.dsn) + corpus = Path(args.corpus) + files = sorted(path for path in corpus.rglob("*") + if path.suffix in (".envelope", ".bin") and fnmatch.fnmatch(path.name, args.include)) + if not files: + print(f"no envelopes matching '{args.include}' under {corpus}", file=sys.stderr) + return 1 + + failures = 0 + for path in files: + data = path.read_bytes() + + if path.name.endswith(".multipart.bin"): + # crashpad minidump upload - replayed verbatim, only the ingest key is swapped + meta = json.loads(path.with_name(path.name[:-len(".multipart.bin")] + ".meta.json").read_text()) + content_type = meta["headers"].get("Content-Type", "multipart/form-data") + query = dict(parse_qsl(meta.get("query", ""))) + query["sentry_key"] = dsn.key + url = f"{dsn.endpoint('minidump')}?{urlencode(query)}" + else: + try: + data = rewrite(data, dsn, not args.keep_ids, not args.keep_timestamps) + except Exception as error: + print(f"SKIP {path.name}: cannot rewrite ({error})", file=sys.stderr) + failures += 1 + continue + content_type = "application/x-sentry-envelope" + url = dsn.endpoint("envelope") + + if args.dry_run: + print(f"DRY {path.name} -> {url} ({len(data)} bytes)") + continue + + status, body = post(url, data, content_type, dsn, args.timeout) + ok = status is not None and 200 <= status < 300 + failures += 0 if ok else 1 + print(f"{'OK ' if ok else 'FAIL'} {status if status else 'ERR'} {path.name} {body.strip()[:120]}") + + print(f"\n{len(files) - failures}/{len(files)} replayed to {dsn.base}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/IntegrationTest/Integration.Tests.ps1 b/test/IntegrationTest/Integration.Tests.ps1 index 9dec4f8eb..7d0d68e08 100644 --- a/test/IntegrationTest/Integration.Tests.ps1 +++ b/test/IntegrationTest/Integration.Tests.ps1 @@ -132,6 +132,26 @@ BeforeAll { return $runResult } + # Tags the envelopes that envelope-capture-server.py records next with the test action + # they belong to. No-op unless the DSN points at the local capture server. + function Set-CaptureLabel { + param ( + [Parameter(Mandatory=$true)] + [string]$Label + ) + + if (-not $script:CaptureMode) { + return + } + + try { + Invoke-WebRequest -Uri "http://127.0.0.1:8787/MARK?label=$Label" -TimeoutSec 5 -UseBasicParsing | Out-Null + } + catch { + Write-Host "Failed to mark capture label '$Label': $_" + } + } + # Run integration test action function Invoke-TestAction { param ( @@ -140,6 +160,7 @@ BeforeAll { ) Write-Host "Running $Action..." + Set-CaptureLabel -Label $Action if ($script:Platform -eq "WebGL") { return Invoke-WebGLTestAction -Action $Action @@ -160,6 +181,7 @@ BeforeAll { # Launch app again to ensure crash report is sent if ($Action -eq "crash-capture") { Write-Host "Running crash-send to ensure crash report is sent..." + Set-CaptureLabel -Label "crash-send" $sendArgs = Get-AppArguments -Action "crash-send" $sendResult = Invoke-DeviceApp -ExecutablePath $script:ExecutablePath -Arguments $sendArgs @@ -197,7 +219,16 @@ BeforeAll { if ([string]::IsNullOrEmpty($env:SENTRY_DSN)) { throw "SENTRY_DSN environment variable is not set." } - if ([string]::IsNullOrEmpty($env:SENTRY_AUTH_TOKEN)) { + + # Envelope capture mode: the DSN points at envelope-capture-server.py instead of Sentry, so the + # test actions still run (and their raw envelopes get recorded) but there is no backend to verify + # against. The event assertions below fail by design in this mode - the artifact is the corpus. + $script:CaptureMode = $env:SENTRY_DSN -match '://[^@]*@(127\.0\.0\.1|localhost|10\.0\.2\.2)' + if ($script:CaptureMode) { + Write-Host "Envelope capture mode: DSN points at the local capture server, skipping Sentry API verification." -ForegroundColor Yellow + } + + if (-not $script:CaptureMode -and [string]::IsNullOrEmpty($env:SENTRY_AUTH_TOKEN)) { throw "SENTRY_AUTH_TOKEN environment variable is not set." } if ([string]::IsNullOrEmpty($env:SENTRY_TEST_APP)) { @@ -277,14 +308,18 @@ BeforeAll { AuthToken = $env:SENTRY_AUTH_TOKEN } - Connect-SentryApi ` - -ApiToken $script:TestSetup.AuthToken ` - -DSN $script:TestSetup.Dsn + if (-not $script:CaptureMode) { + Connect-SentryApi ` + -ApiToken $script:TestSetup.AuthToken ` + -DSN $script:TestSetup.Dsn + } } AfterAll { - Disconnect-SentryApi + if (-not $script:CaptureMode) { + Disconnect-SentryApi + } if ($script:Platform -ne "WebGL") { Disconnect-Device } @@ -299,7 +334,7 @@ Describe "Unity $($env:SENTRY_TEST_PLATFORM) Integration Tests" { $script:runResult = Invoke-TestAction -Action "message-capture" $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($eventId) { + if ($eventId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -EventId "$eventId" Write-Host "::endgroup::" @@ -325,7 +360,7 @@ Describe "Unity $($env:SENTRY_TEST_PLATFORM) Integration Tests" { $script:runResult = Invoke-TestAction -Action "exception-capture" $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($eventId) { + if ($eventId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -EventId "$eventId" Write-Host "::endgroup::" @@ -387,7 +422,7 @@ if ($env:SENTRY_TEST_PLATFORM -ne "WebGL") { } $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($eventId) { + if ($eventId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -TagName "test.crash_id" -TagValue "$eventId" -TimeoutSeconds 300 Write-Host "::endgroup::" @@ -443,7 +478,7 @@ if ($env:SENTRY_TEST_PLATFORM -in "Desktop", "Android" -and -not $isCocoaBackend # The native app-hang event is captured in-proc (same run, no relaunch). Its event ID # is generated natively, so look it up by the unique scope tag the app sets instead. $hangId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($hangId) { + if ($hangId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -TagName "test.app_hang_id" -TagValue "$hangId" -TimeoutSeconds 300 Write-Host "::endgroup::" diff --git a/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs b/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs index 4482aada7..d2e687617 100644 --- a/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs +++ b/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.Reflection; +using System.Text.RegularExpressions; +using System.Xml; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; @@ -21,6 +23,14 @@ public void OnPreprocessBuild(BuildReport report) public void OnPostprocessBuild(BuildReport report) { var pathToBuiltProject = report.summary.outputPath; + if (report.summary.platform == BuildTarget.StandaloneOSX) + { + // ATS applies to macOS players too and blocks plain HTTP to an IP literal, which is what + // the envelope capture server is. The iOS module isn't available on macOS build agents, + // so patch the plist as plain XML instead of going through PlistDocument. + AllowArbitraryLoadsInMacPlist(Path.Combine(pathToBuiltProject, "Contents", "Info.plist")); + } + if (report.summary.platform == BuildTarget.iOS) { var plistPath = Path.Combine(pathToBuiltProject, "Info.plist"); @@ -51,4 +61,56 @@ public void OnPostprocessBuild(BuildReport report) File.WriteAllText(plistPath, contents); } } + + private static void AllowArbitraryLoadsInMacPlist(string plistPath) + { + if (!File.Exists(plistPath)) + { + Debug.LogError($"Failed to find the plist at {plistPath}."); + return; + } + + var document = new XmlDocument { XmlResolver = null }; + // Parse (not Ignore) keeps the DOCTYPE in the document; the null resolver keeps us from + // fetching the external DTD Apple references. + using (var reader = XmlReader.Create(plistPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, XmlResolver = null })) + { + document.Load(reader); + } + + var root = document.SelectSingleNode("/plist/dict"); + if (root is null) + { + Debug.LogError("Failed to find the root in the plist."); + return; + } + + foreach (XmlNode child in root.ChildNodes) + { + if (child.Name == "key" && child.InnerText == "NSAppTransportSecurity") + { + Debug.Log("AllowInsecureHttp: plist already contains NSAppTransportSecurity, nothing to do."); + return; + } + } + + var key = document.CreateElement("key"); + key.InnerText = "NSAppTransportSecurity"; + var value = document.CreateElement("dict"); + var allowKey = document.CreateElement("key"); + allowKey.InnerText = "NSAllowsArbitraryLoads"; + value.AppendChild(allowKey); + value.AppendChild(document.CreateElement("true")); + + root.AppendChild(key); + root.AppendChild(value); + document.Save(plistPath); + + // XmlDocument serializes the DOCTYPE with an empty internal subset (`...PropertyList-1.0.dtd"[]>`) + // which Apple's plist parser rejects. Drop it again. + var patched = Regex.Replace(File.ReadAllText(plistPath), @"(\[]*)\[\]>", "$1>"); + File.WriteAllText(plistPath, patched); + + Debug.Log("AllowInsecureHttp: added NSAllowsArbitraryLoads to the macOS plist."); + } } diff --git a/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs b/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs index 89b72cd1f..6c7c46d6f 100644 --- a/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs +++ b/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs @@ -16,6 +16,9 @@ public override void Configure(SentryCliOptions cliOptions) cliOptions.Organization = "sentry-sdks"; cliOptions.Project = "sentry-unity-integration-tests"; + // No UrlOverride on purpose: sentry-cli derives its upload URL from the DSN, so during + // envelope capture the debug files go to the capture server and end up in the artifact. + Debug.Log("Sentry: CliConfiguration::Configure() finished"); } } diff --git a/test/Scripts.Integration.Test/envelope-capture-server.py b/test/Scripts.Integration.Test/envelope-capture-server.py new file mode 100644 index 000000000..b51d83365 --- /dev/null +++ b/test/Scripts.Integration.Test/envelope-capture-server.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Captures raw Sentry envelopes sent by the integration test app. + +Stands in for Sentry's ingest endpoint: accepts every request, writes the body to disk +and answers 200 so the SDK considers the payload delivered. Point the DSN of the test +build at this server (host 127.0.0.1) and the run produces a corpus of real envelopes - +including native crash envelopes and crashpad minidump uploads - that can be replayed +against a local Sentry via scripts/replay-envelopes.py. + +Usage: + envelope-capture-server.py --output DIR [--host 0.0.0.0] [--port 8787] [--platform NAME] + +Control endpoints: + GET /HEALTH 200 once the server is serving + GET /MARK?label=foo tags subsequently captured files with `foo` (the test action) + GET /STOP shuts the server down +""" + +import argparse +import gzip +import json +import re +import shutil +import sys +import tempfile +import threading +import uuid +import zlib +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +state_lock = threading.Lock() +sequence = 0 +label = "startup" +output_dir = Path(".") +chunk_dir = Path(".") +symbol_dir = Path(".") +platform_name = "unknown" +assembled = set() + + +def parse_envelope(data): + """Splits envelope bytes into (header, [(item_header, payload)]).""" + newline = data.find(b"\n") + if newline == -1: + raise ValueError("no envelope header") + header = json.loads(data[:newline]) + items = [] + pos = newline + 1 + while pos < len(data): + if data[pos:pos + 1] == b"\n": + pos += 1 + continue + newline = data.find(b"\n", pos) + if newline == -1: + break + item_header = json.loads(data[pos:newline]) + pos = newline + 1 + if "length" in item_header: + end = pos + int(item_header["length"]) + else: + end = data.find(b"\n", pos) + if end == -1: + end = len(data) + items.append((item_header, data[pos:end])) + pos = end + return header, items + + +def parse_multipart(body, boundary): + """Yields (headers, payload) for each part of a multipart/form-data body.""" + for segment in body.split(b"--" + boundary): + if segment in (b"", b"--", b"--\r\n", b"\r\n"): + continue + segment = segment[2:] if segment.startswith(b"\r\n") else segment + head, _, payload = segment.partition(b"\r\n\r\n") + if payload.endswith(b"\r\n"): + payload = payload[:-2] + headers = {} + for line in head.decode("utf-8", "replace").splitlines(): + key, sep, value = line.partition(":") + if sep: + headers[key.strip().lower()] = value.strip() + yield headers, payload + + +def decode_body(body, encoding): + if not encoding: + return body + encoding = encoding.lower() + try: + if encoding == "gzip": + return gzip.decompress(body) + if encoding in ("deflate", "zlib"): + return zlib.decompress(body) + except Exception as error: + print(f"failed to decompress {encoding} body: {error}", file=sys.stderr) + return body + + +def safe(value): + return re.sub(r"[^A-Za-z0-9_.-]", "_", value)[:60] or "unknown" + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + print(f"{self.address_string()} - {fmt % args}", file=sys.stderr) + + def cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + self.send_header("Access-Control-Max-Age", "86400") + + def respond(self, code, payload=b"", content_type="application/json"): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + # One request per connection. Keep-alive sockets that the server later drops surface as + # "the network connection was lost" in NSURLSession and cost us envelopes. + self.send_header("Connection", "close") + self.close_connection = True + self.cors() + self.end_headers() + if payload: + self.wfile.write(payload) + + def do_OPTIONS(self): + self.respond(200) + + def do_GET(self): + global label + url = urlparse(self.path) + if url.path == "/HEALTH": + self.respond(200, b'{"ok":true}') + elif url.path == "/MARK": + new_label = parse_qs(url.query).get("label", ["unlabeled"])[0] + with state_lock: + label = safe(new_label) + print(f"--- mark: {label} ---", file=sys.stderr) + self.respond(200, b'{"ok":true}') + elif url.path.endswith("/chunk-upload/"): + # sentry-cli asks what the server accepts before uploading debug files. Advertising + # uncompressed chunks keeps the upload handler trivial. + options = { + "url": f"http://{self.headers.get('Host', '127.0.0.1')}{url.path}", + "chunkSize": 8 * 1024 * 1024, + "chunksPerRequest": 64, + "maxFileSize": 2 * 1024 * 1024 * 1024, + "maxRequestSize": 32 * 1024 * 1024, + "concurrency": 1, + "hashAlgorithm": "sha1", + "compression": [], + "accept": ["debug_files", "sources", "pdbs", "portablepdbs", "il2cpp", + "bcsymbolmaps", "proguard"], + } + self.respond(200, json.dumps(options).encode()) + elif url.path == "/STOP": + self.respond(200, b'{"ok":true}') + threading.Thread(target=self.server.shutdown).start() + else: + self.respond(200, b"{}") + + def read_body(self): + if self.headers.get("Transfer-Encoding", "").lower() == "chunked": + chunks = [] + while True: + size = int(self.rfile.readline().split(b";")[0], 16) + if size == 0: + self.rfile.readline() + break + chunks.append(self.rfile.read(size)) + self.rfile.readline() + return b"".join(chunks) + return self.rfile.read(int(self.headers.get("Content-Length", 0))) + + def handle_chunk_upload(self, body): + """Stores each uploaded chunk under its sha1 so assemble can stitch the file back.""" + boundary = re.search(r"boundary=([^;]+)", self.headers.get("Content-Type", "")) + if not boundary: + self.respond(400, b'{"detail":"missing boundary"}') + return + + count = 0 + for headers, payload in parse_multipart(body, boundary.group(1).strip('"').encode()): + name = re.search(r'filename="([^"]*)"', headers.get("content-disposition", "")) + if not name: + continue + (chunk_dir / name.group(1)).write_bytes(payload) + count += 1 + + print(f"stored {count} chunks", file=sys.stderr) + self.respond(200, b"{}") + + def handle_assemble(self, body): + """Reassembles uploaded chunks into the debug files sentry-cli meant to upload.""" + try: + request = json.loads(body) + except ValueError as error: + self.respond(400, json.dumps({"detail": str(error)}).encode()) + return + + response = {} + for checksum, entry in request.items(): + name = Path(entry.get("name") or checksum).name + + # sentry-cli polls assemble until every file reports `ok`. Once assembled we drop the + # chunks, so answer from this set rather than re-checking them - otherwise the next + # poll reports the file as missing and sentry-cli fails the upload. + with state_lock: + if checksum in assembled: + response[checksum] = {"state": "ok", "missingChunks": [], "detail": None} + continue + + missing = [c for c in entry.get("chunks", []) if not (chunk_dir / c).exists()] + if missing: + response[checksum] = {"state": "not_found", "missingChunks": missing, "detail": None} + continue + + # A dif and its source bundle share both debug id and name, so the checksum keeps + # them from overwriting each other. + target = symbol_dir / f"{entry.get('debug_id', 'unknown')}-{checksum[:8]}-{safe(name)}" + with target.open("wb") as out: + for chunk in entry["chunks"]: + out.write((chunk_dir / chunk).read_bytes()) + # Source bundles carry Sentry's "SYSB" magic; mark them so the corpus is self-describing. + with target.open("rb") as probe: + if probe.read(4) == b"SYSB": + target = target.rename(target.with_name(target.name + ".src")) + + # IL2CPP debug files run to gigabytes; dropping the chunks once assembled keeps peak + # disk at one copy. sentry-cli re-uploads any chunk a later probe reports missing. + for chunk in entry["chunks"]: + (chunk_dir / chunk).unlink(missing_ok=True) + print(f"assembled {target.name} ({target.stat().st_size} bytes)", file=sys.stderr) + + with state_lock: + assembled.add(checksum) + with (symbol_dir / "index.jsonl").open("a") as index: + index.write(json.dumps({"file": target.name, "platform": platform_name, + "checksum": checksum, "size": target.stat().st_size, + "request": entry}) + "\n") + + response[checksum] = {"state": "ok", "missingChunks": [], "detail": None} + + self.respond(200, json.dumps(response).encode()) + + def do_POST(self): + global sequence + url = urlparse(self.path) + raw = self.read_body() + body = decode_body(raw, self.headers.get("Content-Encoding")) + + if url.path.endswith("/chunk-upload/"): + self.handle_chunk_upload(body) + return + if url.path.endswith("/assemble/"): + self.handle_assemble(body) + return + + with state_lock: + sequence += 1 + seq, current_label = sequence, label + + meta = { + "sequence": seq, + "label": current_label, + "platform": platform_name, + "received": datetime.now(timezone.utc).isoformat(), + "method": self.command, + "path": url.path, + "query": url.query, + "headers": dict(self.headers), + "raw_bytes": len(raw), + "decoded_bytes": len(body), + } + + content_type = self.headers.get("Content-Type", "") + event_id = None + if "multipart/form-data" in content_type: + # crashpad uploads the minidump to /api//minidump/ as multipart + extension = "multipart.bin" + kind = "minidump" + else: + extension = "envelope" + kind = "envelope" + try: + header, items = parse_envelope(body) + meta["envelope_header"] = header + meta["items"] = [ + { + "type": item_header.get("type"), + "length": len(payload), + "filename": item_header.get("filename"), + "content_type": item_header.get("content_type"), + } + for item_header, payload in items + ] + event_id = header.get("event_id") + types = [i.get("type") or "unknown" for i, _ in items] + if types: + kind = "+".join(dict.fromkeys(types)) + except Exception as error: + meta["parse_error"] = str(error) + + name = f"{seq:03d}-{safe(platform_name)}-{safe(current_label)}-{safe(kind)}" + (output_dir / f"{name}.{extension}").write_bytes(body) + (output_dir / f"{name}.meta.json").write_text(json.dumps(meta, indent=2)) + with state_lock: + with (output_dir / "index.jsonl").open("a") as index: + index.write(json.dumps({"file": f"{name}.{extension}", **meta}) + "\n") + + print(f"captured {name}.{extension} ({len(body)} bytes) {url.path}", file=sys.stderr) + self.respond(200, json.dumps({"id": event_id or uuid.uuid4().hex}).encode()) + + +def main(): + global output_dir, chunk_dir, symbol_dir, platform_name + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8787) + parser.add_argument("--output", required=True) + parser.add_argument("--platform", default="unknown") + args = parser.parse_args() + + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + platform_name = args.platform + + # Debug files sentry-cli uploads land next to the envelopes; the chunks they are stitched + # from are scratch and get cleaned up on shutdown. + symbol_dir = output_dir / "debug-files" + symbol_dir.mkdir(exist_ok=True) + chunk_dir = Path(tempfile.mkdtemp(prefix="sentry-chunks-")) + + server = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"envelope capture listening on {args.host}:{args.port} -> {output_dir}", file=sys.stderr) + server.serve_forever() + shutil.rmtree(chunk_dir, ignore_errors=True) + print(f"envelope capture stopped after {sequence} requests", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/test/Scripts.Integration.Test/start-capture-server.ps1 b/test/Scripts.Integration.Test/start-capture-server.ps1 new file mode 100644 index 000000000..ae0ac95ba --- /dev/null +++ b/test/Scripts.Integration.Test/start-capture-server.ps1 @@ -0,0 +1,66 @@ +#!/usr/bin/env pwsh +# +# Starts envelope-capture-server.py in the background and waits until it serves. +# Used by the integration test workflows when SENTRY_DSN points at the capture host. + +param( + [string] $Platform = "unknown", + [int] $Port = 8787, + [string] $Output = "" +) + +$ErrorActionPreference = "Stop" + +# One directory per platform so the per-job artifacts can be merged into a single corpus +# without index.jsonl and capture-server.log colliding. +if ([string]::IsNullOrEmpty($Output)) { + $Output = "test/IntegrationTest/envelopes/$Platform" +} + +# Build jobs call this from every build step, because a detached server does not reliably survive +# the gap between steps. Reuse the running one instead of fighting over the port and the log file. +try { + Invoke-WebRequest -Uri "http://127.0.0.1:$Port/HEALTH" -TimeoutSec 2 -UseBasicParsing | Out-Null + Write-Host "Envelope capture server already running on port $Port" + exit 0 +} +catch { + # Nothing answered. A server from a previous step may still be holding the port without + # serving (the runner suspends leftovers between steps), which would make the new one fail + # with "Address already in use" - so clear the port before starting. + if ($IsWindows) { + Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } + } + else { + $stale = & lsof -ti "tcp:$Port" 2>$null + foreach ($processId in $stale) { + Write-Host "Killing stale listener on port $Port (pid $processId)" + & kill -9 $processId 2>$null + } + } +} + +$python = if (Get-Command python3 -ErrorAction SilentlyContinue) { "python3" } else { "python" } +$server = Join-Path $PSScriptRoot "envelope-capture-server.py" + +New-Item -ItemType Directory -Force -Path $Output | Out-Null +$logPath = Join-Path $Output "capture-server.log" + +Start-Process -FilePath $python ` + -ArgumentList @($server, "--output", $Output, "--port", $Port, "--platform", $Platform) ` + -RedirectStandardError $logPath -NoNewWindow + +for ($i = 1; $i -le 30; $i++) { + try { + Invoke-WebRequest -Uri "http://127.0.0.1:$Port/HEALTH" -TimeoutSec 2 -UseBasicParsing | Out-Null + Write-Host "Envelope capture server is up on port $Port (writing to $Output)" + exit 0 + } + catch { + Start-Sleep -Seconds 1 + } +} + +Get-Content $logPath -ErrorAction SilentlyContinue | Write-Host +throw "Envelope capture server did not come up on port $Port"