diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..2905bd5 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -0,0 +1,48 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Android CI + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Android CI / build + # zizmor: ignore[unpinned-images] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + + - name: Build and test library + run: | + chmod +x ./gradlew + ./gradlew test assembleRelease --no-daemon + working-directory: client/android/GoogleMapsA2UI diff --git a/.github/workflows/cleanup-stale-prs.yml b/.github/workflows/cleanup-stale-prs.yml new file mode 100644 index 0000000..d238b98 --- /dev/null +++ b/.github/workflows/cleanup-stale-prs.yml @@ -0,0 +1,84 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Cleanup Stale Draft PRs + +on: + schedule: + - cron: '0 2 * * *' # Daily at 02:00 UTC + workflow_dispatch: + inputs: + older_than_days: + description: 'Close draft PRs older than N days' + required: false + default: '3' + type: string + dry_run: + description: 'Dry run (simulate without closing PRs or deleting branches)' + required: false + default: false + type: boolean + +permissions: + pull-requests: write + contents: write + +jobs: + cleanup: + name: Cleanup Draft PRs + # zizmor: ignore[unpinned-images] + runs-on: ubuntu-latest + steps: + - name: Close stale draft PRs and delete branches + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + INPUT_DAYS: ${{ inputs.older_than_days }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + DAYS="${INPUT_DAYS:-3}" + DRY_RUN="${INPUT_DRY_RUN:-false}" + + echo "Searching for open draft PRs with head branch matching 'test_*' older than $DAYS day(s)..." + + CUTOFF_EPOCH=$(date -d "$DAYS days ago" +%s) + echo "Cutoff timestamp: $CUTOFF_EPOCH ($(date -d "@$CUTOFF_EPOCH" --utc --iso-8601=seconds))" + + PRS_JSON=$(gh pr list --repo "$GH_REPO" --state open --draft --json number,headRefName,updatedAt) + + echo "$PRS_JSON" | jq -c '.[]' | while read -r pr; do + PR_NUMBER=$(echo "$pr" | jq -r '.number') + HEAD_REF=$(echo "$pr" | jq -r '.headRefName') + UPDATED_AT=$(echo "$pr" | jq -r '.updatedAt') + + # Only target Copybara presubmit branches (prefix test_) + if [[ ! "$HEAD_REF" =~ ^test_ ]]; then + echo "Skipping PR #$PR_NUMBER (head branch '$HEAD_REF' does not match 'test_*')" + continue + fi + + PR_EPOCH=$(date -d "$UPDATED_AT" +%s) + if [ "$PR_EPOCH" -lt "$CUTOFF_EPOCH" ]; then + echo "PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) is older than $DAYS day(s)." + if [ "$DRY_RUN" = "true" ]; then + echo "[DRY RUN] Would close PR #$PR_NUMBER and delete branch '$HEAD_REF'" + else + echo "Closing PR #$PR_NUMBER and deleting branch '$HEAD_REF'..." + gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR and cleaning up branch." --delete-branch || \ + gh pr close "$PR_NUMBER" --repo "$GH_REPO" --comment "Automatically closing stale presubmit draft PR." + fi + else + echo "Keeping PR #$PR_NUMBER ($HEAD_REF, updated at $UPDATED_AT) - active within $DAYS day(s)." + fi + done diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml new file mode 100644 index 0000000..6c8352a --- /dev/null +++ b/.github/workflows/ios-ci.yml @@ -0,0 +1,45 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: iOS CI + +on: + pull_request: + branches: [ main ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: iOS CI / build + # Pinned to macos-15 so the bundled Xcode and iOS Simulator lineup stay stable. + # zizmor: ignore[unpinned-images] + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Build and test package + run: | + xcodebuild test \ + -scheme GoogleMapsA2UI \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -skipPackagePluginValidation \ + CODE_SIGNING_ALLOWED=NO + working-directory: client/ios/GoogleMapsA2UI diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 8ee8f3b..70ba831 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -22,7 +22,10 @@ on: jobs: build: + name: Python CI / build runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -33,7 +36,7 @@ jobs: enable-cache: auto - name: Set up Python - run: uv python install + run: uv python install 3.13 - name: Install dependencies run: uv sync --all-groups @@ -48,3 +51,7 @@ jobs: run: uv run ruff check . working-directory: agent/python_agent continue-on-error: true + + - name: Run unit tests + run: uv run python -m unittest discover -s . -p "test_*.py" + working-directory: agent/python_agent diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc62731..165d705 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,11 @@ on: workflow_dispatch: + inputs: + dry_run: + description: "Run in dry-run mode (no tags, no publish)" + type: boolean + default: true permissions: contents: write @@ -45,7 +50,7 @@ jobs: - name: Install dependencies working-directory: client/web - run: npm ci + run: npm install - name: Setup Node for Publishing uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 @@ -60,5 +65,15 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_WOMBAT_TOKEN }} NODE_PATH: ${{ github.workspace }}/client/web/node_modules - run: npx --prefix client/web semantic-release + DRY_RUN: ${{ inputs.dry_run }} + REF_NAME: ${{ github.ref_name }} + run: | + EXTRA_ARGS="" + if [ "$DRY_RUN" != "false" ]; then + EXTRA_ARGS="--dry-run" + fi + if [ "$REF_NAME" != "main" ]; then + EXTRA_ARGS="$EXTRA_ARGS --branches $REF_NAME" + fi + npx --prefix client/web semantic-release $EXTRA_ARGS diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index a55bc4d..5272949 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -25,6 +25,7 @@ permissions: jobs: build: + name: Web CI / build # zizmor: ignore[unpinned-images] runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 8c4f481..5f48f4e 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -1,3 +1,17 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + name: Zizmor on: @@ -24,3 +38,6 @@ jobs: - name: Run zizmor uses: zizmorcore/zizmor-action@195d10ad90f31d8cd6ea1efd6ecc12969ddbe73f # v0.5.1 + with: + args: --ignore insufficient-cooldown + diff --git a/.releaserc.json b/.releaserc.json index 089c315..15c8354 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -3,7 +3,34 @@ "main" ], "plugins": [ - "@semantic-release/commit-analyzer", + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": [ + { + "breaking": true, + "release": "patch" + }, + { + "type": "feat", + "release": "patch" + }, + { + "type": "fix", + "release": "patch" + }, + { + "type": "perf", + "release": "patch" + }, + { + "type": "refactor", + "release": "patch" + } + ] + } + ], "@semantic-release/release-notes-generator", "@semantic-release/changelog", [ diff --git a/README.md b/README.md index a153511..b847351 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ This package provides the core Python agent implementations for the Agentic UI T * `agent.py`: Contains the `MAUIAgent` class, which handles session management, LLM interaction, and A2UI schema loading. * `agent_with_grounding.py`: Contains `MAUIAgentWithGrounding`, extending the base agent with Vertex Grounding capabilities. +* `streaming_request_handler.py`: Contains `StreamingRequestHandler` (extending `DefaultRequestHandler`), enabling the A2A server to handle both incremental SSE streaming (`message/stream`) and non-streaming (`message/send`) JSON-RPC requests. * `shared/`: Contains schema extensions (e.g., `maps_catalog_extension.json`). * `skills/`: Contains specific skill definitions used by the agents. * `pyproject.toml`: Configuration file for the package, using Hatchling as the build backend. @@ -185,6 +186,25 @@ class MAUIAgentExecutor(AgentExecutor): raise ServerError(error=UnsupportedOperationError()) ``` +#### 3. Enabling Real-Time SSE Streaming (`StreamingRequestHandler`) + +To serve both real-time Server-Sent Events (`message/stream`) and standard single-response requests (`message/send`) from your `A2AStarletteApplication`, pass `StreamingRequestHandler` as the `http_handler`: + +```python +from a2a.server.apps import A2AStarletteApplication +from a2a.server.tasks import InMemoryTaskStore +from streaming_request_handler import StreamingRequestHandler + +request_handler = StreamingRequestHandler( + agent_executor=agent_executor, + task_store=InMemoryTaskStore(), +) +server = A2AStarletteApplication( + agent_card=default_agent.agent_card, + http_handler=request_handler, +) +``` + ## Agentic UI Toolkit Client Libraries This repository provides A2UI client libraries across multiple platforms (Web, Android, and iOS) to render interactive A2UI surfaces and communicate with an A2A agent server. @@ -254,6 +274,28 @@ return ( ); ``` +#### 3. Streaming Responses (`sendStream`) +`A2UIClient` also supports real-time incremental Server-Sent Events (SSE) streaming via `client.sendStream(messageText)` (`message/stream`). You can use a `useStreaming` boolean flag to switch between streaming (`client.sendStream`) and non-streaming (`client.send`): + +```tsx +const useStreaming = true; // Set to false to use non-streaming client.send() + +async function handleSend(messageText: string) { + renderer.addUserMessage(messageText); + + if (useStreaming) { + // Stream incremental text and A2UI component updates via SSE (message/stream) + for await (const chunk of client.sendStream(messageText)) { + renderer.processResponse([chunk]); + } + } else { + // Wait for the full response in a single payload (message/send) + const response = await client.send(messageText); + renderer.processResponse(response); + } +} +``` + #### Local Development To make changes to this package and test them in an application: @@ -297,7 +339,6 @@ Agentic UI Toolkit requires an API Key to use Google Maps Platform products. To Your API Key must have the following APIs enabled in the [Google Cloud Console](https://console.cloud.google.com/apis/credentials): -* Geocoding API * Maps JavaScript API * Places UI Kit * Routes API @@ -312,15 +353,46 @@ To support the use of Grounding Lite within the Python ADK backend, this API Key Your API Key must also be included when loading the Google Maps JavaScript API code. See the [Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/load-maps-js-api) for instructions on how to load the API, including configuring the API Key. -Agentic UI Toolkit requires features available in the Alpha channel. You must use `v=alpha` when loading the Maps JavaScript API. Learn more about versions in the [Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/versions). - -Use of Agentic UI Toolkit requires several [Maps JavaScript API libraries](https://developers.google.com/maps/documentation/javascript/libraries). When loading the Google Maps JavaScript API, you must include the following libraries: +Agentic UI Toolkit requires features available in the Alpha channel. You must +use `v: "alpha"` when loading the Maps JavaScript API. + +MAUI components are able to dynamically load their required libraries, enabling +both the +[Dynamic Library Import](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import) +and +[Script Tag](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#use-legacy-tag) +methods of loading the Maps JavaScript API. + +If you use the script tag, do not load it asynchronously (no `async` attribute +or `loading=async` parameter). MAUI components need `google.maps.importLibrary` +to exist when they first render. An asynchronously loaded script tag only +defines it once the full API has downloaded, even if you list the libraries +below in its `libraries` parameter, so components that render before then will +not appear. + +The specific libraries required by MAUI components are: + +* `maps3d` +* `places` +* `routes` + +Alternatively, web apps that do not load the Maps JavaScript API themselves can +let the web client library load it the first time a component renders. Call +`configureMapsJsApi` once from your app's JavaScript or TypeScript entry point +(for example `main.ts` or `app.ts`), before rendering any components. It is a +module export, so it does not go in an `index.html` ` + - + diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt index 56d43bc..ed6ad25 100644 --- a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParser.kt @@ -17,6 +17,12 @@ package com.google.android.libraries.mapsplatform.a2ui import org.json.JSONArray import org.json.JSONObject +sealed class A2AParserException(message: String) : Exception(message) { + class InvalidPayloadStructure( + message: String = "The raw JSON response lacks a recognized message parts structure." + ) : A2AParserException(message) +} + data class ParsedA2AEventMetadata(val mimeType: String?) sealed interface ParsedA2AEvent { @@ -38,7 +44,6 @@ object A2AResponseParser { private const val KEY_CONTENT = "content" private const val KEY_STATUS = "status" private const val KEY_MESSAGE = "message" - private const val KEY_RESULT = "result" private const val KEY_CREATE_SURFACE = "createSurface" private const val KEY_UPDATE_COMPONENTS = "updateComponents" @@ -46,98 +51,53 @@ object A2AResponseParser { private const val KEY_DELETE_SURFACE = "deleteSurface" private const val KEY_SURFACE_ID = "surfaceId" + private val A2UI_PATTERN = "(.*?)".toRegex(RegexOption.DOT_MATCHES_ALL) + fun parse(rawJson: JSONObject): List { - val partsList = mutableListOf() - val partsArray = extractPartsArray(rawJson) - - if (partsArray != null) { - var currentTextBuilder = java.lang.StringBuilder() - var currentUiElements = JSONArray() - - for (i in 0 until partsArray.length()) { - val part = partsArray.getJSONObject(i) - val textPart = - if (part.has(KEY_TEXT)) part.optString(KEY_TEXT) - else if (part.optString(KEY_KIND) == KEY_TEXT) part.optString(KEY_TEXT) else null - - if (textPart != null) { - if (currentUiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) - currentUiElements = JSONArray() - } + val partsArray = + extractPartsArray(rawJson) ?: throw A2AParserException.InvalidPayloadStructure() - if (textPart.contains("---a2ui_JSON---") || textPart.contains("```json")) { - extractJsonBlocks(textPart, currentTextBuilder, partsList) - } else if (textPart.isNotEmpty()) { - if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") - currentTextBuilder.append(textPart) - } + val partsList = mutableListOf() + var currentTextBuilder = java.lang.StringBuilder() + var currentUiElements = JSONArray() + + for (i in 0 until partsArray.length()) { + val part = partsArray.getJSONObject(i) + val textPart = + if (part.has(KEY_TEXT)) part.optString(KEY_TEXT) + else if (part.optString(KEY_KIND) == KEY_TEXT) part.optString(KEY_TEXT) else null + + if (textPart != null) { + if (currentUiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) + currentUiElements = JSONArray() } - val dataPayload = - if (part.has(KEY_DATA)) part.optJSONObject(KEY_DATA) - else if (part.optString(KEY_KIND) == KEY_DATA) part.optJSONObject(KEY_DATA) else null - if (dataPayload != null && isUiElement(dataPayload)) { - if (currentTextBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) - currentTextBuilder = java.lang.StringBuilder() - } - currentUiElements.put(dataPayload) + if (textPart.contains("")) { + extractJsonBlocks(textPart, currentTextBuilder, partsList) + } else if (textPart.isNotEmpty()) { + if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") + currentTextBuilder.append(textPart) } } - if (currentTextBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) - } - if (currentUiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) - } - } else { - try { - val resultObj = rawJson.opt(KEY_RESULT) - if (resultObj is String) { - if (resultObj.isNotEmpty()) { - val firstChar = resultObj.trim().firstOrNull() - if (firstChar == '[') { - val array = JSONArray(resultObj) - val uiElements = JSONArray() - for (j in 0 until array.length()) { - val item = array.optJSONObject(j) - if (item != null && isUiElement(item)) { - uiElements.put(item) - } - } - if (uiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(uiElements.toString())) - } - } - } - } else if (resultObj is JSONArray) { - var currentTextBuilder = java.lang.StringBuilder() - val uiElements = JSONArray() - for (j in 0 until resultObj.length()) { - val item = resultObj.optJSONObject(j) - if (item != null) { - if (item.has(KEY_TEXT)) { - val textPart = item.optString(KEY_TEXT) - if (textPart.isNotEmpty()) { - if (currentTextBuilder.isNotEmpty()) currentTextBuilder.append("\n") - currentTextBuilder.append(textPart) - } - } - if (isUiElement(item)) { - uiElements.put(item) - } - } - } - if (currentTextBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) - } - if (uiElements.length() > 0) { - partsList.add(ParsedA2AEvent.Data(uiElements.toString())) - } + val dataPayload = + if (part.has(KEY_DATA)) part.optJSONObject(KEY_DATA) + else if (part.optString(KEY_KIND) == KEY_DATA) part.optJSONObject(KEY_DATA) else null + if (dataPayload != null && isUiElement(dataPayload)) { + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + currentTextBuilder = java.lang.StringBuilder() } - } catch (e: Exception) {} + currentUiElements.put(dataPayload) + } + } + + if (currentTextBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(currentTextBuilder.toString())) + } + if (currentUiElements.length() > 0) { + partsList.add(ParsedA2AEvent.Data(currentUiElements.toString())) } val deduplicatedParts = mutableListOf() @@ -147,7 +107,7 @@ object A2AResponseParser { for (part in partsList) { val finalText: String? = if (part is ParsedA2AEvent.Text) { - part.text.replace("```json", "").replace("```", "").trim().takeIf { it.isNotEmpty() } + part.text.trim().takeIf { it.isNotEmpty() } } else null // Deduplicate consecutive identical text blocks @@ -226,24 +186,6 @@ object A2AResponseParser { rawJson.has(KEY_CONTENT) -> rawJson.optJSONObject(KEY_CONTENT)?.optJSONArray(KEY_PARTS) rawJson.has(KEY_STATUS) -> rawJson.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) - rawJson.has(KEY_RESULT) -> { - val resultObj = rawJson.opt(KEY_RESULT) - if (resultObj is String) { - try { - val innerJson = JSONObject(resultObj) - innerJson - .optJSONObject(KEY_STATUS) - ?.optJSONObject(KEY_MESSAGE) - ?.optJSONArray(KEY_PARTS) - } catch (e: Exception) { - null - } - } else if (resultObj is JSONObject) { - resultObj.optJSONObject(KEY_STATUS)?.optJSONObject(KEY_MESSAGE)?.optJSONArray(KEY_PARTS) - } else { - null - } - } else -> null } @@ -253,7 +195,9 @@ object A2AResponseParser { } } - return if (finalParts.length() > 0) finalParts else null + val hasRecognizedStructure = + rawJson.optJSONArray(KEY_HISTORY) != null || additionalParts != null + return if (hasRecognizedStructure) finalParts else null } private fun extractJsonBlocks( @@ -261,12 +205,7 @@ object A2AResponseParser { textBuilder: java.lang.StringBuilder, partsList: MutableList, ) { - val jsonPattern = "```json(.*?)```".toRegex(RegexOption.DOT_MATCHES_ALL) - val a2uiPattern = "---a2ui_JSON---(.*?)---a2ui_JSON_END---".toRegex(RegexOption.DOT_MATCHES_ALL) - - val allMatches = mutableListOf() - allMatches.addAll(jsonPattern.findAll(textPart)) - allMatches.addAll(a2uiPattern.findAll(textPart)) + val allMatches = A2UI_PATTERN.findAll(textPart).toList() if (allMatches.isEmpty()) { if (textBuilder.isNotEmpty()) textBuilder.append("\n") @@ -274,8 +213,6 @@ object A2AResponseParser { return } - allMatches.sortBy { it.range.first } - var lastEnd = 0 for (match in allMatches) { val beforeText = textPart.substring(lastEnd, match.range.first).trim() @@ -288,24 +225,28 @@ object A2AResponseParser { try { val firstChar = jsonString.firstOrNull() if (firstChar == '[') { - if (textBuilder.isNotEmpty()) { - partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) - textBuilder.clear() - } val array = JSONArray(jsonString) val localUiElements = JSONArray() for (i in 0 until array.length()) { localUiElements.put(array.getJSONObject(i)) } + if (textBuilder.isNotEmpty()) { + partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) + textBuilder.clear() + } partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) } else if (firstChar == '{') { + val jsonObj = JSONObject(jsonString) if (textBuilder.isNotEmpty()) { partsList.add(ParsedA2AEvent.Text(textBuilder.toString())) textBuilder.clear() } val localUiElements = JSONArray() - localUiElements.put(JSONObject(jsonString)) + localUiElements.put(jsonObj) partsList.add(ParsedA2AEvent.Data(localUiElements.toString())) + } else { + if (textBuilder.isNotEmpty()) textBuilder.append("\n") + textBuilder.append(match.value) } } catch (e: Exception) { if (textBuilder.isNotEmpty()) textBuilder.append("\n") diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt index 5043f39..6fe76f3 100644 --- a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/A2UIView.kt @@ -96,7 +96,9 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 request: WebResourceRequest?, error: WebResourceError?, ) { - super.onReceivedError(view, request, error) + if (request != null && error != null) { + super.onReceivedError(view, request, error) + } Log.e( A2UI_ERROR_TAG, "Error loading WebView: ${error?.description}, URL: ${request?.url}", diff --git a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt index 87c400f..48ca918 100644 --- a/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt +++ b/client/android/GoogleMapsA2UI/src/main/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterface.kt @@ -63,19 +63,21 @@ class WebAppInterface(private val webView: WebView, private val a2uiView: A2UIVi @JavascriptInterface fun onWebpageResized(height: Int) { Log.d(TAG, "onWebpageResized: $height") - if (!resized) { - webView.post { - val layoutParams = webView.layoutParams - if (layoutParams != null) { - val newHeight = (height * webView.resources.displayMetrics.density).toInt() + webView.post { + val layoutParams = webView.layoutParams + if (layoutParams != null) { + val newHeight = (height * webView.resources.displayMetrics.density).toInt() + if (layoutParams.height != newHeight) { layoutParams.height = newHeight webView.layoutParams = layoutParams - resized = true Log.d("A2UIViewDebug", "WebView height updated to: $newHeight") + } + if (!resized) { + resized = true a2uiView.onRenderCompleteInternal() - } else { - Log.e("A2UIViewDebug", "WebView LayoutParams is null.") } + } else { + Log.e("A2UIViewDebug", "WebView LayoutParams is null.") } } } diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt index 4f91ed0..7340d24 100644 --- a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2AResponseParserTest.kt @@ -14,10 +14,10 @@ package com.google.android.libraries.mapsplatform.a2ui +import com.google.common.truth.Truth.assertThat import org.json.JSONArray import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -25,17 +25,56 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class A2AResponseParserTest { + /** + * Verifies that parsing an unexpected payload structure without message parts or results throws + * [A2AParserException.InvalidPayloadStructure]. + */ @Test - fun testParse_InvalidPayloadStructure() { - // Test that an unexpected payload format returns an empty event list safely + fun parse_invalidPayloadStructure_throwsException() { val payloadWithNoParts = JSONObject().apply { put("status", "ok") } - val events = A2AResponseParser.parse(payloadWithNoParts) - assertEquals(0, events.size) + val thrown = + assertThrows(A2AParserException.InvalidPayloadStructure::class.java) { + A2AResponseParser.parse(payloadWithNoParts) + } + assertThat(thrown).hasMessageThat().contains("lacks a recognized message parts structure") } + /** + * Verifies that when "history" is present but not an array, the parser throws + * [A2AParserException.InvalidPayloadStructure]. + */ @Test - fun testParse_SimpleTextPart() { - // Test standard text extraction from a standard payload structure + fun parse_invalidHistoryType_throwsException() { + val payloadWithInvalidHistory = JSONObject().apply { put("history", "not_an_array") } + val thrown = + assertThrows(A2AParserException.InvalidPayloadStructure::class.java) { + A2AResponseParser.parse(payloadWithInvalidHistory) + } + assertThat(thrown).hasMessageThat().contains("lacks a recognized message parts structure") + } + + /** Verifies that when the parts array is empty, the parser returns an empty list. */ + @Test + fun parse_emptyParts_returnsEmptyList() { + val emptyPartsPayload = JSONObject("""{"parts": []}""") + val emptyEvents = A2AResponseParser.parse(emptyPartsPayload) + assertThat(emptyEvents).isEmpty() + } + + /** + * Verifies that when the parts array contains unknown types, the parser returns an empty list + * safely without throwing an exception. + */ + @Test + fun parse_unknownParts_returnsEmptyList() { + val unknownPartsPayload = JSONObject("""{"parts": [{"kind": "unsupported_media"}]}""") + val unknownEvents = A2AResponseParser.parse(unknownPartsPayload) + assertThat(unknownEvents).isEmpty() + } + + /** Verifies that a simple text part in the "parts" array is parsed into a single text event. */ + @Test + fun parse_simpleTextPart_returnsSingleTextEvent() { val payload = JSONObject( """ @@ -49,19 +88,126 @@ class A2AResponseParserTest { ) val events = A2AResponseParser.parse(payload) - assertEquals(1, events.size) - val textEvent = events[0] as ParsedA2AEvent.Text - assertEquals("Show me some good sushi in Seattle", textEvent.text) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Show me some good sushi in Seattle")) + } + + /** Verifies that the parser resolves message parts nested under "content.parts". */ + @Test + fun parse_contentPartsPath_resolvesTextEvent() { + val payload = + JSONObject( + """ + { + "content": { + "parts": [ + {"text": "Welcome to Seattle!"} + ] + } + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Welcome to Seattle!")) + } + + /** Verifies that the parser resolves message parts nested under "status.message.parts". */ + @Test + fun parse_statusMessagePartsPath_resolvesTextEvent() { + val payload = + JSONObject( + """ + { + "status": { + "message": { + "parts": [ + {"text": "Status message text"} + ] + } + } + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Status message text")) + } + + /** + * Verifies that the parser resolves message parts from the "history" array, extracting agent + * parts that appear after the last user message. + */ + @Test + fun parse_historyPayload_resolvesAgentPartsAfterLastUser() { + val payload = + JSONObject( + """ + { + "history": [ + { + "role": "user", + "parts": [{"text": "First query"}] + }, + { + "role": "agent", + "parts": [{"text": "First answer"}] + }, + { + "role": "user", + "parts": [{"text": "Show me Seattle maps"}] + }, + { + "role": "agent", + "parts": [{"text": "Here is Seattle!"}] + } + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).containsExactly(ParsedA2AEvent.Text("Here is Seattle!")) + } + + /** + * Verifies that when a history payload has only user messages and no agent reply yet, the parser + * returns an empty list. + */ + @Test + fun parse_historyPayloadWithNoAgentResponse_returnsEmptyList() { + val payload = + JSONObject( + """ + { + "history": [ + { + "role": "user", + "parts": [{"text": "Hello, agent!"}] + } + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).isEmpty() } + /** + * Verifies that multiple text parts in the payload are concatenated with newlines into a single + * text event. + */ @Test - fun testParse_TextConcatenation() { - // Android parser concatenates all text elements within a JSONArray into a single text event + fun parse_multipleTextParts_concatenatesIntoSingleTextEvent() { val payload = JSONObject( """ { - "result": [ + "parts": [ {"text": "Hello Seattle!"}, {"text": "Hello Seattle!"}, {"text": "Different text."} @@ -72,44 +218,278 @@ class A2AResponseParserTest { ) val events = A2AResponseParser.parse(payload) - assertEquals(1, events.size) // Expecting 1 because texts are concatenated - assertEquals( - "Hello Seattle!\nHello Seattle!\nDifferent text.", - (events[0] as ParsedA2AEvent.Text).text, - ) + assertThat(events) + .containsExactly(ParsedA2AEvent.Text("Hello Seattle!\nHello Seattle!\nDifferent text.")) + } + + /** Verifies that parts containing empty text strings produce an empty event list. */ + @Test + fun parse_emptyTextPart_returnsEmptyList() { + val payload = + JSONObject( + """ + { + "parts": [ + {"text": ""} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).isEmpty() + } + + /** + * Verifies that non-array parts structure throws [A2AParserException.InvalidPayloadStructure]. + */ + @Test + fun parse_invalidPartsType_throwsException() { + val payload = JSONObject("""{"parts": "not_an_array"}""") + assertThrows(A2AParserException.InvalidPayloadStructure::class.java) { + A2AResponseParser.parse(payload) + } + } + + /** + * Verifies that embedded '' tags within a text body are extracted into discrete text + * and A2UI data events in sequential order. + */ + @Test + fun parse_embeddedA2UIJSON_extractsTextAndDataEventsSequentially() { + val textWithJson = + "Here is the map: [{\"createSurface\": {\"surfaceId\": \"seattle-map\"}}] Hope this helps!" + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithJson)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(3) + assertThat(events[0]).isEqualTo(ParsedA2AEvent.Text("Here is the map:")) + + val dataEvent = events[1] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.getJSONObject(0).getJSONObject("createSurface").getString("surfaceId")) + .isEqualTo("seattle-map") + + assertThat(events[2]).isEqualTo(ParsedA2AEvent.Text("Hope this helps!")) + } + + /** + * Verifies that an A2UI JSON array embedded inside a text part is parsed into a flat array of + * components. + */ + @Test + fun parse_embeddedA2UIJSONArray_flattensToSingleArray() { + val textWithJsonArray = + "Here is the map: [{\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}}, {\"updateComponents\": {\"surfaceId\": \"sushi-seattle\"}}] Done." + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithJsonArray)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(3) + assertThat(events[0]).isEqualTo(ParsedA2AEvent.Text("Here is the map:")) + + val dataEvent = events[1] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(2) + assertThat(arr.getJSONObject(0).has("createSurface")).isTrue() + assertThat(arr.getJSONObject(1).has("updateComponents")).isTrue() + + assertThat(events[2]).isEqualTo(ParsedA2AEvent.Text("Done.")) + } + + /** + * Verifies that malformed JSON inside an embedded '' tag falls back gracefully to + * plain text without crashing. + */ + @Test + fun parse_malformedEmbeddedA2UITag_fallsBackToPlainText() { + val textWithMalformedJson = "Intro text {not_valid_json outro text" + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithMalformedJson)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events) + .containsExactly( + ParsedA2AEvent.Text("Intro text\n{not_valid_json\noutro text") + ) } + /** + * Verifies that non-object/non-array JSON inside an embedded '' tag falls back to + * plain text. + */ @Test - fun testParse_StringifiedJsonResultArray() { - // Tests the scenario where 'result' contains a stringified JSON array starting with '[' - val stringifiedArray = """[{"createSurface": {"surfaceId": "sushi-seattle"}}]""" - val payload = JSONObject().apply { put("result", stringifiedArray) } + fun parse_nonObjectOrArrayEmbeddedA2UITag_fallsBackToPlainText() { + val textWithPrimitiveJson = "Intro text 12345 outro text" + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithPrimitiveJson)}} + ] + } + """ + .trimIndent() + ) val events = A2AResponseParser.parse(payload) - assertEquals(1, events.size) + assertThat(events) + .containsExactly(ParsedA2AEvent.Text("Intro text\n12345\noutro text")) + } + + /** Verifies that an unclosed '' tag remains as plain text. */ + @Test + fun parse_unclosedA2UITag_fallsBackToPlainText() { + val unclosedTagText = "Before {\"createSurface\": {}} without closing tag" + val payload = + JSONObject( + """ + { + "parts": [ + {"text": ${JSONObject.quote(unclosedTagText)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events) + .containsExactly( + ParsedA2AEvent.Text("Before {\"createSurface\": {}} without closing tag") + ) + } + + /** + * Verifies that duplicate surface creation definitions with identical surface IDs are + * deduplicated within the payload. + */ + @Test + fun parse_duplicateSurfaceIds_deduplicatesSurfaces() { + val payload = + JSONObject( + """ + { + "parts": [ + { + "data": { + "createSurface": {"surfaceId": "dup-surface"} + } + }, + { + "data": { + "createSurface": {"surfaceId": "dup-surface"} + } + } + ] + } + """ + .trimIndent() + ) + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(1) val dataEvent = events[0] as ParsedA2AEvent.Data - val a2uiArray = JSONArray(dataEvent.data) - assertEquals(1, a2uiArray.length()) - assertTrue(a2uiArray.getJSONObject(0).has("createSurface")) + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(1) + } + + /** + * Verifies that multiple '' tags within a single text part are all extracted + * sequentially. + */ + @Test + fun parse_multipleEmbeddedA2UITags_extractsAllSequentially() { + val textWithMultipleTags = + "First map: {\"createSurface\": {\"surfaceId\": \"sushi\"}} Then: {\"updateComponents\": {\"surfaceId\": \"sushi\"}} Done." + val payload = + JSONObject( + """ + { + "parts": [ + {"kind": "text", "text": ${JSONObject.quote(textWithMultipleTags)}} + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(5) + assertThat(events[0]).isEqualTo(ParsedA2AEvent.Text("First map:")) + + val dataEvent1 = events[1] as ParsedA2AEvent.Data + val arr1 = JSONArray(dataEvent1.data) + assertThat(arr1.getJSONObject(0).getJSONObject("createSurface").getString("surfaceId")) + .isEqualTo("sushi") + + assertThat(events[2]).isEqualTo(ParsedA2AEvent.Text("Then:")) + + val dataEvent2 = events[3] as ParsedA2AEvent.Data + val arr2 = JSONArray(dataEvent2.data) + assertThat(arr2.getJSONObject(0).getJSONObject("updateComponents").getString("surfaceId")) + .isEqualTo("sushi") + + assertThat(events[4]).isEqualTo(ParsedA2AEvent.Text("Done.")) } + /** + * Verifies that consecutive data parts identified as A2UI payloads are batched together into a + * single data event. + */ @Test - fun testParse_NativeJsonResultArray() { - // Tests the newly added support for native JSONArray inside the 'result' key (from PR #311 - // fixes) + fun parse_consecutiveA2UIPayloads_batchesIntoSingleEvent() { val payload = JSONObject( """ { - "result": [ + "parts": [ { - "text": "Here is your native array map" + "kind": "data", + "data": { + "createSurface": { + "surfaceId": "sushi-seattle", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + } }, { - "updateComponents": { - "surfaceId": "sushi-seattle", - "components": [] + "kind": "data", + "data": { + "updateComponents": { + "surfaceId": "sushi-seattle", + "components": [] + } } } ] @@ -119,16 +499,82 @@ class A2AResponseParserTest { ) val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(1) + val dataEvent = events[0] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(2) + assertThat(arr.getJSONObject(0).has("createSurface")).isTrue() + assertThat(arr.getJSONObject(1).has("updateComponents")).isTrue() + } - // We expect one Text event and one Data event - assertEquals(2, events.size) + /** + * Verifies that an A2UI batch is finalized and a new one starts if interrupted by a text part. + */ + @Test + fun parse_a2uiBatchInterruptedByTextPart_createsSeparateBatches() { + val payload = + JSONObject( + """ + { + "parts": [ + { + "kind": "data", + "data": {"createSurface": {"surfaceId": "sushi-seattle"}} + }, + {"kind": "text", "text": "Middle Text explaining the surface"}, + { + "kind": "data", + "data": {"updateComponents": {"surfaceId": "sushi-seattle"}} + } + ] + } + """ + .trimIndent() + ) - val textEvent = events[0] as ParsedA2AEvent.Text - assertEquals("Here is your native array map", textEvent.text) + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(3) - val dataEvent = events[1] as ParsedA2AEvent.Data - val a2uiArray = JSONArray(dataEvent.data) - assertEquals(1, a2uiArray.length()) - assertTrue(a2uiArray.getJSONObject(0).has("updateComponents")) + val data1 = events[0] as ParsedA2AEvent.Data + assertThat(JSONArray(data1.data).length()).isEqualTo(1) + + assertThat(events[1]).isEqualTo(ParsedA2AEvent.Text("Middle Text explaining the surface")) + + val data2 = events[2] as ParsedA2AEvent.Data + assertThat(JSONArray(data2.data).length()).isEqualTo(1) + } + + /** + * Verifies that a data part is recognized as an A2UI payload if it contains recognized keys, even + * without a mime type. + */ + @Test + fun parse_dataPartWithImplicitA2UIKey_returnsDataEvent() { + val payload = + JSONObject( + """ + { + "parts": [ + { + "kind": "data", + "data": { + "createSurface": { + "surfaceId": "sushi-seattle", + "catalogId": "a2ui://maps-agentic-ui-catalog.json" + } + } + } + ] + } + """ + .trimIndent() + ) + + val events = A2AResponseParser.parse(payload) + assertThat(events).hasSize(1) + val dataEvent = events[0] as ParsedA2AEvent.Data + val arr = JSONArray(dataEvent.data) + assertThat(arr.length()).isEqualTo(1) + assertThat(arr.getJSONObject(0).has("createSurface")).isTrue() } } diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIAttributionIdTests.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIAttributionIdTests.kt index ed7473c..7a4ab99 100644 --- a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIAttributionIdTests.kt +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIAttributionIdTests.kt @@ -33,16 +33,16 @@ class A2UIAttributionIdTests { val htmlContent = InputStreamReader(inputStream).readText() assertTrue( - "Expected the generated attribution ID to contain the Web identifier 'gmp_web_maui_v0.1.8_atoui'", - htmlContent.contains("gmp_web_maui_v0.1.8_atoui"), + "Expected the generated attribution ID to contain the Web identifier 'gmp_web_maui_v0.1.9_atoui'", + htmlContent.contains("gmp_web_maui_v0.1.9_atoui"), ) assertTrue( - "Expected the generated attribution ID to contain the Android identifier 'gmp_android_maui_v0.1.8_atoui'", - htmlContent.contains("gmp_android_maui_v0.1.8_atoui"), + "Expected the generated attribution ID to contain the Android identifier 'gmp_android_maui_v0.1.9_atoui'", + htmlContent.contains("gmp_android_maui_v0.1.9_atoui"), ) assertTrue( - "Expected the generated attribution ID to contain the combined Web and Android identifier string 'gmp_web_maui_v0.1.8_atoui,gmp_android_maui_v0.1.8_atoui'", - htmlContent.contains("gmp_web_maui_v0.1.8_atoui,gmp_android_maui_v0.1.8_atoui"), + "Expected the generated attribution ID to contain the combined Web and Android identifier string 'gmp_web_maui_v0.1.9_atoui,gmp_android_maui_v0.1.9_atoui'", + htmlContent.contains("gmp_web_maui_v0.1.9_atoui,gmp_android_maui_v0.1.9_atoui"), ) } } diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServicesTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServicesTest.kt new file mode 100644 index 0000000..306e93f --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIServicesTest.kt @@ -0,0 +1,74 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.android.libraries.mapsplatform.a2ui + +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Unit tests for [A2UIServices]. + * + * Verifies global configuration, including Google Maps API Key provisioning. + */ +@RunWith(JUnit4::class) +class A2UIServicesTest { + + @Before + fun setUp() { + A2UIServices.provideAPIKey("") + } + + @After + fun tearDown() { + A2UIServices.provideAPIKey("") + } + + /** Verifies that the default API key is empty when initialized/reset. */ + @Test + fun defaultState_apiKeyIsEmpty() { + assertThat(A2UIServices.apiKey).isEmpty() + } + + /** Verifies that provideAPIKey() correctly sets the static apiKey property. */ + @Test + fun provideAPIKey_setsApiKey() { + A2UIServices.provideAPIKey("AIzaSyTestApiKey") + assertThat(A2UIServices.apiKey).isEqualTo("AIzaSyTestApiKey") + } + + /** Verifies that subsequent calls to provideAPIKey() override previous keys. */ + @Test + fun provideAPIKey_consecutiveUpdates_overridesKey() { + A2UIServices.provideAPIKey("FirstKey") + assertThat(A2UIServices.apiKey).isEqualTo("FirstKey") + + A2UIServices.provideAPIKey("SecondKey") + assertThat(A2UIServices.apiKey).isEqualTo("SecondKey") + } + + /** Verifies that blank or special character keys are stored verbatim. */ + @Test + fun provideAPIKey_blankOrSpecialCharacters_storesExactString() { + A2UIServices.provideAPIKey("AIzaSyTest-Key_123$!@#") + assertThat(A2UIServices.apiKey).isEqualTo("AIzaSyTest-Key_123$!@#") + + A2UIServices.provideAPIKey("") + assertThat(A2UIServices.apiKey).isEmpty() + } +} diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIViewTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIViewTest.kt new file mode 100644 index 0000000..eb08de7 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/A2UIViewTest.kt @@ -0,0 +1,320 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.android.libraries.mapsplatform.a2ui + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowLooper + +/** + * Unit tests for [A2UIView]. + * + * Verifies rendering configuration, HTML asset loading, JavaScript injection, deduplication, + * latency measurement, and URL navigation overriding. + */ +@RunWith(RobolectricTestRunner::class) +class A2UIViewTest { + + private lateinit var activity: Activity + private lateinit var a2uiView: A2UIView + + @Before + fun setUp() { + activity = Robolectric.buildActivity(Activity::class.java).setup().get() + A2UIServices.provideAPIKey("AIzaSyValidKey") + a2uiView = A2UIView(activity) + activity.setContentView(a2uiView) + } + + @After + fun tearDown() { + A2UIServices.provideAPIKey("") + } + + private fun createMockRequest(urlStr: String?, isMainFrame: Boolean = true): WebResourceRequest { + val request = mock() + whenever(request.url).thenReturn(urlStr?.let { Uri.parse(it) }) + whenever(request.isForMainFrame).thenReturn(isMainFrame) + whenever(request.method).thenReturn("GET") + whenever(request.requestHeaders).thenReturn(emptyMap()) + return request + } + + /** + * Verifies that render() stores the A2UI payload and loads the HTML asset with the API key + * injected. + */ + @Test + fun render_withApiKey_storesPayloadAndLoadsInjectedHtml() { + val payload = """[{"createSurface": {"surfaceId": "test"}}]""" + + a2uiView.render(payload) + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + + val shadowWebView = shadowOf(a2uiView) + val lastLoadedData = shadowWebView.lastLoadDataWithBaseURL + assertThat(lastLoadedData).isNotNull() + assertThat(lastLoadedData.baseUrl).isEqualTo("file:///android_asset/") + assertThat(lastLoadedData.mimeType).isEqualTo("text/html") + assertThat(lastLoadedData.encoding).isEqualTo("UTF-8") + assertThat(lastLoadedData.data).contains("AIzaSyValidKey") + } + + /** + * Verifies that updateA2uiJson() properly updates the a2uiJson property and schedules JavaScript + * evaluation with safely escaped JSON when JS is ready. + */ + @Test + fun updateA2uiJson_complexJsonPayload_updatesPayloadAndExecutes() { + val complexJson = + """[{"text": "Line 1\nLine 2 with \"quotes\" and 'single' and \\backslash"}]""" + a2uiView.onJsReadyInternal() + a2uiView.updateA2uiJson(complexJson) + ShadowLooper.idleMainLooper() + + assertThat(a2uiView.a2uiJson).isEqualTo(complexJson) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("shell.processA2uiMessages") + assertThat(lastEvaluatedJs).contains("Line 1") + assertThat(lastEvaluatedJs).contains("quotes") + } + + /** + * Verifies that updateA2uiJson() when JS is not ready only stores the payload without executing + * JS. + */ + @Test + fun updateA2uiJson_whenJsNotReady_storesPayloadWithoutEvaluating() { + val payload = """[{"createSurface": {"surfaceId": "pending"}}]""" + + a2uiView.updateA2uiJson(payload) + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNull() + } + + /** Verifies that onJsReadyInternal() marks the view ready and pushes queued payloads. */ + @Test + fun onJsReadyInternal_withQueuedPayload_triggersUpdate() { + val payload = """[{"createSurface": {"surfaceId": "queued"}}]""" + a2uiView.a2uiJson = payload + + a2uiView.onJsReadyInternal() + ShadowLooper.idleMainLooper() + + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("queued") + } + + /** + * Verifies that onRenderComplete callback correctly computes latency and receives status, and + * resets startTime so subsequent calls are ignored. + */ + @Test + fun onRenderCompleteInternal_computesLatencyAndInvokesCallback() { + var callCount = 0 + var invokedLatency: Long? = null + var invokedStatus: String? = null + a2uiView.onRenderComplete = { latency, status -> + callCount++ + invokedLatency = latency + invokedStatus = status + } + + val pastStartTime = System.currentTimeMillis() - 150 + a2uiView.render("[]", startTimeMs = pastStartTime) + a2uiView.onRenderCompleteInternal() + + assertThat(invokedLatency).isNotNull() + val latency = checkNotNull(invokedLatency) + assertThat(latency).isAtLeast(150L) + assertThat(invokedStatus).isEqualTo("A2UI Render Complete") + assertThat(callCount).isEqualTo(1) + + // Second call should not invoke callback because startTime was reset to null + a2uiView.onRenderCompleteInternal() + assertThat(callCount).isEqualTo(1) + } + + /** Verifies that onRenderCompleteInternal() does not throw when no callback is registered. */ + @Test + fun onRenderCompleteInternal_withNullCallback_doesNotCrash() { + a2uiView.onRenderComplete = null + a2uiView.render("[]") + a2uiView.onRenderCompleteInternal() + } + + /** + * Verifies that shouldOverrideUrlLoading intercepts web and maps URLs to launch ACTION_VIEW + * intents. + */ + @Test + fun shouldOverrideUrlLoading_webUrl_startsActionViewIntent() { + val request = createMockRequest("https://www.google.com") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(startedIntent.data).isEqualTo(Uri.parse("https://www.google.com")) + } + + /** + * Verifies that shouldOverrideUrlLoading with Google Maps URLs falls back to browser (null + * package) when the Maps app is not installed on the device. + */ + @Test + fun shouldOverrideUrlLoading_mapsUrlWithoutMapsApp_fallsBackToBrowser() { + val request = createMockRequest("https://maps.google.com/?q=sushi") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(startedIntent.data).isEqualTo(Uri.parse("https://maps.google.com/?q=sushi")) + assertThat(startedIntent.`package`).isNull() + } + + /** + * Verifies that shouldOverrideUrlLoading also recognizes path-based maps URLs (google.com/maps). + */ + @Test + fun shouldOverrideUrlLoading_pathBasedMapsUrl_interceptsIntent() { + val request = createMockRequest("https://www.google.com/maps/search/pizza") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.action).isEqualTo(Intent.ACTION_VIEW) + assertThat(startedIntent.data).isEqualTo(Uri.parse("https://www.google.com/maps/search/pizza")) + } + + /** + * Verifies that non-http and non-https schemes (e.g. javascript:, about:blank) are not handled. + */ + @Test + fun shouldOverrideUrlLoading_nonHttpScheme_returnsFalse() { + val request = createMockRequest("javascript:void(0)") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isFalse() + } + + /** Verifies that onReceivedError handles resource loading errors gracefully without crashing. */ + @Test + fun onReceivedError_withNullRequestAndError_logsAndDoesNotCrash() { + val client = shadowOf(a2uiView).webViewClient + client.onReceivedError(a2uiView, null, null) + } + + @Test + fun onReceivedError_withValidRequestAndError_delegatesAndDoesNotCrash() { + val request = createMockRequest("https://maps.googleapis.com/test", isMainFrame = true) + val error = mock() + whenever(error.description).thenReturn("Connection failed") + whenever(error.errorCode).thenReturn(-2) + + val client = shadowOf(a2uiView).webViewClient + client.onReceivedError(a2uiView, request, error) + + verify(request).isForMainFrame + verify(error).errorCode + } + + /** Verifies that secondary constructors initialize without errors. */ + @Test + fun constructor_withAttributeSetAndDefStyle_initializesSuccessfully() { + val viewWithAttrs = A2UIView(activity, null) + val viewWithStyle = A2UIView(activity, null, 0) + assertThat(viewWithAttrs).isNotNull() + assertThat(viewWithStyle).isNotNull() + } + + /** Verifies that shouldOverrideUrlLoading returns false when request or URL is null. */ + @Test + fun shouldOverrideUrlLoading_nullRequestOrUrl_returnsFalse() { + val client = shadowOf(a2uiView).webViewClient + val requestWithNullUrl = createMockRequest(null) + + assertThat(client.shouldOverrideUrlLoading(a2uiView, null as WebResourceRequest?)).isFalse() + assertThat(client.shouldOverrideUrlLoading(a2uiView, requestWithNullUrl)).isFalse() + } + + /** Verifies that shouldOverrideUrlLoading preserves Maps package when Maps app is installed. */ + @Test + fun shouldOverrideUrlLoading_mapsUrlWithInstalledMapsApp_setsMapsPackage() { + val mapsIntent = + Intent(Intent.ACTION_VIEW, Uri.parse("https://maps.google.com/?q=coffee")).apply { + `package` = "com.google.android.apps.maps" + } + val resolveInfo = + android.content.pm.ResolveInfo().apply { + activityInfo = + android.content.pm.ActivityInfo().apply { + packageName = "com.google.android.apps.maps" + name = "com.google.android.maps.MapsActivity" + } + } + shadowOf(activity.packageManager).addResolveInfoForIntent(mapsIntent, resolveInfo) + + val request = createMockRequest("https://maps.google.com/?q=coffee") + + val client = shadowOf(a2uiView).webViewClient + val handled = client.shouldOverrideUrlLoading(a2uiView, request) + assertThat(handled).isTrue() + + val startedIntent = shadowOf(activity).nextStartedActivity + assertThat(startedIntent).isNotNull() + assertThat(startedIntent.`package`).isEqualTo("com.google.android.apps.maps") + } + + /** Verifies that onJsReadyInternal does not evaluate JS when a2uiJson is empty. */ + @Test + fun onJsReadyInternal_withEmptyPayload_doesNotTriggerUpdate() { + a2uiView.a2uiJson = "" + a2uiView.onJsReadyInternal() + ShadowLooper.idleMainLooper() + + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNull() + } +} diff --git a/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterfaceTest.kt b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterfaceTest.kt new file mode 100644 index 0000000..c24dce0 --- /dev/null +++ b/client/android/GoogleMapsA2UI/src/test/java/com/google/android/libraries/mapsplatform/a2ui/WebAppInterfaceTest.kt @@ -0,0 +1,174 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.android.libraries.mapsplatform.a2ui + +import android.app.Activity +import android.content.Context +import android.widget.FrameLayout +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowLooper + +/** + * Unit tests for [WebAppInterface]. + * + * Verifies Web-to-Native event bridging, including user action callbacks (directions requests), + * JavaScript ready signaling, and WebView dynamic height adjustment. + */ +@RunWith(RobolectricTestRunner::class) +class WebAppInterfaceTest { + + private lateinit var context: Context + private lateinit var a2uiView: A2UIView + private lateinit var webAppInterface: WebAppInterface + + @Before + fun setUp() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + context = activity + A2UIServices.provideAPIKey("test-api-key") + a2uiView = A2UIView(context) + activity.setContentView(a2uiView) + webAppInterface = WebAppInterface(a2uiView, a2uiView) + } + + @After + fun tearDown() { + A2UIServices.provideAPIKey("") + } + + /** Verifies that sendA2uiMessages posts JavaScript evaluation and resets the resized state. */ + @Test + fun sendA2uiMessages_validPayload_postsJsEvaluationAndResetsResizeState() { + webAppInterface.resized = true + val payload = """[{"createSurface": {"surfaceId": "msg-1"}}]""" + + webAppInterface.sendA2uiMessages(payload) + ShadowLooper.idleMainLooper() + + assertThat(webAppInterface.resized).isFalse() + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("shell.processA2uiMessages") + assertThat(lastEvaluatedJs).contains("msg-1") + } + + /** + * Verifies that when the web layer triggers onGetDirections(), the user action callback on + * [A2UIView] is notified with the exact action JSON payload. + */ + @Test + fun onGetDirections_actionPayload_surfacesActionToCallback() { + var receivedAction: String? = null + a2uiView.onUserAction = { actionJson -> receivedAction = actionJson } + + val actionPayload = """{"placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4", "action": "get_directions"}""" + webAppInterface.onGetDirections(actionPayload) + + assertThat(receivedAction).isEqualTo(actionPayload) + } + + /** Verifies that onGetDirections() does not throw when no user action callback is registered. */ + @Test + fun onGetDirections_withNullActionCallback_doesNotCrash() { + a2uiView.onUserAction = null + webAppInterface.onGetDirections("""{"action": "test"}""") + } + + /** Verifies that onJsReady() triggers the JS ready lifecycle on A2UIView. */ + @Test + fun onJsReady_withQueuedPayload_triggersJsReadyOnA2uiView() { + val payload = """[{"createSurface": {"surfaceId": "test"}}]""" + a2uiView.a2uiJson = payload + + webAppInterface.onJsReady() + ShadowLooper.idleMainLooper() + + assertThat(a2uiView.a2uiJson).isEqualTo(payload) + val lastEvaluatedJs = shadowOf(a2uiView).lastEvaluatedJavascript + assertThat(lastEvaluatedJs).isNotNull() + assertThat(lastEvaluatedJs).contains("test") + } + + /** + * Verifies that onWebpageResized() updates the WebView layout parameters to match the content + * height and fires the onRenderComplete callback. + */ + @Test + fun onWebpageResized_newHeight_updatesLayoutAndTriggersRenderComplete() { + var renderCompleteInvoked = false + var completedStatus: String? = null + a2uiView.onRenderComplete = { _, status -> + renderCompleteInvoked = true + completedStatus = status + } + + a2uiView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 100) + + a2uiView.render("""[{"createSurface": {"surfaceId": "test"}}]""") + webAppInterface.onWebpageResized(350) + ShadowLooper.idleMainLooper() + + val expectedHeight = (350 * a2uiView.resources.displayMetrics.density).toInt() + assertThat(a2uiView.layoutParams.height).isEqualTo(expectedHeight) + assertThat(renderCompleteInvoked).isTrue() + assertThat(completedStatus).isEqualTo("A2UI Render Complete") + assertThat(webAppInterface.resized).isTrue() + } + + /** Verifies that subsequent onWebpageResized calls are ignored once resized is true. */ + @Test + fun onWebpageResized_whenAlreadyResized_isIdempotent() { + var renderCount = 0 + a2uiView.onRenderComplete = { _, _ -> renderCount++ } + + a2uiView.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 100) + + a2uiView.render("""[{"createSurface": {"surfaceId": "test"}}]""") + webAppInterface.onWebpageResized(300) + ShadowLooper.idleMainLooper() + + assertThat(renderCount).isEqualTo(1) + + // Second resize call while resized == true should be ignored + webAppInterface.onWebpageResized(500) + ShadowLooper.idleMainLooper() + + assertThat(renderCount).isEqualTo(1) + } + + /** + * Verifies that onWebpageResized handles null layoutParams on the WebView gracefully without + * crashing. + */ + @Test + fun onWebpageResized_withNullLayoutParams_doesNotCrash() { + val unattachedView = A2UIView(context) + unattachedView.layoutParams = null + val customInterface = WebAppInterface(unattachedView, unattachedView) + customInterface.resized = false + + customInterface.onWebpageResized(300) + ShadowLooper.idleMainLooper() + + assertThat(customInterface.resized).isFalse() + } +} diff --git a/client/android/README.md b/client/android/README.md index a811721..5fef8d3 100644 --- a/client/android/README.md +++ b/client/android/README.md @@ -94,6 +94,9 @@ class GmpA2UIViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { } ``` +### 4. Streaming vs. Non-Streaming Updates (`message/stream` & `message/send`) +In Compose or View-based apps, `A2UIView` supports both real-time SSE streaming (`message/stream`) and single-response requests (`message/send`). When `useStreaming` is enabled, incremental `.Data` chunks emitted by `A2AResponseParser.parse(chunkJson)` are applied in place via `updateA2uiJson(newJson)` without recreating the underlying `WebView` (see `MainActivity.kt` and `ChatRepository.kt` in the Android sample app). + ## Architecture Deep Dive The SDK encapsulates all complex parsing and rendering logic into four core components: diff --git a/client/android/web_build/index.html b/client/android/web_build/index.html index a3da007..e43d38f 100644 --- a/client/android/web_build/index.html +++ b/client/android/web_build/index.html @@ -21,7 +21,12 @@ A2UI Bridge - + - + diff --git a/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift index 4bc57f8..ac3f0d9 100644 --- a/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift +++ b/client/ios/GoogleMapsA2UI/Tests/A2AResponseParserTests.swift @@ -19,9 +19,8 @@ import XCTest @testable import GoogleMapsA2UI final class A2AResponseParserTests: XCTestCase { - /// Tests that an error is thrown when the input payload is not a valid JSON object. - func testParse_InvalidJSONFormat() { + func testParse_invalidJSONFormat() { let invalidPayload: [String: Any] = ["key": Date()] // Date is not valid JSON XCTAssertThrowsError(try A2AResponseParser.parse(invalidPayload)) { error in XCTAssertEqual(error as? A2AParserError, .invalidJSONFormat) @@ -29,15 +28,23 @@ final class A2AResponseParserTests: XCTestCase { } /// Tests that an error is thrown when the JSON payload lacks a recognizable `parts` structure. - func testParse_InvalidPayloadStructure() { + func testParse_invalidPayloadStructure() { let payloadWithNoParts: [String: Any] = ["status": "ok"] XCTAssertThrowsError(try A2AResponseParser.parse(payloadWithNoParts)) { error in XCTAssertEqual(error as? A2AParserError, .invalidPayloadStructure) } } + /// Tests that an error is thrown when `parts` is not an array of dictionaries. + func testParse_invalidPartsType_throwsError() { + let payloadWithInvalidParts: [String: Any] = ["parts": "not_an_array"] + XCTAssertThrowsError(try A2AResponseParser.parse(payloadWithInvalidParts)) { error in + XCTAssertEqual(error as? A2AParserError, .invalidPayloadStructure) + } + } + /// Tests that a single text part is parsed correctly into a text event. - func testParse_SimpleTextPart() throws { + func testParse_simpleTextPart() throws { let payload: [String: Any] = [ "parts": [ ["kind": "text", "text": "Show me some good sushi in Seattle"] @@ -46,16 +53,23 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.textValue, "Show me some good sushi in Seattle") + } - guard case .text(let text) = events[0] else { - XCTFail("Expected text event") - return - } - XCTAssertEqual(text, "Show me some good sushi in Seattle") + /// Tests that parts containing empty text strings produce an empty event list. + func testParse_emptyTextPart_returnsEmptyList() throws { + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": ""] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertTrue(events.isEmpty) } /// Tests that multiple text parts within the `content.parts` path are parsed into separate text events. - func testParse_MultipleTextParts() throws { + func testParse_multipleTextParts() throws { let payload: [String: Any] = [ "content": [ "parts": [ @@ -66,23 +80,16 @@ final class A2AResponseParserTests: XCTestCase { ] let events = try A2AResponseParser.parse(payload) - XCTAssertEqual(events.count, 2) - - if case .text(let text1) = events[0] { - XCTAssertEqual(text1, "Show me some good sushi in Seattle") - } else { - XCTFail("Expected first event to be text") - } - - if case .text(let text2) = events[1] { - XCTAssertEqual(text2, "What are their ratings?") - } else { - XCTFail("Expected second event to be text") - } + XCTAssertEqual( + events.map(\.textValue), + [ + "Show me some good sushi in Seattle", + "What are their ratings?", + ]) } /// Tests that an A2UI JSON payload embedded inside a text part using `` tags is extracted. - func testParse_EmbeddedA2UIJSON() throws { + func testParse_embeddedA2UIJSON() throws { let textWithJSON = "Here is the Seattle map {\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}} Hope you like it!" let payload: [String: Any] = [ @@ -93,28 +100,69 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 3) + XCTAssertEqual(events[0].textValue, "Here is the Seattle map") + XCTAssertEqual(events[1].metadataMimeType, "application/json+a2ui") + let dict = try XCTUnwrap(events[1].dataDictionaries?.first) + XCTAssertNotNil(dict["createSurface"]) + XCTAssertEqual(events[2].textValue, "Hope you like it!") + } - guard case .text(let prefix) = events[0] else { - return XCTFail("Expected text event") - } - XCTAssertEqual(prefix, "Here is the Seattle map") + /// Tests that an A2UI JSON array embedded inside a text part is parsed into a flat array of components. + func testParse_embeddedA2UIJSONArray_flattensToSingleArray() throws { + let textWithJSONArray = + "Here is the map: [{\"createSurface\": {\"surfaceId\": \"sushi-seattle\"}}, {\"updateComponents\": {\"surfaceId\": \"sushi-seattle\"}}] Done." + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": textWithJSONArray] + ] + ] - guard case .data(let data, let metadata) = events[1] else { - return XCTFail("Expected data event") - } - XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") - let array = data as? [Any] - let dict = array?.first as? [String: Any] - XCTAssertNotNil(dict?["createSurface"]) + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 3) + XCTAssertEqual(events[0].textValue, "Here is the map:") + XCTAssertEqual(events[1].metadataMimeType, "application/json+a2ui") + let array = try XCTUnwrap(events[1].dataDictionaries) + XCTAssertEqual(array.count, 2) + XCTAssertNotNil(array[0]["createSurface"]) + XCTAssertNotNil(array[1]["updateComponents"]) + XCTAssertEqual(events[2].textValue, "Done.") + } - guard case .text(let suffix) = events[2] else { - return XCTFail("Expected text event") - } - XCTAssertEqual(suffix, "Hope you like it!") + /// Tests that malformed JSON inside an embedded '' tag falls back gracefully to plain text. + func testParse_malformedEmbeddedA2UITag_fallsBackToPlainText() throws { + let textWithMalformedJSON = "Intro text {not_valid_json outro text" + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": textWithMalformedJSON] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual( + events.map(\.textValue), + [ + "Intro text", + "{not_valid_json", + "outro text", + ]) + } + + /// Tests that an unclosed '' tag remains as plain text. + func testParse_unclosedA2UITag_fallsBackToPlainText() throws { + let unclosedTagText = "Before {\"createSurface\": {}} without closing tag" + let payload: [String: Any] = [ + "parts": [ + ["kind": "text", "text": unclosedTagText] + ] + ] + + let events = try A2AResponseParser.parse(payload) + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.textValue, unclosedTagText) } /// Tests that a data part with an explicit A2UI mime type is parsed and batched into an array. - func testParse_DataPartWithA2UIMimeType() throws { + func testParse_dataPartWithA2UIMimeType() throws { let payload: [String: Any] = [ "parts": [ [ @@ -133,19 +181,14 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.metadataMimeType, "application/json+a2ui") - guard case .data(let data, let metadata) = events[0] else { - return XCTFail("Expected data event") - } - XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") - - let a2uiArray = data as? [Any] - XCTAssertNotNil(a2uiArray, "A2UI payload should be batched into an array") - XCTAssertEqual(a2uiArray?.count, 1) + let a2uiArray = try XCTUnwrap(events.first?.dataArray) + XCTAssertEqual(a2uiArray.count, 1) } /// Tests that a data part is inferred as A2UI if it contains recognized keys, even without a mime type. - func testParse_DataPartWithImplicitA2UIKey() throws { + func testParse_dataPartWithImplicitA2UIKey() throws { let payload: [String: Any] = [ "parts": [ [ @@ -162,18 +205,13 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - - guard case .data(let data, let metadata) = events[0] else { - return XCTFail("Expected data event") - } - XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") - let a2uiArray = data as? [Any] - XCTAssertNotNil(a2uiArray) - XCTAssertEqual(a2uiArray?.count, 1) + XCTAssertEqual(events.first?.metadataMimeType, "application/json+a2ui") + let a2uiArray = try XCTUnwrap(events.first?.dataArray) + XCTAssertEqual(a2uiArray.count, 1) } /// Tests that the parser can successfully locate and extract parts from the `status.message.parts` JSON path. - func testParse_StatusMessagePartsPath() throws { + func testParse_statusMessagePartsPath() throws { let payload: [String: Any] = [ "status": [ "message": [ @@ -186,16 +224,11 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) - - guard case .text(let text) = events[0] else { - XCTFail("Expected text event") - return - } - XCTAssertEqual(text, "Seattle is home to a world-class sushi scene") + XCTAssertEqual(events.first?.textValue, "Seattle is home to a world-class sushi scene") } /// Tests that consecutive data parts identified as A2UI payloads are batched together into a single data event. - func testParse_ConsecutiveA2UIPayloadsAreBatched() throws { + func testParse_consecutiveA2UIPayloadsAreBatched() throws { let payload: [String: Any] = [ "parts": [ [ @@ -223,31 +256,16 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.metadataMimeType, "application/json+a2ui") - guard case .data(let data, let metadata) = events[0] else { - XCTFail("Expected data event") - return - } - XCTAssertEqual(metadata?.mimeType, "application/json+a2ui") - - guard let a2uiArray = data as? [Any] else { - XCTFail("Expected data payload to be an array of batched items") - return - } + let a2uiArray = try XCTUnwrap(events.first?.dataDictionaries) XCTAssertEqual(a2uiArray.count, 2) - - guard let dict1 = a2uiArray[0] as? [String: Any], - let dict2 = a2uiArray[1] as? [String: Any] - else { - XCTFail("Expected array elements to be dictionaries") - return - } - XCTAssertNotNil(dict1["createSurface"]) - XCTAssertNotNil(dict2["updateComponents"]) + XCTAssertNotNil(a2uiArray[0]["createSurface"]) + XCTAssertNotNil(a2uiArray[1]["updateComponents"]) } /// Tests that an A2UI batch is finalized and a new one starts if interrupted by a text part. - func testParse_A2UIBatchInterruptedByTextPart() throws { + func testParse_a2uiBatchInterruptedByTextPart() throws { let payload: [String: Any] = [ "parts": [ [ @@ -267,31 +285,17 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 3) - guard case .data(let data1, let metadata1) = events[0] else { - XCTFail("Expected first event to be data") - return - } - XCTAssertEqual(metadata1?.mimeType, "application/json+a2ui") - let batch1 = data1 as? [Any] - XCTAssertEqual(batch1?.count, 1) + XCTAssertEqual(events[0].metadataMimeType, "application/json+a2ui") + XCTAssertEqual(events[0].dataArray?.count, 1) - guard case .text(let text) = events[1] else { - XCTFail("Expected second event to be text") - return - } - XCTAssertEqual(text, "Middle Text explaining the surface") + XCTAssertEqual(events[1].textValue, "Middle Text explaining the surface") - guard case .data(let data2, let metadata2) = events[2] else { - XCTFail("Expected third event to be data") - return - } - XCTAssertEqual(metadata2?.mimeType, "application/json+a2ui") - let batch2 = data2 as? [Any] - XCTAssertEqual(batch2?.count, 1) + XCTAssertEqual(events[2].metadataMimeType, "application/json+a2ui") + XCTAssertEqual(events[2].dataArray?.count, 1) } /// Tests that multiple `` tags within a single text part are all extracted sequentially. - func testParse_MultipleEmbeddedA2UITags() throws { + func testParse_multipleEmbeddedA2UITags() throws { let textWithMultipleTags = "First map: {\"createSurface\": {\"surfaceId\": \"sushi\"}} Then: {\"updateComponents\": {\"surfaceId\": \"sushi\"}} Done." let payload: [String: Any] = [ @@ -303,24 +307,54 @@ final class A2AResponseParserTests: XCTestCase { let events = try A2AResponseParser.parse(payload) XCTAssertEqual(events.count, 5) - guard case .text(let t1) = events[0], - case .data(let d1, let m1) = events[1], - case .text(let t2) = events[2], - case .data(let d2, let m2) = events[3], - case .text(let t3) = events[4] - else { - XCTFail("Expected sequence: [text, data, text, data, text]") - return - } + XCTAssertEqual(events[0].textValue, "First map:") + XCTAssertEqual(events[1].metadataMimeType, "application/json+a2ui") + let dict1 = try XCTUnwrap(events[1].dataDictionaries?.first) + XCTAssertNotNil(dict1["createSurface"]) + + XCTAssertEqual(events[2].textValue, "Then:") + XCTAssertEqual(events[3].metadataMimeType, "application/json+a2ui") + let dict2 = try XCTUnwrap(events[3].dataDictionaries?.first) + XCTAssertNotNil(dict2["updateComponents"]) + + XCTAssertEqual(events[4].textValue, "Done.") + } + + /// Tests that when the parts array is present but empty or contains unknown types, + /// the parser returns an empty list safely without throwing an exception. + func testParse_emptyOrUnknownParts_returnsEmptyList() throws { + let emptyPartsPayload: [String: Any] = ["parts": []] + let emptyEvents = try A2AResponseParser.parse(emptyPartsPayload) + XCTAssertEqual(emptyEvents.count, 0) - XCTAssertEqual(t1, "First map:") - XCTAssertEqual(m1?.mimeType, "application/json+a2ui") - XCTAssertNotNil((d1 as? [Any])?.first as? [String: Any]) + let unknownPartsPayload: [String: Any] = [ + "parts": [["kind": "unsupported_media"]] + ] + let unknownEvents = try A2AResponseParser.parse(unknownPartsPayload) + XCTAssertEqual(unknownEvents.count, 0) + } +} - XCTAssertEqual(t2, "Then:") - XCTAssertEqual(m2?.mimeType, "application/json+a2ui") - XCTAssertNotNil((d2 as? [Any])?.first as? [String: Any]) +// MARK: - Test Helpers + +extension ParsedA2AEvent { + fileprivate var textValue: String? { + guard case .text(let text) = self else { return nil } + return text + } + + fileprivate var dataArray: [Any]? { + guard case .data(let data, _) = self else { return nil } + return data as? [Any] + } + + fileprivate var dataDictionaries: [[String: Any]]? { + guard case .data(let data, _) = self else { return nil } + return data as? [[String: Any]] + } - XCTAssertEqual(t3, "Done.") + fileprivate var metadataMimeType: String? { + guard case .data(_, let metadata) = self else { return nil } + return metadata?.mimeType } } diff --git a/client/ios/GoogleMapsA2UI/Tests/A2UIAttributionIdTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2UIAttributionIdTests.swift index 39a6c9c..8500b1c 100644 --- a/client/ios/GoogleMapsA2UI/Tests/A2UIAttributionIdTests.swift +++ b/client/ios/GoogleMapsA2UI/Tests/A2UIAttributionIdTests.swift @@ -25,16 +25,16 @@ final class A2UIAttributionIdTests: XCTestCase { let htmlString = localContent.html XCTAssertTrue( - htmlString.contains("gmp_web_maui_v0.1.8_atoui"), - "Expected the generated attribution ID to contain the Web identifier 'gmp_web_maui_v0.1.8_atoui'" + htmlString.contains("gmp_web_maui_v0.1.9_atoui"), + "Expected the generated attribution ID to contain the Web identifier 'gmp_web_maui_v0.1.9_atoui'" ) XCTAssertTrue( - htmlString.contains("gmp_ios_maui_v0.1.8_atoui"), - "Expected the generated attribution ID to contain the iOS identifier 'gmp_ios_maui_v0.1.8_atoui'" + htmlString.contains("gmp_ios_maui_v0.1.9_atoui"), + "Expected the generated attribution ID to contain the iOS identifier 'gmp_ios_maui_v0.1.9_atoui'" ) XCTAssertTrue( - htmlString.contains("gmp_web_maui_v0.1.8_atoui,gmp_ios_maui_v0.1.8_atoui"), - "Expected the generated attribution ID to contain the combined Web and iOS identifier string 'gmp_web_maui_v0.1.8_atoui,gmp_ios_maui_v0.1.8_atoui'" + htmlString.contains("gmp_web_maui_v0.1.9_atoui,gmp_ios_maui_v0.1.9_atoui"), + "Expected the generated attribution ID to contain the combined Web and iOS identifier string 'gmp_web_maui_v0.1.9_atoui,gmp_ios_maui_v0.1.9_atoui'" ) } } diff --git a/client/ios/GoogleMapsA2UI/Tests/A2UIServicesTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2UIServicesTests.swift new file mode 100644 index 0000000..18b1791 --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Tests/A2UIServicesTests.swift @@ -0,0 +1,74 @@ +// +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest + +@testable import GoogleMapsA2UI + +/// Unit tests for `A2UIServices`. +/// +/// Verifies Google Maps API key configuration and HTML template resolution and caching. +@MainActor +final class A2UIServicesTests: XCTestCase { + + override func tearDown() { + super.tearDown() + A2UIServices.provideApiKey("") + } + + /// Tests that providing an API key injects the key into the resolved HTML content template. + func testProvideApiKey_SetsKeyAndInjectsIntoHtml() throws { + A2UIServices.provideApiKey("AIzaSyTestKey123") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertTrue(content.html.contains("AIzaSyTestKey123")) + XCTAssertFalse(content.html.contains("$GOOGLE_MAPS_API_KEY")) + } + + /// Tests that providing an empty API key clears the placeholder without crashing. + func testProvideApiKey_SetsEmptyKey_RemovesPlaceholder() throws { + A2UIServices.provideApiKey("") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertFalse(content.html.contains("$GOOGLE_MAPS_API_KEY")) + } + + /// Tests that getLocalHTMLContent returns the module bundle's resourceURL as baseURL. + func testGetLocalHTMLContent_ReturnsModuleBaseURL() throws { + A2UIServices.provideApiKey("TestKey") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertEqual(content.baseURL, Bundle.module.resourceURL) + } + + /// Tests that the resolved HTML template is cached when the API key does not change, + /// and invalidated/recalculated when a new key is provided. + func testGetLocalHTMLContent_CachesResultWhenKeyUnchanged() throws { + A2UIServices.provideApiKey("KeyAlpha") + let first = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + let second = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertEqual(first.html, second.html) + + A2UIServices.provideApiKey("KeyBeta") + let third = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertTrue(third.html.contains("KeyBeta")) + XCTAssertFalse(third.html.contains("KeyAlpha")) + } + + /// Tests that an API key containing special characters is injected verbatim without breaking HTML template resolution. + func testProvideApiKey_SpecialCharacters_InjectsVerbatim() throws { + A2UIServices.provideApiKey("AIzaSyTest-Key_123$!@#") + let content = try XCTUnwrap(A2UIServices.getLocalHTMLContent()) + XCTAssertTrue(content.html.contains("AIzaSyTest-Key_123$!@#")) + } +} diff --git a/client/ios/GoogleMapsA2UI/Tests/A2UIViewTests.swift b/client/ios/GoogleMapsA2UI/Tests/A2UIViewTests.swift new file mode 100644 index 0000000..ab70e0d --- /dev/null +++ b/client/ios/GoogleMapsA2UI/Tests/A2UIViewTests.swift @@ -0,0 +1,522 @@ +// +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import WebKit +import XCTest + +@testable import GoogleMapsA2UI + +/// Unit tests for `A2UIView` and its internal coordinator. +/// +/// Verifies JSON payload serialization, JavaScript injection deduplication, +/// and SwiftUI view hierarchy configuration for data vs text events. +@MainActor +final class A2UIViewTests: XCTestCase { + + /// Tests that the coordinator's injectJSON method correctly serializes complex native payloads + /// containing special characters, quotes, and newlines into the lastInjectedPayload cache. + func testCoordinator_InjectJSON_SerializesComplexPayload() throws { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let payload: [String: Any] = [ + "createSurface": [ + "surfaceId": "sushi-1", + "description": "Text with \"double quotes\" and 'single' and \n newline.", + ] + ] + + coordinator.injectJSON(webView, payload: payload) + + let injected = try XCTUnwrap(coordinator.lastInjectedPayload) + XCTAssertTrue(injected.contains("sushi-1")) + XCTAssertTrue(injected.contains("double quotes")) + } + + /// Tests that the coordinator deduplicates subsequent identical JSON payload injections. + func testCoordinator_InjectJSON_DeduplicatesIdenticalPayload() throws { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let payload = ["surfaceId": "test-dedupe"] + coordinator.injectJSON(webView, payload: payload) + let firstInjected = try XCTUnwrap(coordinator.lastInjectedPayload) + + // Second call with same payload + coordinator.injectJSON(webView, payload: payload) + XCTAssertEqual(coordinator.lastInjectedPayload, firstInjected) + } + + /// Tests that initializing `A2UIView` with an A2UI Data event produces a non-empty SwiftUI View body. + func testA2UIView_BodyStructure_WithDataEvent() { + let event = ParsedA2AEvent.data( + [["createSurface": ["surfaceId": "test"]]], + metadata: ParsedA2AEventMetadata(mimeType: "application/json+a2ui") + ) + + let view = A2UIView( + part: event, + id: "test-id", + onUserAction: { _ in } + ) + + let mirror = Mirror(reflecting: view.body) + let bodyDesc = String(describing: mirror.children.first?.value ?? "") + XCTAssertTrue(bodyDesc.contains("A2UIMessageInnerWrapper")) + } + + /// Tests that initializing `A2UIView` with a Text event safely defaults to an EmptyView. + func testA2UIView_BodyStructure_WithTextEvent() { + let event = ParsedA2AEvent.text("Just text") + let view = A2UIView( + part: event, + id: "text-id", + onUserAction: { _ in } + ) + + let mirror = Mirror(reflecting: view.body) + let bodyDesc = String(describing: mirror.children.first?.value ?? "") + XCTAssertTrue(bodyDesc.contains("EmptyView")) + } + + /// Tests that receiving an onGetDirections action via the iOS bridge triggers onUserAction callback. + func testCoordinator_UserContentController_OnGetDirections() { + var receivedAction: String? + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { action in receivedAction = action }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage( + name: "iOS", + body: ["action": "onGetDirections", "data": "{\"placeId\": \"123\"}"] + ) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(receivedAction, "{\"placeId\": \"123\"}") + } + + /// Tests that heightObserver updates the dynamic height and fires onRenderComplete when delta > 5. + func testCoordinator_UserContentController_HeightObserver_UpdatesHeight() { + var height: CGFloat = 100 + var renderCompletedId: String? + var renderStatus: String? + var renderLatency: Double? + let parent = A2UIMessageRepresentableView( + webViewID: "test-height-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: { id, latency, status in + renderCompletedId = id + renderLatency = latency + renderStatus = status + } + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage(name: "heightObserver", body: CGFloat(250)) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(height, 250) + XCTAssertEqual(renderCompletedId, "test-height-view") + XCTAssertEqual(renderStatus, "success") + XCTAssertNotNil(renderLatency) + } + + /// Tests that heightObserver ignores small height changes (<= 5) to prevent layout thrashing. + func testCoordinator_UserContentController_HeightObserver_SuppressesSmallChanges() { + var height: CGFloat = 100 + var renderCalled = false + let parent = A2UIMessageRepresentableView( + webViewID: "test-suppress-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: { _, _, _ in renderCalled = true } + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage(name: "heightObserver", body: CGFloat(103)) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(height, 100) + XCTAssertFalse(renderCalled) + } + + /// Tests that heightObserver ignores invalid/small heights (<= 50). + func testCoordinator_UserContentController_HeightObserver_IgnoresSmallHeights() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage(name: "heightObserver", body: CGFloat(30)) + + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertEqual(height, 100) + } + + /// Tests that onJsReady sets isJSReady flag and injects payload. + func testCoordinator_UserContentController_OnJsReady_SetsReadyAndInjects() { + var height: CGFloat = 100 + let webView = WKWebView() + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [["createSurface": ["surfaceId": "ready-test"]]], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let message = MockScriptMessage( + name: "iOS", + body: ["action": "onJsReady", "data": ""], + webView: webView + ) + + XCTAssertFalse(coordinator.isJSReady) + coordinator.userContentController(WKUserContentController(), didReceive: message) + + XCTAssertTrue(coordinator.isJSReady) + XCTAssertNotNil(coordinator.lastInjectedPayload) + } + + /// Tests that injectJSON falls back safely to "[]" when given an un-serializable payload. + func testCoordinator_InjectJSON_NonSerializablePayload_FallsBackSafely() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + // Pass a non-serializable object + let invalidPayload: [String: Any] = ["invalid": NSObject()] + coordinator.injectJSON(webView, payload: invalidPayload) + + XCTAssertEqual(coordinator.lastInjectedPayload, "[]") + } + + /// Tests that the coordinator handles JavaScript logging and error messages without crashing. + func testCoordinator_UserContentController_LogAndErrorMessages() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + + let logMessage = MockScriptMessage( + name: "iOS", + body: ["action": "log", "data": "Test log output"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: logMessage) + + let errorMessage = MockScriptMessage( + name: "iOS", + body: ["action": "error", "data": "Test JS error"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: errorMessage) + } + + /// Tests that malformed or unrecognized script messages are handled safely without throwing. + func testCoordinator_UserContentController_MalformedMessages_HandledGracefully() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + + // 1. Missing action/data keys + let missingKeysMessage = MockScriptMessage( + name: "iOS", + body: ["invalid": "value"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: missingKeysMessage) + + // 2. Unknown action string + let unknownActionMessage = MockScriptMessage( + name: "iOS", + body: ["action": "unknownCustomAction", "data": "{}"] + ) + coordinator.userContentController(WKUserContentController(), didReceive: unknownActionMessage) + + // 3. Height observer with non-numeric body + let badHeightMessage = MockScriptMessage( + name: "heightObserver", + body: "not-a-number" + ) + coordinator.userContentController(WKUserContentController(), didReceive: badHeightMessage) + + XCTAssertEqual(height, 100) + } + + /// Tests that external HTTP/HTTPS link clicks trigger navigation cancellation in decidePolicyFor. + func testCoordinator_DecidePolicyForNavigationAction_ExternalLink_CancelsPolicy() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let url = URL(string: "https://www.google.com")! + let action = MockNavigationAction( + navigationType: .linkActivated, + request: URLRequest(url: url) + ) + + var decidedPolicy: WKNavigationActionPolicy? + coordinator.webView(webView, decidePolicyFor: action) { policy in + decidedPolicy = policy + } + + XCTAssertEqual(decidedPolicy, .cancel) + } + + /// Tests that non-link navigation actions (e.g. other/reload) are allowed in decidePolicyFor. + func testCoordinator_DecidePolicyForNavigationAction_OtherNavigation_AllowsPolicy() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + + let url = URL(string: "https://www.google.com")! + let action = MockNavigationAction( + navigationType: .other, + request: URLRequest(url: url) + ) + + var decidedPolicy: WKNavigationActionPolicy? + coordinator.webView(webView, decidePolicyFor: action) { policy in + decidedPolicy = policy + } + + XCTAssertEqual(decidedPolicy, .allow) + } + + /// Tests that createWebViewWith returns nil when intercepting window.open popup requests. + func testCoordinator_CreateWebViewWith_ReturnsNil() { + var height: CGFloat = 100 + let parent = A2UIMessageRepresentableView( + webViewID: "test-view", + payload: [], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = A2UIMessageRepresentableView.Coordinator(parent) + let webView = WKWebView() + let config = WKWebViewConfiguration() + + let url = URL(string: "https://maps.google.com")! + let action = MockNavigationAction( + navigationType: .linkActivated, + request: URLRequest(url: url) + ) + + let createdView = coordinator.webView( + webView, + createWebViewWith: config, + for: action, + windowFeatures: WKWindowFeatures() + ) + + XCTAssertNil(createdView) + } + + /// Tests that mounting A2UIView in a UIHostingController triggers makeCoordinator and makeUIView without crashing. + func testA2UIView_UIHostingController_MountsViewHierarchyAndInitializesWebView() { + let event = ParsedA2AEvent.data( + [["createSurface": ["surfaceId": "mount-test"]]], + metadata: ParsedA2AEventMetadata(mimeType: "application/json+a2ui") + ) + let view = A2UIView( + part: event, + id: "mount-test-id", + onUserAction: { _ in }, + onRenderComplete: { _, _, _ in } + ) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 375, height: 667)) + let hostingController = UIHostingController(rootView: view) + window.rootViewController = hostingController + window.makeKeyAndVisible() + hostingController.loadViewIfNeeded() + hostingController.view.layoutIfNeeded() + + XCTAssertNotNil(hostingController.view) + } + + /// Tests that mounting A2UIView with a Text event renders an EmptyView inside a UIHostingController. + func testA2UIView_UIHostingController_WithTextEvent_MountsEmptyView() { + let event = ParsedA2AEvent.text("mount text test") + let view = A2UIView( + part: event, + id: "mount-text-id", + onUserAction: { _ in } + ) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 375, height: 667)) + let hostingController = UIHostingController(rootView: view) + window.rootViewController = hostingController + window.makeKeyAndVisible() + hostingController.loadViewIfNeeded() + hostingController.view.layoutIfNeeded() + + XCTAssertNotNil(hostingController.view) + } + + /// Tests that A2UIMessageRepresentableView creates a configured coordinator instance. + func testA2UIMessageRepresentableView_MakeCoordinator_InitializesCorrectly() { + var height: CGFloat = 100 + let view = A2UIMessageRepresentableView( + webViewID: "coordinator-test-id", + payload: ["surfaceId": "test"], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + + let coordinator = view.makeCoordinator() + XCTAssertEqual(coordinator.parent.webViewID, "coordinator-test-id") + XCTAssertFalse(coordinator.isJSReady) + } + + /// Tests that configureWebView properly sets up WKWebView properties, delegates, and configuration. + func testA2UIMessageRepresentableView_ConfigureWebView_InitializesConfigurationAndDelegates() { + var height: CGFloat = 100 + let view = A2UIMessageRepresentableView( + webViewID: "configure-test-id", + payload: ["surfaceId": "test"], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = view.makeCoordinator() + let webView = view.configureWebView(coordinator: coordinator) + + XCTAssertFalse(webView.isOpaque) + XCTAssertEqual(webView.backgroundColor, .clear) + XCTAssertFalse(webView.scrollView.isScrollEnabled) + XCTAssertTrue(webView.navigationDelegate === coordinator) + XCTAssertTrue(webView.uiDelegate === coordinator) + } + + /// Tests that updateWebView injects JSON when JS is ready, and skips when not ready. + func testA2UIMessageRepresentableView_UpdateWebView_InjectsWhenJSReady() { + var height: CGFloat = 100 + let view = A2UIMessageRepresentableView( + webViewID: "update-test-id", + payload: ["surfaceId": "update-test"], + dynamicHeight: Binding(get: { height }, set: { height = $0 }), + onUserAction: { _ in }, + onRenderComplete: nil + ) + let coordinator = view.makeCoordinator() + let webView = WKWebView() + + // 1. When not ready, updateWebView does nothing + coordinator.isJSReady = false + view.updateWebView(webView, coordinator: coordinator) + XCTAssertNil(coordinator.lastInjectedPayload) + + // 2. When ready, updateWebView injects JSON + coordinator.isJSReady = true + view.updateWebView(webView, coordinator: coordinator) + XCTAssertNotNil(coordinator.lastInjectedPayload) + } +} + +private class MockNavigationAction: WKNavigationAction { + private let _navigationType: WKNavigationType + private let _request: URLRequest + + init(navigationType: WKNavigationType, request: URLRequest) { + self._navigationType = navigationType + self._request = request + super.init() + } + + override var navigationType: WKNavigationType { _navigationType } + override var request: URLRequest { _request } +} + +private class MockScriptMessage: WKScriptMessage { + private let _name: String + private let _body: Any + private weak var _webView: WKWebView? + + init(name: String, body: Any, webView: WKWebView? = nil) { + self._name = name + self._body = body + self._webView = webView + super.init() + } + + override var name: String { _name } + override var body: Any { _body } + override var webView: WKWebView? { _webView } +} diff --git a/client/ios/README.md b/client/ios/README.md index fd4b238..913dfaf 100644 --- a/client/ios/README.md +++ b/client/ios/README.md @@ -135,3 +135,6 @@ struct ChatMessageView: View { } } ``` + +#### 4. Streaming Updates (`message/stream`) +When consuming real-time Server-Sent Events (`message/stream`), pass a stable `id` (such as `"\(message.id)-\(index)"`) to `A2UIView`. Whenever `part` updates with incremental A2UI payload data, `A2UIView` automatically pushes the update to the mounted web component via its JavaScript bridge (`updateA2UIData`) without reloading the underlying `WKWebView`. You can switch between `message/stream` and `message/send` in your networking service using a `useStreaming` boolean flag (see `ChatService.swift` in the iOS sample app). diff --git a/client/ios/web_build/index.html b/client/ios/web_build/index.html index a3da007..e43d38f 100644 --- a/client/ios/web_build/index.html +++ b/client/ios/web_build/index.html @@ -21,7 +21,12 @@ A2UI Bridge - +