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"