diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 07fab65c9..cc031f721 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -189,9 +189,10 @@ jobs:
run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release"
- name: Configure Sentry
- run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -CheckSymbols
+ run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -CheckSymbols -TestMode "integration"
env:
BUILD_PLATFORM: ${{ matrix.build_platform }}
+ SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }}
- name: Build Project
run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -CheckSymbols:$([System.Convert]::ToBoolean($env:CHECK_SYMBOLS)) -UnityVersion "$env:UNITY_VERSION"
@@ -219,15 +220,14 @@ jobs:
run: |
# Note: remove local.properties file that contains Android SDK & NDK paths in the Unity installation.
rm -rf samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame
- tar -cvzf test-app-runtime.tar.gz samples/IntegrationTest/Build
+ tar -cvzf test-app-webgl.tar.gz samples/IntegrationTest/Build
- # Upload runtime initialization build
- name: Upload test app
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
- name: testapp-${{ matrix.platform }}-${{ matrix.unity-version }}-runtime
+ name: testapp-webgl-compiled-${{ matrix.unity-version }}
if-no-files-found: error
- path: test-app-runtime.tar.gz
+ path: test-app-webgl.tar.gz
retention-days: 14
- name: Upload IntegrationTest project on failure
@@ -325,33 +325,17 @@ jobs:
# - https://github.com/actions/runner-images/blob/main/images/macos/macos-14-Readme.md
smoke-test-run-webgl:
- name: Run ${{ matrix.platform }} ${{ matrix.unity-version }} Smoke Test
+ name: Run WebGL ${{ matrix.unity-version }} Integration Test
if: ${{ !startsWith(github.ref, 'refs/heads/release/') }}
needs: [smoke-test-build-webgl, create-unity-matrix]
- runs-on: ubuntu-latest
+ secrets: inherit
strategy:
fail-fast: false
matrix:
unity-version: ${{ fromJSON(needs.create-unity-matrix.outputs.unity-matrix).unity-version }}
- platform: ["WebGL"]
- steps:
- - name: Checkout
- uses: actions/checkout@v3
-
- - name: Download test app artifact
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- id: download
- with:
- name: testapp-${{ matrix.platform }}-${{ matrix.unity-version }}-runtime
-
- - name: Extract test app
- run: tar -xvzf test-app-runtime.tar.gz
-
- - name: Run (WebGL)
- timeout-minutes: 10
- run: |
- pip3 install --upgrade --user selenium urllib3 requests
- python3 scripts/smoke-test-webgl.py "samples/IntegrationTest/Build"
+ uses: ./.github/workflows/smoke-test-run-webgl.yml
+ with:
+ unity-version: ${{ matrix.unity-version }}
smoke-test-build-linux:
name: Build Linux ${{ matrix.unity-version }} Integration Test
diff --git a/.github/workflows/smoke-test-run-webgl.yml b/.github/workflows/smoke-test-run-webgl.yml
new file mode 100644
index 000000000..441e6d61e
--- /dev/null
+++ b/.github/workflows/smoke-test-run-webgl.yml
@@ -0,0 +1,54 @@
+name: "IntegrationTest: Run WebGL"
+on:
+ workflow_call:
+ inputs:
+ unity-version:
+ required: true
+ type: string
+
+defaults:
+ run:
+ shell: pwsh
+
+jobs:
+ run:
+ name: WebGL ${{ inputs.unity-version }}
+ runs-on: ubuntu-latest
+ env:
+ SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }}
+ SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+
+ - name: Initialize app-runner submodule
+ run: git submodule update --init modules/app-runner
+ shell: bash
+
+ - name: Download test app artifact
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ name: testapp-webgl-compiled-${{ inputs.unity-version }}
+
+ - name: Extract test app
+ run: tar -xvzf test-app-webgl.tar.gz
+
+ - name: Install Selenium
+ run: pip3 install --upgrade selenium
+ shell: bash
+
+ - name: Run Integration Tests
+ timeout-minutes: 20
+ run: |
+ $env:SENTRY_WEBGL_BUILD_PATH = "samples/IntegrationTest/Build"
+ Invoke-Pester -Path test/IntegrationTest/Integration.Tests.WebGL.ps1 -CI
+
+ - name: Upload test results on failure
+ if: ${{ failure() }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: testapp-webgl-logs-${{ inputs.unity-version }}
+ path: |
+ test/IntegrationTest/results/
+ retention-days: 14
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 733cfff87..b1a65ad05 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -386,7 +386,7 @@ Related: https://forum.unity.com/threads/6572-debugger-agent-unable-to-listen-on
-
+
diff --git a/scripts/smoke-test-webgl.py b/scripts/smoke-test-webgl.py
deleted file mode 100644
index c144983aa..000000000
--- a/scripts/smoke-test-webgl.py
+++ /dev/null
@@ -1,216 +0,0 @@
-#!/usr/bin/env python3
-
-# Testing approach:
-# 1. Start a web=server for pre-built WebGL app directory (index.html & co) and to collect the API requests
-# 3. Run the smoke test using chromedriver
-# 4. Check the messages received by the API server
-
-import datetime
-import re
-import sys
-import time
-import os
-from http import HTTPStatus
-from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
-from threading import Thread
-from selenium import webdriver
-from selenium.webdriver.chrome.options import Options
-from threading import Thread, Lock
-
-host = '127.0.0.1'
-port = 8000
-scriptDir = os.path.dirname(os.path.abspath(__file__))
-
-if len(sys.argv) > 1:
- appDir = sys.argv[1]
-else:
- appDir = os.path.join(scriptDir, '..', 'samples',
- 'artifacts', 'builds', 'WebGL')
-
-
-print("Using appDir:{}".format(appDir))
-
-ignoreRegex = '"exception":{"values":\[{"type":"(' + '|'.join(
- ['The resource [^ ]+ could not be loaded from the resource file!', 'GL.End requires material.SetPass before!']) + ')"'
-
-
-class RequestVerifier:
- __requests = []
- __testNumber = 0
- __lock = Lock()
-
- def Capture(self, info, body):
-
- # Note: this error seems to be related to *not* using https - we could probably use it by providing self-signed
- # certificate when starting the http server.
- # We would also have to add `options.add_argument('ignore-certificate-errors')` to the chromedriver setup.
- match = re.search(ignoreRegex, body)
- if match:
- print(
- "TEST: Skipping the received HTTP Request because it's an unrelated unity bug:\n{}".format(match.group(0)))
- return
-
- self.__lock.acquire()
- try:
- print("TEST: Received HTTP Request #{} = {}\n{}".format(
- len(self.__requests), info, body), flush=True)
- self.__requests.append({"request": info, "body": body})
- finally:
- self.__lock.release()
-
- def Expect(self, message, result):
- self.__testNumber += 1
- info = "TEST | #{}. {}: {}".format(self.__testNumber,
- message, "PASS" if result else "FAIL")
- if result:
- print(info, flush=True)
- else:
- raise Exception(info)
-
- def CheckMessage(self, index, substring, negate):
- if len(self.__requests) <= index:
- raise Exception('HTTP Request #{} not captured.'.format(index))
-
- message = self.__requests[index]["body"]
- contains = substring in message or substring.replace(
- "'", "\"") in message
- return contains if not negate else not contains
-
- def ExpectMessage(self, index, substring):
- self.Expect("HTTP Request #{} contains \"{}\".".format(
- index, substring), self.CheckMessage(index, substring, False))
-
- def ExpectMessageNot(self, index, substring):
- self.Expect("HTTP Request #{} doesn't contain \"{}\".".format(
- index, substring), self.CheckMessage(index, substring, True))
-
-
-t = RequestVerifier()
-
-
-class Handler(SimpleHTTPRequestHandler):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, directory=appDir, **kwargs)
-
- def do_POST(self):
- body = ""
- content = self.rfile.read(int(self.headers['Content-Length']))
- parts = content.split(b'\n')
- for part in parts:
- try:
- body += '\n' + part.decode("utf-8")
- except:
- body += '\n(binary chunk: {} bytes)'.format(len(part))
- t.Capture(self.requestline, body)
- self.send_response(HTTPStatus.OK, '{'+'}')
- self.end_headers()
-
- # Special handling for .br (brotli) - we must send "Content-Encoding: br" header.
- # Therefore, we override `send_head()` with our custom implementation in that case
- def send_head(self):
- path = self.translate_path(self.path)
- if path.endswith('.br'):
- f = None
- try:
- f = open(path, 'rb')
- except OSError:
- self.send_error(HTTPStatus.NOT_FOUND, "File not found")
- return None
-
- ctype = self.guess_type(path[:-3])
- try:
- fs = os.fstat(f.fileno())
- self.send_response(HTTPStatus.OK)
- self.send_header("Content-Encoding", 'br')
- self.send_header("Content-type", ctype)
- self.send_header("Content-Length", str(fs[6]))
- self.send_header("Last-Modified",
- self.date_time_string(fs.st_mtime))
- self.end_headers()
- return f
- except:
- f.close()
- raise
- return super().send_head()
-
-
-appServer = ThreadingHTTPServer((host, port), Handler)
-appServerThread = Thread(target=appServer.serve_forever)
-appServerThread.start()
-time.sleep(1)
-
-
-class TestDriver:
- def __init__(self):
- options = Options()
- options.add_experimental_option('excludeSwitches', ['enable-logging'])
- options.add_argument('--headless')
- options.set_capability('goog:loggingPrefs', {'browser': 'ALL'})
- self.driver = webdriver.Chrome(options=options)
- self.driver.get('http://{}:{}?test=smoke'.format(host, port))
- self.messages = []
-
- def fetchMessages(self):
- for entry in self.driver.get_log('browser'):
- m = entry['message']
- entry['message'] = m[m.find('"'):].replace('\\n', '').strip('" ')
- self.messages.append(entry)
-
- def hasMessage(self, message):
- self.fetchMessages()
- return any(message in entry['message'] for entry in self.messages)
-
- def dumpMessages(self):
- self.fetchMessages()
- for entry in self.messages:
- print("CHROME: {} {}".format(datetime.datetime.fromtimestamp(
- entry['timestamp']/1000).strftime('%H:%M:%S.%f'), entry['message']), flush=True)
-
-
-def waitUntil(condition, interval=0.1, timeout=1):
- start = time.time()
- while not condition():
- if time.time() - start >= timeout:
- raise Exception('Waiting timed out'.format(condition))
- time.sleep(interval)
-
-
-driver = TestDriver()
-try:
- waitUntil(lambda: driver.hasMessage('SMOKE TEST: PASS'), timeout=10)
-finally:
- driver.dumpMessages()
- driver.driver.quit()
- appServer.shutdown()
-
-
-# Verify received API requests - see SmokeTester.cs - this is a copy-paste with minimal syntax changes
-currentMessage = 0
-t.ExpectMessage(currentMessage, "'type':'session'")
-currentMessage += 1
-t.ExpectMessage(currentMessage, "'type':'event'")
-t.ExpectMessage(currentMessage, "LogError(GUID)")
-t.ExpectMessage(currentMessage, "'user':{'id':'")
-# t.ExpectMessage(
-# currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'")
-# t.ExpectMessageNot(currentMessage, "'length':0")
-currentMessage += 1
-t.ExpectMessage(currentMessage, "'type':'event'")
-t.ExpectMessage(currentMessage, "CaptureMessage(GUID)")
-# t.ExpectMessage(
-# currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'")
-# t.ExpectMessageNot(currentMessage, "'length':0")
-currentMessage += 1
-t.ExpectMessage(currentMessage, "'type':'event'")
-t.ExpectMessage(
- currentMessage, "'message':'crumb','type':'error','data':{'foo':'bar'},'category':'bread','level':'fatal'}")
-t.ExpectMessage(currentMessage, "'message':'scope-crumb'}")
-t.ExpectMessage(currentMessage, "'extra':{'extra-key':42}")
-t.ExpectMessage(currentMessage, "'tag-key':'tag-value'")
-t.ExpectMessage(
- currentMessage, "'user':{'id':'user-id','username':'username','email':'email@example.com','ip_address':'::1','other':{'role':'admin'}}")
-
-# t.ExpectMessage(
-# currentMessage, "'filename':'screenshot.jpg','attachment_type':'event.attachment'")
-# t.ExpectMessageNot(currentMessage, "'length':0")
-print('TEST: PASS', flush=True)
diff --git a/src/Sentry.Unity/UnityWebRequestTransport.cs b/src/Sentry.Unity/UnityWebRequestTransport.cs
index 467a5f92f..c19a18d8c 100644
--- a/src/Sentry.Unity/UnityWebRequestTransport.cs
+++ b/src/Sentry.Unity/UnityWebRequestTransport.cs
@@ -15,6 +15,7 @@ internal class WebBackgroundWorker : IBackgroundWorker
{
private readonly SentryMonoBehaviour _behaviour;
private readonly UnityWebRequestTransport _transport;
+ private int _pendingItems;
public WebBackgroundWorker(SentryUnityOptions options, SentryMonoBehaviour behaviour)
{
@@ -24,13 +25,26 @@ public WebBackgroundWorker(SentryUnityOptions options, SentryMonoBehaviour behav
public bool EnqueueEnvelope(Envelope envelope)
{
- _behaviour.QueueCoroutine(_transport.SendEnvelopeAsync(envelope));
+ _pendingItems++;
+ _behaviour.QueueCoroutine(SendAndTrack(envelope));
return true;
}
+ private IEnumerator SendAndTrack(Envelope envelope)
+ {
+ try
+ {
+ yield return _transport.SendEnvelopeAsync(envelope);
+ }
+ finally
+ {
+ _pendingItems--;
+ }
+ }
+
public Task FlushAsync(TimeSpan timeout) => Task.CompletedTask;
- public int QueuedItems { get; }
+ public int QueuedItems => _pendingItems;
}
internal class UnityWebRequestTransport : HttpTransportBase
diff --git a/test/IntegrationTest/CommonTestCases.ps1 b/test/IntegrationTest/CommonTestCases.ps1
index 4f3aab1ea..3af169c25 100644
--- a/test/IntegrationTest/CommonTestCases.ps1
+++ b/test/IntegrationTest/CommonTestCases.ps1
@@ -100,6 +100,11 @@ $CommonTestCases = @(
@{ Name = "Contains OS context"; TestBlock = {
param($TestSetup, $TestType, $SentryEvent, $RunResult)
$SentryEvent.contexts.os | Should -Not -BeNullOrEmpty
+
+ if ($TestSetup.Platform -eq "WebGL") {
+ Set-ItResult -Skipped -Because "OS name is not available in the browser sandbox"
+ return
+ }
$SentryEvent.contexts.os.name | Should -Not -BeNullOrEmpty
}
}
diff --git a/test/IntegrationTest/Integration.Tests.WebGL.ps1 b/test/IntegrationTest/Integration.Tests.WebGL.ps1
new file mode 100644
index 000000000..b1a207586
--- /dev/null
+++ b/test/IntegrationTest/Integration.Tests.WebGL.ps1
@@ -0,0 +1,181 @@
+#!/usr/bin/env pwsh
+#
+# Integration tests for Sentry Unity SDK (WebGL)
+#
+# Environment variables:
+# SENTRY_WEBGL_BUILD_PATH: path to the WebGL build directory
+# SENTRY_TEST_DSN: test DSN
+# SENTRY_AUTH_TOKEN: authentication token for Sentry API
+
+Set-StrictMode -Version latest
+$ErrorActionPreference = "Stop"
+
+# Import app-runner modules
+. $PSScriptRoot/../../modules/app-runner/import-modules.ps1
+
+# Import shared test cases and utility functions
+. $PSScriptRoot/CommonTestCases.ps1
+
+BeforeAll {
+ # Run integration test action via WebGL (HTTP server + headless Chrome)
+ function Invoke-TestAction {
+ param (
+ [Parameter(Mandatory=$true)]
+ [string]$Action
+ )
+
+ Write-Host "Running $Action..."
+
+ $serverScript = Join-Path $PSScriptRoot "webgl-server.py"
+ $buildPath = $env:SENTRY_WEBGL_BUILD_PATH
+ $timeoutSeconds = 120
+
+ $process = Start-Process -FilePath "python3" `
+ -ArgumentList @($serverScript, $buildPath, $Action, $timeoutSeconds) `
+ -NoNewWindow -PassThru -RedirectStandardOutput "$PSScriptRoot/results/${Action}-stdout.txt" `
+ -RedirectStandardError "$PSScriptRoot/results/${Action}-stderr.txt"
+
+ $process | Wait-Process -Timeout ($timeoutSeconds + 30)
+
+ $exitCode = $process.ExitCode
+ $stdoutContent = Get-Content "$PSScriptRoot/results/${Action}-stdout.txt" -Raw -ErrorAction SilentlyContinue
+ $stderrContent = Get-Content "$PSScriptRoot/results/${Action}-stderr.txt" -Raw -ErrorAction SilentlyContinue
+
+ # Parse the JSON array of console lines from stdout
+ $output = @()
+ if ($stdoutContent) {
+ try {
+ $output = $stdoutContent | ConvertFrom-Json
+ }
+ catch {
+ Write-Host "Failed to parse webgl-server.py output as JSON: $_"
+ Write-Host "Raw stdout: $stdoutContent"
+ $output = @($stdoutContent)
+ }
+ }
+
+ if ($stderrContent) {
+ Write-Host "::group::Server stderr ($Action)"
+ Write-Host $stderrContent
+ Write-Host "::endgroup::"
+ }
+
+ $runResult = [PSCustomObject]@{
+ Output = $output
+ ExitCode = $exitCode
+ }
+
+ # Save result to JSON file
+ $runResult | ConvertTo-Json -Depth 5 | Out-File -FilePath (Get-OutputFilePath "${Action}-result.json")
+
+ # Print app output so it's visible in CI logs
+ Write-Host "::group::Browser console output ($Action)"
+ $runResult.Output | ForEach-Object { Write-Host $_ }
+ Write-Host "::endgroup::"
+
+ if ($exitCode -ne 0) {
+ Write-Warning "WebGL test action '$Action' did not complete (exit code: $exitCode)"
+ }
+
+ return $runResult
+ }
+
+ # Create directory for the test results
+ New-Item -ItemType Directory -Path "$PSScriptRoot/results/" -ErrorAction Continue 2>&1 | Out-Null
+ Set-OutputDir -Path "$PSScriptRoot/results/"
+
+ # Initialize test parameters
+ $script:TestSetup = [PSCustomObject]@{
+ Platform = "WebGL"
+ BuildPath = $env:SENTRY_WEBGL_BUILD_PATH
+ Dsn = $env:SENTRY_TEST_DSN
+ AuthToken = $env:SENTRY_AUTH_TOKEN
+ }
+
+ # Validate environment
+ if ([string]::IsNullOrEmpty($script:TestSetup.BuildPath)) {
+ throw "SENTRY_WEBGL_BUILD_PATH environment variable is not set."
+ }
+ if (-not (Test-Path $script:TestSetup.BuildPath)) {
+ throw "WebGL build not found at: $($script:TestSetup.BuildPath)"
+ }
+ if ([string]::IsNullOrEmpty($script:TestSetup.Dsn)) {
+ throw "SENTRY_TEST_DSN environment variable is not set."
+ }
+ if ([string]::IsNullOrEmpty($script:TestSetup.AuthToken)) {
+ throw "SENTRY_AUTH_TOKEN environment variable is not set."
+ }
+
+ Connect-SentryApi `
+ -ApiToken $script:TestSetup.AuthToken `
+ -DSN $script:TestSetup.Dsn
+}
+
+
+AfterAll {
+ Disconnect-SentryApi
+}
+
+
+Describe "Unity WebGL Integration Tests" {
+
+ Context "Message Capture" {
+ BeforeAll {
+ $script:runEvent = $null
+ $script:runResult = Invoke-TestAction -Action "message-capture"
+
+ $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1
+ if ($eventId) {
+ Write-Host "::group::Getting event content"
+ $script:runEvent = Get-SentryTestEvent -EventId "$eventId"
+ Write-Host "::endgroup::"
+ }
+ }
+
+ It "" -ForEach $CommonTestCases {
+ & $testBlock -SentryEvent $runEvent -TestType "message-capture" -RunResult $runResult -TestSetup $script:TestSetup
+ }
+
+ It "Has message level info" {
+ ($runEvent.tags | Where-Object { $_.key -eq "level" }).value | Should -Be "info"
+ }
+
+ It "Has message content" {
+ $runEvent.title | Should -Not -BeNullOrEmpty
+ }
+ }
+
+ Context "Exception Capture" {
+ BeforeAll {
+ $script:runEvent = $null
+ $script:runResult = Invoke-TestAction -Action "exception-capture"
+
+ $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1
+ if ($eventId) {
+ Write-Host "::group::Getting event content"
+ $script:runEvent = Get-SentryTestEvent -EventId "$eventId"
+ Write-Host "::endgroup::"
+ }
+ }
+
+ It "" -ForEach $CommonTestCases {
+ & $testBlock -SentryEvent $runEvent -TestType "exception-capture" -RunResult $runResult -TestSetup $script:TestSetup
+ }
+
+ It "Has exception information" {
+ $runEvent.exception | Should -Not -BeNullOrEmpty
+ $runEvent.exception.values | Should -Not -BeNullOrEmpty
+ }
+
+ It "Has exception with stacktrace" {
+ $exception = $runEvent.exception.values[0]
+ $exception | Should -Not -BeNullOrEmpty
+ $exception.type | Should -Not -BeNullOrEmpty
+ $exception.stacktrace | Should -Not -BeNullOrEmpty
+ }
+
+ It "Has error level" {
+ ($runEvent.tags | Where-Object { $_.key -eq "level" }).value | Should -Be "error"
+ }
+ }
+}
diff --git a/test/IntegrationTest/webgl-server.py b/test/IntegrationTest/webgl-server.py
new file mode 100644
index 000000000..05246d464
--- /dev/null
+++ b/test/IntegrationTest/webgl-server.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""
+HTTP server for WebGL integration tests.
+
+Serves a Unity WebGL build with proper Brotli Content-Encoding headers,
+launches headless Chrome to run the test, captures browser console output,
+and waits for the INTEGRATION_TEST_COMPLETE signal.
+
+Usage:
+ python3 webgl-server.py [timeout-seconds]
+
+Prints captured browser console lines to stdout (one per line).
+Exit code 0 if completion signal seen, 1 on timeout or error.
+"""
+
+import json
+import os
+import sys
+import time
+from http import HTTPStatus
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from threading import Thread
+
+from selenium import webdriver
+from selenium.webdriver.chrome.options import Options
+
+HOST = "127.0.0.1"
+PORT = 8000
+
+
+def create_handler(app_dir):
+ class Handler(SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory=app_dir, **kwargs)
+
+ def do_POST(self):
+ # Accept POST requests (Sentry envelope endpoint) and respond OK
+ content_length = int(self.headers.get("Content-Length", 0))
+ self.rfile.read(content_length)
+ self.send_response(HTTPStatus.OK)
+ self.end_headers()
+
+ def send_head(self):
+ path = self.translate_path(self.path)
+ if path.endswith(".br"):
+ try:
+ f = open(path, "rb")
+ except OSError:
+ self.send_error(HTTPStatus.NOT_FOUND, "File not found")
+ return None
+ ctype = self.guess_type(path[:-3])
+ try:
+ fs = os.fstat(f.fileno())
+ self.send_response(HTTPStatus.OK)
+ self.send_header("Content-Encoding", "br")
+ self.send_header("Content-type", ctype)
+ self.send_header("Content-Length", str(fs[6]))
+ self.send_header(
+ "Last-Modified", self.date_time_string(fs.st_mtime)
+ )
+ self.end_headers()
+ return f
+ except Exception:
+ f.close()
+ raise
+ return super().send_head()
+
+ def log_message(self, format, *args):
+ # Suppress request logging to keep output clean
+ pass
+
+ return Handler
+
+
+def parse_console_message(raw_msg):
+ """Extract the actual message from a Chrome console log entry.
+
+ Chrome formats console messages as: 'URL LINE:COL "actual message"'
+ """
+ quote_start = raw_msg.find('"')
+ if quote_start >= 0:
+ raw_msg = raw_msg[quote_start:].strip('" ')
+ return raw_msg.replace("\\n", "\n")
+
+
+def run_test(app_dir, test_action, timeout_seconds):
+ # Start HTTP server
+ handler_class = create_handler(app_dir)
+ server = ThreadingHTTPServer((HOST, PORT), handler_class)
+ server_thread = Thread(target=server.serve_forever, daemon=True)
+ server_thread.start()
+
+ # Small delay for server startup
+ time.sleep(0.5)
+
+ # Launch headless Chrome
+ options = Options()
+ options.add_experimental_option("excludeSwitches", ["enable-logging"])
+ options.add_argument("--headless")
+ options.add_argument("--no-sandbox")
+ options.add_argument("--disable-dev-shm-usage")
+ options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
+
+ driver = webdriver.Chrome(options=options)
+ url = f"http://{HOST}:{PORT}?test={test_action}"
+ driver.get(url)
+
+ collected_lines = []
+ complete = False
+ start_time = time.time()
+
+ try:
+ while time.time() - start_time < timeout_seconds:
+ for entry in driver.get_log("browser"):
+ msg = parse_console_message(entry["message"])
+ collected_lines.append(msg)
+
+ if "INTEGRATION_TEST_COMPLETE" in msg:
+ complete = True
+
+ if complete:
+ # Give a brief moment for any final console messages
+ time.sleep(1)
+ for entry in driver.get_log("browser"):
+ collected_lines.append(parse_console_message(entry["message"]))
+ break
+
+ time.sleep(0.5)
+ finally:
+ driver.quit()
+ server.shutdown()
+
+ # Output collected lines as JSON array for easy parsing by PowerShell.
+ # This must be in the finally block so partial output is emitted even
+ # when the polling loop fails (e.g. Chrome crash).
+ print(json.dumps(collected_lines))
+
+ return 0 if complete else 1
+
+
+if __name__ == "__main__":
+ if len(sys.argv) < 3:
+ print(f"Usage: {sys.argv[0]} [timeout-seconds]", file=sys.stderr)
+ sys.exit(2)
+
+ app_dir = sys.argv[1]
+ test_action = sys.argv[2]
+ timeout = int(sys.argv[3]) if len(sys.argv) > 3 else 60
+
+ sys.exit(run_test(app_dir, test_action, timeout))
diff --git a/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs b/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs
index c03c43f77..2c501b269 100644
--- a/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs
+++ b/test/Scripts.Integration.Test/Scripts/IntegrationTester.cs
@@ -17,10 +17,10 @@ public void Start()
switch (arg)
{
case "message-capture":
- MessageCapture();
+ StartCoroutine(MessageCapture());
break;
case "exception-capture":
- ExceptionCapture();
+ StartCoroutine(ExceptionCapture());
break;
case "crash-capture":
StartCoroutine(CrashCapture());
@@ -30,7 +30,9 @@ public void Start()
break;
default:
Debug.LogError($"IntegrationTester: Unknown command: {arg}");
+#if !UNITY_WEBGL
Application.Quit(1);
+#endif
break;
}
}
@@ -54,17 +56,17 @@ private void AddIntegrationTestContext(string testType)
SentrySdk.AddBreadcrumb("Context configuration finished");
}
- private void MessageCapture()
+ private IEnumerator MessageCapture()
{
AddIntegrationTestContext("message-capture");
var eventId = SentrySdk.CaptureMessage("Integration test message");
Debug.Log($"EVENT_CAPTURED: {eventId}");
- Application.Quit(0);
+ yield return CompleteAndQuit();
}
- private void ExceptionCapture()
+ private IEnumerator ExceptionCapture()
{
AddIntegrationTestContext("exception-capture");
@@ -78,7 +80,22 @@ private void ExceptionCapture()
Debug.Log($"EVENT_CAPTURED: {eventId}");
}
+ yield return CompleteAndQuit();
+ }
+
+ private IEnumerator CompleteAndQuit()
+ {
+#if UNITY_WEBGL
+ // On WebGL, envelope sends are coroutine-based and need additional frames to
+ // complete. Wait to avoid a race where the test harness shuts down the browser
+ // before the send finishes.
+ yield return new WaitForSeconds(3);
+ Debug.Log("INTEGRATION_TEST_COMPLETE");
+#else
+ Debug.Log("INTEGRATION_TEST_COMPLETE");
Application.Quit(0);
+ yield break;
+#endif
}
// Use a deeper call stack with NoInlining to ensure Unity 2022's IL2CPP
diff --git a/test/Scripts.Integration.Test/integration-test.ps1 b/test/Scripts.Integration.Test/integration-test.ps1
index d6c1ddc5a..8d14be637 100644
--- a/test/Scripts.Integration.Test/integration-test.ps1
+++ b/test/Scripts.Integration.Test/integration-test.ps1
@@ -96,7 +96,8 @@ Else {
./scripts/smoke-test-ios.ps1 Test "latest" -IsIntegrationTest
}
"^WebGL$" {
- python3 scripts/smoke-test-webgl.py $(GetNewProjectBuildPath)
+ $env:SENTRY_WEBGL_BUILD_PATH = GetNewProjectBuildPath
+ Invoke-Pester -Path test/IntegrationTest/Integration.Tests.WebGL.ps1 -CI
}
"^Switch$" {
Write-PhaseSuccess "Switch build completed - no automated test execution available"