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()