diff --git a/.github/workflows/integration-tests-sentry-cli.yml b/.github/workflows/integration-tests-sentry-cli.yml new file mode 100644 index 00000000..23481023 --- /dev/null +++ b/.github/workflows/integration-tests-sentry-cli.yml @@ -0,0 +1,24 @@ +name: integration-tests-sentry-cli + +on: + push: + branches: + - main + - release/** + pull_request: + +jobs: + integration-test: + runs-on: ubuntu-latest + env: + SENTRY_URL: http://127.0.0.1:8000 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10.5' + + - name: Start server and run integration test for sentry-cli commands + run: | + test/integration-test-server-start.sh & + ./gradlew -p plugin-build test --tests SentryPluginIntegrationTest diff --git a/examples/android-gradle/src/main/AndroidManifest.xml b/examples/android-gradle/src/main/AndroidManifest.xml index e7972de3..17b6b62d 100644 --- a/examples/android-gradle/src/main/AndroidManifest.xml +++ b/examples/android-gradle/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ - + + + + + + + diff --git a/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginIntegrationTest.kt b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginIntegrationTest.kt new file mode 100644 index 00000000..4bdcd498 --- /dev/null +++ b/plugin-build/src/test/kotlin/io/sentry/android/gradle/SentryPluginIntegrationTest.kt @@ -0,0 +1,90 @@ +package io.sentry.android.gradle + +import kotlin.test.assertEquals +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@Suppress("FunctionName") +@RunWith(Parameterized::class) +class SentryPluginIntegrationTest( + androidGradlePluginVersion: String, + gradleVersion: String +) : BaseSentryPluginTest(androidGradlePluginVersion, gradleVersion) { + + @Test + fun uploadSentryProguardMappingsIntegration() { + if (System.getenv("SENTRY_URL").isNullOrBlank()) { + return // Don't run test if local test server endpoint is not set + } + applyAutoUploadProguardMapping() + + val build = runner + .appendArguments(":app:assembleRelease") + .build() + + assertEquals( + build.task(":app:uploadSentryProguardMappingsRelease")?.outcome, + TaskOutcome.SUCCESS + ) + } + + @Test + fun uploadNativeSymbols() { + if (System.getenv("SENTRY_URL").isNullOrBlank()) { + return // Don't run test if local test server endpoint is not set + } + applyUploadNativeSymbols() + + val build = runner + .appendArguments(":app:assembleRelease") + .build() + + assertEquals( + build.task(":app:uploadSentryNativeSymbolsForRelease")?.outcome, + TaskOutcome.SUCCESS + ) + } + + private fun applyAutoUploadProguardMapping() { + appBuildFile.writeText( + // language=Groovy + """ + plugins { + id "com.android.application" + id "io.sentry.android.gradle" + } + + sentry { + includeProguardMapping = true + autoUploadProguardMapping = true + uploadNativeSymbols = false + tracingInstrumentation { + enabled = false + } + } + """.trimIndent() + ) + } + + private fun applyUploadNativeSymbols() { + appBuildFile.writeText( + // language=Groovy + """ + plugins { + id "com.android.application" + id "io.sentry.android.gradle" + } + + sentry { + autoUploadProguardMapping = false + uploadNativeSymbols = true + tracingInstrumentation { + enabled = false + } + } + """.trimIndent() + ) + } +} diff --git a/test/assets/AndroidManifest.xml b/test/assets/AndroidManifest.xml new file mode 100644 index 00000000..0e54328c --- /dev/null +++ b/test/assets/AndroidManifest.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/artifact.json b/test/assets/artifact.json new file mode 100644 index 00000000..43bce355 --- /dev/null +++ b/test/assets/artifact.json @@ -0,0 +1,10 @@ +{ + "id": "fixture-id", + "sha1": "fixture-sha1", + "name": "fixture-name", + "size": 1, + "dist": null, + "headers": { + "fixture-header-key": "fixture-header-value" + } +} diff --git a/test/assets/artifacts.json b/test/assets/artifacts.json new file mode 100644 index 00000000..a7d52378 --- /dev/null +++ b/test/assets/artifacts.json @@ -0,0 +1,22 @@ +[ + { + "id": "6796495645", + "name": "~/dist/bundle.min.js", + "dist": "foo", + "headers": { + "Sourcemap": "dist/bundle.min.js.map" + }, + "size": 497, + "sha1": "2fb719956748ab7ec5ae9bcb47606733f5589b72", + "dateCreated": "2022-05-12T11:08:01.520199Z" + }, + { + "id": "6796495646", + "name": "~/dist/bundle.min.js.map", + "dist": "foo", + "headers": {}, + "size": 1522, + "sha1": "f818059cbf617a8fae9b4e46d08f6c0246bb1624", + "dateCreated": "2022-05-12T11:08:01.496220Z" + } +] \ No newline at end of file diff --git a/test/assets/assemble-artifacts-response.json b/test/assets/assemble-artifacts-response.json new file mode 100644 index 00000000..0c71ed67 --- /dev/null +++ b/test/assets/assemble-artifacts-response.json @@ -0,0 +1,5 @@ +{ + "state": "ok", + "missingChunks": [], + "detail": null +} diff --git a/test/assets/associate-dsyms-response.json b/test/assets/associate-dsyms-response.json new file mode 100644 index 00000000..6b7f7f46 --- /dev/null +++ b/test/assets/associate-dsyms-response.json @@ -0,0 +1,15 @@ +{ + "associatedDsymFiles": [ + { + "uuid": null, + "debugId": null, + "objectName": "fixture-objectName", + "cpuName": "fixture-cpuName", + "sha1": "fixture-sha1", + "data": { + "type": null, + "features": ["fixture-feature"] + } + } + ] +} diff --git a/test/assets/debug-info-files.json b/test/assets/debug-info-files.json new file mode 100644 index 00000000..7ef5e520 --- /dev/null +++ b/test/assets/debug-info-files.json @@ -0,0 +1,13 @@ +[ + { + "uuid": null, + "debugId": null, + "objectName": "fixture-objectName", + "cpuName": "fixture-cpuName", + "sha1": "fixture-sha1", + "data": { + "type": null, + "features": ["fixture-feature"] + } + } +] \ No newline at end of file diff --git a/test/assets/deploy.json b/test/assets/deploy.json new file mode 100644 index 00000000..39d8169e --- /dev/null +++ b/test/assets/deploy.json @@ -0,0 +1,8 @@ +{ + "id": "1", + "environment": "production", + "dateStarted": null, + "dateFinished": "2022-01-01T12:00:00.000000Z", + "name": "fixture-deploy", + "url": null +} diff --git a/test/assets/release.json b/test/assets/release.json new file mode 100644 index 00000000..27af3053 --- /dev/null +++ b/test/assets/release.json @@ -0,0 +1,44 @@ +{ + "dateReleased": "2022-01-01T12:00:00.000000Z", + "newGroups": 0, + "commitCount": 0, + "url": null, + "data": + {}, + "lastDeploy": null, + "deployCount": 0, + "dateCreated": "2022-01-01T12:00:00.000000Z", + "lastEvent": null, + "version": "1.1.0", + "firstEvent": null, + "lastCommit": null, + "shortVersion": "1.1.0", + "authors": + [], + "owner": null, + "versionInfo": + { + "buildHash": null, + "version": + { + "raw": "1.1.0" + }, + "description": "1.1.0", + "package": null + }, + "ref": null, + "projects": + [ + { + "name": "Sentry Fastlane App", + "platform": "ruby", + "slug": "sentry-fastlane-plugin", + "platforms": + [ + "ruby" + ], + "newGroups": 0, + "id": 1234567 + } + ] +} diff --git a/test/assets/repos.json b/test/assets/repos.json new file mode 100644 index 00000000..998b89b8 --- /dev/null +++ b/test/assets/repos.json @@ -0,0 +1,13 @@ +[ + { + "id": "1", + "name": "sentry/sentry-fastlane-plugin", + "url": null, + "provider": { + "id": "1", + "name": "fixture-name" + }, + "status": "fixture-status", + "dateCreated": "2022-01-01T12:00:00.000000Z" + } +] diff --git a/test/integration-test-server-start.sh b/test/integration-test-server-start.sh new file mode 100755 index 00000000..9fc54bf8 --- /dev/null +++ b/test/integration-test-server-start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +python3 test/integration-test-server.py diff --git a/test/integration-test-server-stop.sh b/test/integration-test-server-stop.sh new file mode 100755 index 00000000..a9168edd --- /dev/null +++ b/test/integration-test-server-stop.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +curl http://127.0.0.1:8000/STOP diff --git a/test/integration-test-server.py b/test/integration-test-server.py new file mode 100644 index 00000000..a8e140bd --- /dev/null +++ b/test/integration-test-server.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 + +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse +import sys +import threading +import binascii +import json + +apiOrg = 'sentry-sdks' +apiProject = 'sentry-android' +uri = urlparse(sys.argv[1] if len(sys.argv) > 1 else 'http://127.0.0.1:8000') +version='1.1.0' +appIdentifier='com.sentry.fastlane.app' + +class Handler(BaseHTTPRequestHandler): + body = None + + def do_GET(self): + self.start_response(HTTPStatus.OK) + + if self.path == "/STOP": + print("HTTP server stopping!") + threading.Thread(target=self.server.shutdown).start() + return + + if self.isApi('api/0/organizations/{}/chunk-upload/'.format(apiOrg)): + self.writeJSON('{"url":"' + uri.geturl() + self.path + '",' + '"chunkSize":8388608,"chunksPerRequest":64,"maxFileSize":2147483648,' + '"maxRequestSize":33554432,"concurrency":1,"hashAlgorithm":"sha1","compression":["gzip"],' + '"accept":["debug_files","release_files","pdbs","sources","bcsymbolmaps"]}') + elif self.isApi('/api/0/organizations/{}/repos/?cursor='.format(apiOrg)): + self.writeJSONFile("test/assets/repos.json") + elif self.isApi('/api/0/organizations/{}/releases/{}/previous-with-commits/'.format(apiOrg, version)): + self.writeJSONFile("test/assets/release.json") + elif self.isApi('/api/0/projects/{}/{}/releases/{}/files/?cursor='.format(apiOrg, apiProject, version)): + self.writeJSONFile("test/assets/artifacts.json") + else: + self.end_headers() + + self.flushLogs() + + def do_POST(self): + self.start_response(HTTPStatus.OK) + + if self.isApi('api/0/projects/{}/{}/files/difs/assemble/'.format(apiOrg, apiProject)): + # Request body example: + # { + # "9a01653a...":{"name":"UnityPlayer.dylib","debug_id":"eb4a7644-...","chunks":["f84d3907945cdf41b33da8245747f4d05e6ffcb4", ...]}, + # "4185e454...":{"name":"UnityPlayer.dylib","debug_id":"86d95b40-...","chunks":[...]} + # } + # Response body to let the CLI know we have the symbols already (we don't need to test the actual upload): + # { + # "9a01653a...":{"state":"ok","missingChunks":[]}, + # "4185e454...":{"state":"ok","missingChunks":[]} + # } + jsonRequest = json.loads(self.body) + jsonResponse = '{' + for key, value in jsonRequest.items(): + jsonResponse += '"{}":{{"state":"ok","missingChunks":[]}},'.format( + key) + self.log_message('Received: %40s %40s %s', key, + value['debug_id'], value['name']) + jsonResponse = jsonResponse.rstrip(',') + '}' + self.writeJSON(jsonResponse) + elif self.isApi('api/0/projects/{}/{}/releases/'.format(apiOrg, apiProject)): + self.writeJSONFile("test/assets/release.json") + elif self.isApi('/api/0/organizations/{}/releases/{}@{}/deploys/'.format(apiOrg, appIdentifier, version)): + self.writeJSONFile("test/assets/deploy.json") + elif self.isApi('/api/0/projects/{}/{}/releases/{}@{}/files/'.format(apiOrg, apiProject, appIdentifier, version)): + self.writeJSONFile("test/assets/artifact.json") + elif self.isApi('/api/0/organizations/{}/releases/{}/assemble/'.format(apiOrg, version)): + self.writeJSONFile("test/assets/assemble-artifacts-response.json") + elif self.isApi('/api/0/projects/{}/{}/files/dsyms/'.format(apiOrg, apiProject)): + self.writeJSONFile("test/assets/debug-info-files.json") + elif self.isApi('/api/0/projects/{}/{}/files/dsyms/associate/'.format(apiOrg, apiProject)): + self.writeJSONFile("test/assets/associate-dsyms-response.json") + else: + self.end_headers() + + self.flushLogs() + + def do_PUT(self): + self.start_response(HTTPStatus.OK) + + if self.isApi('/api/0/organizations/{}/releases/{}/'.format(apiOrg, version)): + self.writeJSONFile("test/assets/release.json") + else: + self.end_headers() + + self.flushLogs() + + def start_response(self, code): + self.body = None + self.log_request(code) + self.send_response_only(code) + + def log_request(self, code=None, size=None): + if isinstance(code, HTTPStatus): + code = code.value + body = self.body = self.requestBody() + if body: + body = self.body[0:min(1000, len(body))] + self.log_message('"%s" %s %s%s', + self.requestline, str(code), "({} bytes)".format(size) if size else '', body) + + # Note: this may only be called once during a single request - can't `.read()` the same stream again. + def requestBody(self): + if self.command == "POST" and 'Content-Length' in self.headers: + length = int(self.headers['Content-Length']) + content = self.rfile.read(length) + try: + return content.decode("utf-8") + except: + return binascii.hexlify(bytearray(content)) + return None + + def isApi(self, api: str): + if self.path.strip('/') == api.strip('/'): + self.log_message("Matched API endpoint {}".format(api)) + return True + return False + + def writeJSONFile(self, file_name: str): + json_file = open(file_name, "r") + self.writeJSON(json_file.read()) + json_file.close() + + def writeJSON(self, string: str): + self.send_header("Content-type", "application/json") + self.end_headers() + self.wfile.write(str.encode(string)) + + def flushLogs(self): + sys.stdout.flush() + sys.stderr.flush() + + +print("HTTP server listening on {}".format(uri.geturl())) +print("To stop the server, execute a GET request to {}/STOP".format(uri.geturl())) +httpd = ThreadingHTTPServer((uri.hostname, uri.port), Handler) +target = httpd.serve_forever()