diff --git a/.github/workflows/native-sdk-e2e.yml b/.github/workflows/native-sdk-e2e.yml index aca6b35b..e771973a 100644 --- a/.github/workflows/native-sdk-e2e.yml +++ b/.github/workflows/native-sdk-e2e.yml @@ -159,12 +159,69 @@ jobs: # BLOCKS_URL is provided via $GITHUB_ENV from the "Pick a free port" step. run: dart run bin/e2e_test.dart - # Kotlin E2E (TODO: add when ready) - # kotlin-e2e: - # name: Kotlin E2E - # needs: [detect-changes, setup] - # if: needs.detect-changes.outputs.source-changed == 'true' - # ... + kotlin-e2e: + name: Kotlin E2E (${{ matrix.label }}) + needs: [detect-changes, setup] + if: needs.detect-changes.outputs.source-changed == 'true' + runs-on: ${{ matrix.runs-on }} + timeout-minutes: ${{ matrix.timeout }} + strategy: + fail-fast: false + matrix: + include: + - label: JVM + runs-on: ubuntu-latest + gradle-task: jvmTest + timeout: 15 + - label: iOS + runs-on: macos-15 + gradle-task: iosSimulatorArm64Test + timeout: 20 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - uses: actions/setup-node@v5 + with: + node-version-file: '.nvmrc' + cache: npm + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - run: npm ci + - run: npm run build + + - name: Download spec + uses: actions/download-artifact@v4 + with: + name: blocks-spec + path: native/kotlin/e2e/ + + - name: Run Kotlin codegen + working-directory: native/kotlin/e2e + run: ./gradlew awsBlocksCodegen + + - name: Start native-bindings server + working-directory: test-apps/native-bindings + run: | + npx tsx aws-blocks/scripts/server.ts & + for i in $(seq 1 30); do + curl -s -X POST http://localhost:3001/aws-blocks/api \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"api.kvGet","params":{"key":"healthcheck"},"id":1}' && break + sleep 1 + done + + - name: Run E2E tests + working-directory: native/kotlin/e2e + env: + BLOCKS_URL: http://localhost:3001/aws-blocks/api + run: ./gradlew ${{ matrix.gradle-task }} swift-e2e: name: Swift E2E @@ -477,31 +534,110 @@ jobs: working-directory: test-apps/native-bindings run: npm run destroy || true + kotlin-e2e-sandbox: + name: Kotlin E2E (${{ matrix.label }}, sandbox) + needs: [detect-changes, kotlin-e2e] + if: >- + needs.detect-changes.outputs.source-changed == 'true' && + (github.event_name == 'workflow_dispatch' || + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ${{ matrix.runs-on }} + timeout-minutes: 30 + environment: publish + strategy: + fail-fast: false + matrix: + include: + - label: JVM + suffix: jvm + runs-on: ubuntu-latest + gradle-task: jvmTest + - label: iOS + suffix: ios + runs-on: macos-15 + gradle-task: iosSimulatorArm64Test + env: + BLOCKS_STACK_SUFFIX: native-kotlin-${{ matrix.suffix }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - uses: actions/setup-node@v5 + with: + node-version-file: '.nvmrc' + cache: npm + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - run: npm ci + - run: npm run build + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: us-east-1 + + - name: Deploy sandbox + working-directory: test-apps/native-bindings + run: npm run deploy + + - name: Resolve BLOCKS_URL + id: url + working-directory: test-apps/native-bindings + run: | + URL=$(python3 -c "import json; print(json.load(open('.blocks-sandbox/config.json'))['apiUrl'])") + echo "blocks_url=${URL}/aws-blocks/api" >> $GITHUB_OUTPUT + + - name: Generate OpenRPC spec + working-directory: test-apps/native-bindings + run: npm run spec + + - name: Download spec + uses: actions/download-artifact@v4 + with: + name: blocks-spec + path: native/kotlin/e2e/ + + - name: Run Kotlin codegen + working-directory: native/kotlin/e2e + run: ./gradlew awsBlocksCodegen + + - name: Run Kotlin E2E against sandbox + working-directory: native/kotlin/e2e + env: + BLOCKS_URL: ${{ steps.url.outputs.blocks_url }} + run: ./gradlew ${{ matrix.gradle-task }} + + - name: Destroy sandbox + if: always() + working-directory: test-apps/native-bindings + run: npm run destroy || true + # Aggregate gate so this can be a single required status check in branch # protection. Always runs (even when the e2e jobs are skipped for unrelated # or fork PRs) and only fails if a job that actually ran failed. native-sdk-e2e-required: name: Native SDK E2E (Required) if: always() - needs: [detect-changes, setup, dart-e2e, swift-e2e] + needs: [detect-changes, setup, dart-e2e, kotlin-e2e, swift-e2e] runs-on: ubuntu-latest steps: - name: Check results run: | - echo "detect-changes=${{ needs.detect-changes.result }} setup=${{ needs.setup.result }} dart-e2e=${{ needs.dart-e2e.result }} swift-e2e=${{ needs.swift-e2e.result }}" - # Required gate = SDK correctness: detect-changes + setup + the local - # dart-e2e suite (no AWS). The deploying `dart-e2e-sandbox` job still - # runs as a NON-BLOCKING signal — it depends on deployed-backend config - # that isn't part of the SDK (real-Cognito email code delivery, which a - # dev-only `cognitoGetLastCode` hook can't satisfy; and fresh-deploy - # OIDC state). The OIDC relay suite was verified 5/5 against a live - # sandbox, so this is environment, not SDK. Re-add `dart-e2e-sandbox` - # to this gate once the deployed Cognito sign-in path is testable. - # Skipped jobs (no source changes) are treated as passing. + echo "detect-changes=${{ needs.detect-changes.result }} setup=${{ needs.setup.result }} dart-e2e=${{ needs.dart-e2e.result }} kotlin-e2e=${{ needs.kotlin-e2e.result }} swift-e2e=${{ needs.swift-e2e.result }}" + # Required gate = SDK correctness. Skipped jobs (no source changes) + # are treated as passing. for result in \ "${{ needs.detect-changes.result }}" \ "${{ needs.setup.result }}" \ "${{ needs.dart-e2e.result }}" \ + "${{ needs.kotlin-e2e.result }}" \ "${{ needs.swift-e2e.result }}"; do if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then echo "A required native SDK E2E job failed (or was cancelled)." diff --git a/native/dart/example/bin/e2e/realtime_test.dart b/native/dart/example/bin/e2e/realtime_test.dart index f4c8a5a1..a54cbe59 100644 --- a/native/dart/example/bin/e2e/realtime_test.dart +++ b/native/dart/example/bin/e2e/realtime_test.dart @@ -23,7 +23,7 @@ void main() async { group('Realtime: subscribe and receive'); final ch = await blocks.api.realtimeGetChannel(channel: 'dart-test'); final stream = ch.subscribe(); - final completer = Completer(); + final completer = Completer(); final sub = stream.listen((msg) { if (!completer.isCompleted) completer.complete(msg); @@ -38,13 +38,10 @@ void main() async { try { final msg = await completer.future.timeout(Duration(seconds: 5)); - check(msg != null, 'received message via WebSocket'); - if (msg is Map) { - check(msg['userId'] == 'dart-sub-test', 'message userId matches'); - check(msg['x'] == 42, 'message x matches'); - } else { - check(true, 'received message (type: ${msg.runtimeType})'); - } + check(msg.userId == 'dart-sub-test', 'message userId matches'); + check(msg.x == 42, 'message x matches'); + check(msg.y == 99, 'message y matches'); + check(msg.color == '#00ff00', 'message color matches'); } on TimeoutException { check(false, 'WebSocket message received within 5s (timed out)'); } finally { diff --git a/native/kotlin/.changeset/brave-geckos-flash.md b/native/kotlin/.changeset/brave-geckos-flash.md new file mode 100644 index 00000000..ae9dad93 --- /dev/null +++ b/native/kotlin/.changeset/brave-geckos-flash.md @@ -0,0 +1,5 @@ +--- +"aws-blocks-kotlin": patch +--- + +Fix handling of keychain in the iOS runtime diff --git a/native/kotlin/.changeset/fuzzy-tables-sit.md b/native/kotlin/.changeset/fuzzy-tables-sit.md new file mode 100644 index 00000000..4e9d9237 --- /dev/null +++ b/native/kotlin/.changeset/fuzzy-tables-sit.md @@ -0,0 +1,5 @@ +--- +"aws-blocks-kotlin": minor +--- + +Bump Kotlin version to 2.2.10 diff --git a/native/kotlin/.changeset/heavy-worlds-give.md b/native/kotlin/.changeset/heavy-worlds-give.md new file mode 100644 index 00000000..467b11e0 --- /dev/null +++ b/native/kotlin/.changeset/heavy-worlds-give.md @@ -0,0 +1,5 @@ +--- +"aws-blocks-kotlin": minor +--- + +Add ability to clear cookies diff --git a/native/kotlin/README.md b/native/kotlin/README.md index 5db6e5cc..e8627547 100644 --- a/native/kotlin/README.md +++ b/native/kotlin/README.md @@ -1,7 +1,7 @@ # AWS Blocks Kotlin [![Maven Central](https://img.shields.io/maven-central/v/com.aws.blocks.kotlin/runtime)](https://central.sonatype.com/search?namespace=com.aws.blocks.kotlin) -[![Kotlin](https://img.shields.io/badge/kotlin-2.1.21-blue.svg?logo=kotlin)](https://kotlinlang.org) +[![Kotlin](https://img.shields.io/badge/kotlin-2.2.10-blue.svg?logo=kotlin)](https://kotlinlang.org) ![Android](http://img.shields.io/badge/platform-android-6EDB8D.svg?style=flat) ![iOS](http://img.shields.io/badge/platform-ios-CDCDCD.svg?style=flat) ![Desktop](http://img.shields.io/badge/platform-desktop-DB413D.svg?style=flat) @@ -106,7 +106,7 @@ See the [`example/android`](example/android) directory for a complete Android ap ## Requirements -- Kotlin 2.x +- Kotlin 2.1+ - JDK 17+ - Gradle 7.4+ - Android Gradle Plugin 7.1+ (for Android targets) diff --git a/native/kotlin/e2e/.gitignore b/native/kotlin/e2e/.gitignore new file mode 100644 index 00000000..946cfe29 --- /dev/null +++ b/native/kotlin/e2e/.gitignore @@ -0,0 +1,3 @@ +build/ +.gradle/ +blocks.spec.json diff --git a/native/kotlin/e2e/build.gradle.kts b/native/kotlin/e2e/build.gradle.kts new file mode 100644 index 00000000..131be11a --- /dev/null +++ b/native/kotlin/e2e/build.gradle.kts @@ -0,0 +1,86 @@ +import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlinx.serialization) + id("com.aws.blocks.kotlin") +} + +kotlin { + jvm() + + iosSimulatorArm64 { + // PersistentCookiesStorage on iOS is Keychain-backed. The bare Kotlin/Native + // simulator test binary has no keychain-access-groups entitlement, so Keychain + // calls fail with errSecNotAvailable and session cookies never persist. Embed an + // entitlements section into the test binary; combined with the booted, non-standalone + // test run configured below, the simulator honors the entitlement so the Keychain + // works under test. + binaries.getTest("DEBUG").linkerOpts( + "-sectcreate", "__TEXT", "__entitlements", + "${projectDir}/entitlements.plist" + ) + } + + sourceSets { + commonMain.dependencies { + implementation("com.aws.blocks.kotlin:runtime") + } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotest.assertions.core) + implementation(libs.kotlinx.coroutines.test) + } + + jvmTest.dependencies { + implementation(libs.kotest.runner.junit5) + implementation(libs.ktor.client.okhttp) + } + + iosTest.dependencies { + implementation(libs.ktor.client.darwin) + } + } +} + +tasks.named("jvmTest") { + useJUnitPlatform() + val url = providers.systemProperty("BLOCKS_URL").orElse( + providers.environmentVariable("BLOCKS_URL") + ).getOrElse("") + systemProperty("BLOCKS_URL", url) + environment("BLOCKS_URL", url) +} + +// Boots and opens an iOS simulator. The Keychain-backed tests run non-standalone against a +// booted device (see below), which requires a simulator to already be running. Making the +// iOS test task depend on this keeps CI and local `run-e2e.sh` working without a manual boot. +val launchIosSimulator by tasks.registering(Exec::class) { + isIgnoreExitValue = true + // No-op if a simulator is already booted; otherwise boot the first available iPhone. + // `open -a Simulator` is macOS-only and harmless if already open. + commandLine( + "sh", "-c", + """ + if ! xcrun simctl list devices booted | grep -q Booted; then + udid=${'$'}(xcrun simctl list devices available | grep -Eo '[0-9A-F-]{36}' | head -1) + [ -n "${'$'}udid" ] && xcrun simctl boot "${'$'}udid" + fi + open -a Simulator 2>/dev/null || true + """.trimIndent() + ) +} + +tasks.withType().configureEach { + dependsOn(launchIosSimulator) + // Launch as an app on a booted simulator (not a standalone `simctl spawn`) so the + // embedded keychain-access-groups entitlement is honored and Keychain storage works. + standalone.set(false) + device.set("booted") +} + +awsBlocks { + apiSpec = file("blocks.spec.json") + packageName = "blocks.e2e" +} diff --git a/native/kotlin/e2e/entitlements.plist b/native/kotlin/e2e/entitlements.plist new file mode 100644 index 00000000..59825ac9 --- /dev/null +++ b/native/kotlin/e2e/entitlements.plist @@ -0,0 +1,12 @@ + + + + + application-identifier + com.aws.blocks.kotlin.e2e + keychain-access-groups + + com.aws.blocks.kotlin.e2e + + + diff --git a/native/kotlin/e2e/gradle.properties b/native/kotlin/e2e/gradle.properties new file mode 100644 index 00000000..8c16cf3d --- /dev/null +++ b/native/kotlin/e2e/gradle.properties @@ -0,0 +1 @@ +kotlin.mpp.enableCInteropCommonization=true diff --git a/native/kotlin/e2e/gradle/wrapper/gradle-wrapper.jar b/native/kotlin/e2e/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..9bbc975c Binary files /dev/null and b/native/kotlin/e2e/gradle/wrapper/gradle-wrapper.jar differ diff --git a/native/kotlin/e2e/gradle/wrapper/gradle-wrapper.properties b/native/kotlin/e2e/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..c5e47f3e --- /dev/null +++ b/native/kotlin/e2e/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +#Wed May 13 11:21:16 ADT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/native/kotlin/e2e/gradlew b/native/kotlin/e2e/gradlew new file mode 100755 index 00000000..faf93008 --- /dev/null +++ b/native/kotlin/e2e/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/native/kotlin/e2e/gradlew.bat b/native/kotlin/e2e/gradlew.bat new file mode 100644 index 00000000..9d21a218 --- /dev/null +++ b/native/kotlin/e2e/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/native/kotlin/e2e/settings.gradle.kts b/native/kotlin/e2e/settings.gradle.kts new file mode 100644 index 00000000..27b88b0f --- /dev/null +++ b/native/kotlin/e2e/settings.gradle.kts @@ -0,0 +1,31 @@ +pluginManagement { + includeBuild("../") + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "aws-blocks-kotlin-e2e" + +includeBuild("../") diff --git a/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/AuthBasicE2ETest.kt b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/AuthBasicE2ETest.kt new file mode 100644 index 00000000..3ba889b7 --- /dev/null +++ b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/AuthBasicE2ETest.kt @@ -0,0 +1,96 @@ +package com.aws.blocks.kotlin.e2e + +import com.aws.blocks.kotlin.exceptions.ApiException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotBeBlank +import kotlin.test.Test +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock + +class AuthBasicE2ETest { + + private val api = createApi() + private val suffix = Clock.System.now().toEpochMilliseconds().toString() + private val username = "basicuser_$suffix" + private val password = "pass1234" + + @Test + fun signUpAndSignIn() = runTest { + val r = api.basicSignUp(username, password) + r.success.shouldBeTrue() + + val user = api.basicSignIn(username, password) + user.username shouldBe username + user.userId.shouldNotBeBlank() + } + + @Test + fun checkAuthWhenSignedIn() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + val authed = api.basicCheckAuth() + authed.shouldBeTrue() + } + + @Test + fun requireAuthWhenSignedIn() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + val user = api.basicRequireAuth() + user.username shouldBe username + } + + @Test + fun getCurrentUserWhenSignedIn() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + val current = api.basicGetCurrentUser() + current.shouldNotBeNull() + current.username shouldBe username + } + + @Test + fun signOut() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + val r = api.basicSignOut() + r.success.shouldBeTrue() + + val afterSignOut = api.basicGetCurrentUser() + afterSignOut.shouldBeNull() + } + + @Test + fun checkAuthAfterSignOut() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + api.basicSignOut() + + val authed = api.basicCheckAuth() + authed.shouldBeFalse() + } + + @Test + fun requireAuthWhenNotAuthenticatedThrows() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + api.basicSignOut() + + shouldThrow { api.basicRequireAuth() } + } + + @Test + fun wrongPasswordThrows() = runTest { + api.basicSignUp(username, password) + + shouldThrow { api.basicSignIn(username, "wrong5678") } + } +} diff --git a/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/BlocksE2ETestCase.kt b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/BlocksE2ETestCase.kt new file mode 100644 index 00000000..080a546d --- /dev/null +++ b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/BlocksE2ETestCase.kt @@ -0,0 +1,12 @@ +package com.aws.blocks.kotlin.e2e + +import blocks.e2e.Api +import com.aws.blocks.kotlin.BlocksServer + +private val blocksUrl: String = + getEnv("BLOCKS_URL")?.takeIf { it.isNotBlank() } + ?: "http://localhost:3001/aws-blocks/api" + +private val server = BlocksServer(name = "e2e", url = blocksUrl) + +fun createApi(): Api = Api(server = server) diff --git a/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/FileBucketE2ETest.kt b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/FileBucketE2ETest.kt new file mode 100644 index 00000000..2bb517e5 --- /dev/null +++ b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/FileBucketE2ETest.kt @@ -0,0 +1,75 @@ +package com.aws.blocks.kotlin.e2e + +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldHaveAtLeastSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotBeBlank +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlin.test.Test + +class FileBucketE2ETest { + + private val api = createApi() + private val prefix = "test_kotlin_${Clock.System.now().toEpochMilliseconds()}" + + @Test + fun uploadAndDownloadViaHandles() = runTest { + val handle = api.fileCreateUploadHandle("$prefix/hello.txt") + handle.url.shouldNotBeBlank() + handle.upload("hello from kotlin".encodeToByteArray()) + + val download = api.fileGetHandle("$prefix/hello.txt") + val bytes = download.download() + bytes.decodeToString() shouldBe "hello from kotlin" + } + + @Test + fun binaryDataRoundTrip() = runTest { + val data = ByteArray(256) { it.toByte() } + val handle = api.fileCreateUploadHandle("$prefix/binary.bin") + handle.upload(data) + + val download = api.fileGetHandle("$prefix/binary.bin") + val bytes = download.download() + bytes.size shouldBe 256 + bytes shouldBe data + } + + @Test + fun serverSidePutAndGet() = runTest { + api.filePut("$prefix/server.txt", "server-side") + val file = api.fileGet("$prefix/server.txt") + file.shouldNotBeNull() + file.body shouldBe "server-side" + } + + @Test + fun deleteFile() = runTest { + api.filePut("$prefix/del.txt", "temp") + api.fileDelete("$prefix/del.txt") + val deleted = api.fileGet("$prefix/del.txt") + deleted.shouldBeNull() + } + + @Test + fun scanWithPrefix() = runTest { + api.filePut("$prefix/scan/a.txt", "a") + api.filePut("$prefix/scan/b.txt", "b") + val scanned = api.fileScan("$prefix/scan/") + scanned.shouldHaveAtLeastSize(2) + } + + @Test + fun largeFile() = runTest { + val data = ByteArray(100_000) { (it % 256).toByte() } + val handle = api.fileCreateUploadHandle("$prefix/large.bin") + handle.upload(data) + + val download = api.fileGetHandle("$prefix/large.bin") + val bytes = download.download() + bytes.size shouldBe 100_000 + } +} diff --git a/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/KvStoreE2ETest.kt b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/KvStoreE2ETest.kt new file mode 100644 index 00000000..75eb1e43 --- /dev/null +++ b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/KvStoreE2ETest.kt @@ -0,0 +1,95 @@ +package com.aws.blocks.kotlin.e2e + +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.booleans.shouldBeTrue +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlin.test.Test + +class KvStoreE2ETest { + + private val api = createApi() + private val prefix = "kv_kotlin_${Clock.System.now().toEpochMilliseconds()}" + + @Test + fun basicRoundTrip() = runTest { + val key = "${prefix}_a" + val r = api.kvPut(key, "hello") + r.success.shouldBeTrue() + val v = api.kvGet(key) + v shouldBe "hello" + } + + @Test + fun missingKeyReturnsNull() = runTest { + val v = api.kvGet("${prefix}_nonexistent") + v.shouldBeNull() + } + + @Test + fun overwrite() = runTest { + val key = "${prefix}_b" + api.kvPut(key, "first") + api.kvPut(key, "second") + val v = api.kvGet(key) + v shouldBe "second" + } + + @Test + fun emptyStringValue() = runTest { + val key = "${prefix}_empty" + api.kvPut(key, "") + val v = api.kvGet(key) + v shouldBe "" + } + + @Test + fun unicode() = runTest { + val key = "${prefix}_uni" + api.kvPut(key, "日本語 🎉 émojis") + val v = api.kvGet(key) + v shouldBe "日本語 🎉 émojis" + } + + @Test + fun largeValue() = runTest { + val key = "${prefix}_large" + val large = "x".repeat(10_000) + api.kvPut(key, large) + val v = api.kvGet(key) + v shouldBe large + } + + @Test + fun specialCharactersInKey() = runTest { + val key = "${prefix}/slashes/and spaces!@#" + api.kvPut(key, "ok") + val v = api.kvGet(key) + v shouldBe "ok" + } + + @Test + fun delete() = runTest { + val key = "${prefix}_del" + api.kvPut(key, "temp") + api.kvDelete(key) + val v = api.kvGet(key) + v.shouldBeNull() + } + + @Test + fun parallelWritesAndReads() = runTest { + val writes = (0 until 10).map { i -> + async { api.kvPut("${prefix}_par_$i", "val_$i") } + } + writes.awaitAll().forEach { it.success.shouldBeTrue() } + + for (i in 0 until 10) { + val v = api.kvGet("${prefix}_par_$i") + v shouldBe "val_$i" + } + } +} diff --git a/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/RealtimeE2ETest.kt b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/RealtimeE2ETest.kt new file mode 100644 index 00000000..77120cfd --- /dev/null +++ b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/RealtimeE2ETest.kt @@ -0,0 +1,74 @@ +package com.aws.blocks.kotlin.e2e + +import blocks.e2e.Cursor +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotBeBlank +import io.kotest.matchers.string.shouldStartWith +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds + +class RealtimeE2ETest { + + private val api = createApi() + + @Test + fun getChannelDescriptor() = runTest { + val channel = api.realtimeGetChannel() + channel.channel.shouldNotBeBlank() + channel.wsUrl.shouldStartWith("ws") + channel.token.shouldNotBeBlank() + } + + @Test + fun publishCursor() = runTest { + val r = api.realtimePublish( + cursor = Cursor(userId = "user-a", x = 10.0, y = 20.0, color = "#ff0000") + ) + r.success.shouldBeTrue() + } + + @Test + fun subscribeAndReceive() = runTest { + val ch = api.realtimeGetChannel("kotlin-test") + + val msg = withContext(Dispatchers.Default) { + val deferred = async { + ch.subscribe().first() + } + + // Publish repeatedly until the subscriber receives the message. + // The subscription may not be established yet (WebSocket handshake + + // subscribe ack), so early publishes are lost — keep retrying. + withTimeout(10.seconds) { + while (deferred.isActive) { + api.realtimePublish( + channel = "kotlin-test", + cursor = Cursor(userId = "kotlin-sub", x = 42.0, y = 99.0, color = "#00ff00") + ) + delay(100) + } + deferred.await() + } + } + msg.userId shouldBe "kotlin-sub" + msg.x shouldBe 42.0 + } + + @Test + fun multiplePublishes() = runTest { + for (i in 0 until 5) { + val r = api.realtimePublish( + cursor = Cursor(userId = "burst-$i", x = i.toDouble(), y = (i * 10).toDouble(), color = "#000") + ) + r.success.shouldBeTrue() + } + } +} diff --git a/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.kt b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.kt new file mode 100644 index 00000000..fb8a478d --- /dev/null +++ b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.kt @@ -0,0 +1,3 @@ +package com.aws.blocks.kotlin.e2e + +expect fun getEnv(name: String): String? diff --git a/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/TodosE2ETest.kt b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/TodosE2ETest.kt new file mode 100644 index 00000000..6dd11cf5 --- /dev/null +++ b/native/kotlin/e2e/src/commonTest/kotlin/com/aws/blocks/kotlin/e2e/TodosE2ETest.kt @@ -0,0 +1,113 @@ +package com.aws.blocks.kotlin.e2e + +import blocks.e2e.Api +import com.aws.blocks.kotlin.BlocksClient +import com.aws.blocks.kotlin.exceptions.ApiException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldHaveAtLeastSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotBeBlank +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlin.test.Test + +class TodosE2ETest { + + private val api = createApi() + private val suffix = Clock.System.now().toEpochMilliseconds().toString() + private val username = "todouser_$suffix" + private val password = "pass1234" + + @Test + fun authGateRejectsUnauthenticated() = runTest { + BlocksClient.clearCookies() + + shouldThrow { api.listTodos() } + shouldThrow { api.createTodo("should-fail") } + } + + @Test + fun createAndGetTodo() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + val t = api.createTodo("first todo", 1.0) + t.todoId.shouldNotBeBlank() + t.title shouldBe "first todo" + t.completed.shouldBeFalse() + t.priority shouldBe 1.0 + + val got = api.getTodo(t.todoId) + got.shouldNotBeNull() + got.title shouldBe "first todo" + } + + @Test + fun listTodos() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + api.createTodo("todo 1", 1.0) + api.createTodo("todo 2", 3.0) + api.createTodo("todo 3", 2.0) + + val all = api.listTodos() + all.shouldHaveAtLeastSize(3) + } + + @Test + fun listTodosSortedByPriority() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + api.createTodo("low", 1.0) + api.createTodo("high", 3.0) + api.createTodo("mid", 2.0) + + val sorted = api.listTodos(Api.ListTodos.SortBy.Priority) + val priorities = sorted.map { it.priority } + priorities shouldBe priorities.sorted() + } + + @Test + fun updateTodo() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + val t = api.createTodo("to update", 2.0) + val r = api.updateTodo(t.todoId, Api.UpdateTodo.Updates(completed = true, title = "updated")) + r.success.shouldBeTrue() + + val got = api.getTodo(t.todoId) + got.shouldNotBeNull() + got.completed.shouldBeTrue() + got.title shouldBe "updated" + } + + @Test + fun deleteTodo() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + + val t = api.createTodo("to delete", 2.0) + val r = api.deleteTodo(t.todoId) + r.success.shouldBeTrue() + + val got = api.getTodo(t.todoId) + got.shouldBeNull() + } + + @Test + fun isolationAfterSignOut() = runTest { + api.basicSignUp(username, password) + api.basicSignIn(username, password) + api.createTodo("some todo", 1.0) + api.basicSignOut() + + shouldThrow { api.listTodos() } + } +} diff --git a/native/kotlin/e2e/src/iosTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.ios.kt b/native/kotlin/e2e/src/iosTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.ios.kt new file mode 100644 index 00000000..9945082a --- /dev/null +++ b/native/kotlin/e2e/src/iosTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.ios.kt @@ -0,0 +1,6 @@ +package com.aws.blocks.kotlin.e2e + +import platform.Foundation.NSProcessInfo + +actual fun getEnv(name: String): String? = + NSProcessInfo.processInfo.environment[name] as? String diff --git a/native/kotlin/e2e/src/jvmTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.jvm.kt b/native/kotlin/e2e/src/jvmTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.jvm.kt new file mode 100644 index 00000000..ff281350 --- /dev/null +++ b/native/kotlin/e2e/src/jvmTest/kotlin/com/aws/blocks/kotlin/e2e/TestEnv.jvm.kt @@ -0,0 +1,4 @@ +package com.aws.blocks.kotlin.e2e + +actual fun getEnv(name: String): String? = + System.getProperty(name) ?: System.getenv(name) diff --git a/native/kotlin/gradle/libs.versions.toml b/native/kotlin/gradle/libs.versions.toml index f14764d9..8150d52c 100644 --- a/native/kotlin/gradle/libs.versions.toml +++ b/native/kotlin/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "9.2.0" -kotlin = "2.1.21" +kotlin = "2.2.10" coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" diff --git a/native/kotlin/run-e2e.sh b/native/kotlin/run-e2e.sh new file mode 100755 index 00000000..253d02aa --- /dev/null +++ b/native/kotlin/run-e2e.sh @@ -0,0 +1,101 @@ +#!/bin/bash +set -e + +# Native SDK E2E — runs the full pipeline locally or in CI. +# Usage: ./run-e2e.sh [--blocks-url URL] [--target jvm|ios] +# +# From the monorepo root, this script: +# 1. Generates the OpenRPC spec from test-apps/native-bindings +# 2. Copies the spec into the e2e project +# 3. Runs Kotlin codegen via the Gradle plugin +# 4. Starts the local dev server (unless --blocks-url is provided) +# 5. Runs the E2E test suite for the specified target +# 6. Stops the server + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MONOREPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BACKEND="$MONOREPO_ROOT/test-apps/native-bindings" +E2E_DIR="$SCRIPT_DIR/e2e" + +BLOCKS_URL="" +SERVER_PID="" +TARGET="jvm" + +cleanup() { + if [ -n "$SERVER_PID" ]; then + echo "Stopping server (PID: $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# Parse args +while [[ $# -gt 0 ]]; do + case $1 in + --blocks-url) BLOCKS_URL="$2"; shift 2 ;; + --target) TARGET="$2"; shift 2 ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +echo "Step 1: Generate OpenRPC spec from test-apps/native-bindings" +cd "$BACKEND" +npm run spec +SPEC_PATH="$BACKEND/aws-blocks/blocks.spec.json" +echo " Spec: $SPEC_PATH" + +echo "" +echo "Step 2: Copy spec to e2e project" +cp "$SPEC_PATH" "$E2E_DIR/blocks.spec.json" +echo " Copied to: $E2E_DIR/blocks.spec.json" + +echo "" +echo "Step 3: Run Kotlin codegen" +cd "$E2E_DIR" +./gradlew awsBlocksCodegen --quiet +echo " Codegen complete" + +if [ -z "$BLOCKS_URL" ]; then + echo "" + echo "Step 4: Start native-bindings dev server" + cd "$BACKEND" + npx tsx aws-blocks/scripts/server.ts > /tmp/blocks-kotlin-e2e-server.log 2>&1 & + SERVER_PID=$! + BLOCKS_URL="http://localhost:3001/aws-blocks/api" + + # Wait for server + for i in $(seq 1 30); do + if curl -s -X POST "$BLOCKS_URL" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"api.kvGet","params":{"key":"healthcheck"},"id":1}' 2>/dev/null | grep -q "result"; then + echo " Server ready at $BLOCKS_URL" + break + fi + sleep 1 + if [ $i -eq 30 ]; then + echo " Server failed to start. Logs:" + cat /tmp/blocks-kotlin-e2e-server.log + exit 1 + fi + done +else + echo "" + echo "Step 4: Using provided endpoint: $BLOCKS_URL" +fi + +echo "" +echo "Step 5: Run E2E tests (target: $TARGET)" +cd "$E2E_DIR" + +case $TARGET in + jvm) + ./gradlew jvmTest -DBLOCKS_URL="$BLOCKS_URL" + ;; + ios) + ./gradlew iosSimulatorArm64Test -DBLOCKS_URL="$BLOCKS_URL" + ;; + *) + echo "Unknown target: $TARGET (expected: jvm, ios)" + exit 1 + ;; +esac diff --git a/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/BlocksClient.kt b/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/BlocksClient.kt index 483b9b3b..828d1f1f 100644 --- a/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/BlocksClient.kt +++ b/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/BlocksClient.kt @@ -24,6 +24,16 @@ class BlocksClient( ) { internal val httpClient: HttpClient = defaultHttpClient() + companion object { + /** + * Clears all persisted cookies (e.g. session tokens). + * Call this to ensure a fully logged-out state across app restarts. + */ + fun clearCookies() { + PersistentCookiesStorage().clear() + } + } + suspend fun execute(request: BlocksRequest): JsonElement { val json = Json.encodeToString(request) diff --git a/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/PersistentCookiesStorage.kt b/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/PersistentCookiesStorage.kt index 3591bbab..558d33c5 100644 --- a/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/PersistentCookiesStorage.kt +++ b/native/kotlin/runtime/src/commonMain/kotlin/com/aws/blocks/kotlin/PersistentCookiesStorage.kt @@ -22,5 +22,9 @@ internal class PersistentCookiesStorage( .mapNotNull { (_, value) -> parseServerSetCookieHeader(value) } } + fun clear() { + store.getAll().keys.forEach { store.remove(it) } + } + override fun close() {} } diff --git a/native/kotlin/runtime/src/iosMain/kotlin/com/aws/blocks/kotlin/KeyValueStore.ios.kt b/native/kotlin/runtime/src/iosMain/kotlin/com/aws/blocks/kotlin/KeyValueStore.ios.kt index 10533290..0adfda76 100644 --- a/native/kotlin/runtime/src/iosMain/kotlin/com/aws/blocks/kotlin/KeyValueStore.ios.kt +++ b/native/kotlin/runtime/src/iosMain/kotlin/com/aws/blocks/kotlin/KeyValueStore.ios.kt @@ -6,8 +6,15 @@ import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr import kotlinx.cinterop.value +import platform.CoreFoundation.CFDictionaryAddValue +import platform.CoreFoundation.CFDictionaryCreateMutable import platform.CoreFoundation.CFDictionaryRef +import platform.CoreFoundation.CFRelease +import platform.CoreFoundation.CFTypeRef import platform.CoreFoundation.CFTypeRefVar +import platform.CoreFoundation.kCFBooleanTrue +import platform.CoreFoundation.kCFTypeDictionaryKeyCallBacks +import platform.CoreFoundation.kCFTypeDictionaryValueCallBacks import platform.Foundation.CFBridgingRelease import platform.Foundation.CFBridgingRetain import platform.Foundation.NSData @@ -32,75 +39,115 @@ import platform.darwin.OSStatus internal actual fun encryptedKeyValueStore(name: String): KeyValueStore = KeychainKeyValueStore(name) +/** + * Builds a Keychain query directly as a `CFDictionary`, runs [block] with it, then releases + * the dictionary and any owned temporaries. + * + * A Kotlin `mapOf(...)` bridged to a dictionary via `CFBridgingRetain` does not produce a + * valid query: `SecItem*` rejects it with `errSecParam`, and even when otherwise valid the + * `CFBoolean` flags (e.g. `kSecReturnData`) do not survive the Foundation bridge. Creating + * the `CFDictionary` directly from CoreFoundation values preserves every value type. + * + * Values come from [QueryBuilder]: `kSec*` constants pass through directly, while Kotlin + * strings/data are bridged with `CFBridgingRetain` and tracked so their owning reference is + * released after the query is used (the dictionary holds its own retain meanwhile). + */ +@OptIn(ExperimentalForeignApi::class) +private inline fun withQuery(build: QueryBuilder.() -> Unit, block: (CFDictionaryRef) -> R): R { + val builder = QueryBuilder().apply(build) + val dict = CFDictionaryCreateMutable( + null, + builder.pairs.size.toLong(), + kCFTypeDictionaryKeyCallBacks.ptr, + kCFTypeDictionaryValueCallBacks.ptr + ) + builder.pairs.forEach { (k, v) -> CFDictionaryAddValue(dict, k, v) } + try { + return block(dict!!) + } finally { + CFRelease(dict) + // Release the references we created via CFBridgingRetain; the dictionary kept its own. + builder.owned.forEach { CFRelease(it) } + } +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +private class QueryBuilder { + val pairs = mutableListOf>() + val owned = mutableListOf() + + /** Adds a pair whose value is an immortal CF constant (not released). */ + fun constant(key: CFTypeRef?, value: CFTypeRef?) { + pairs += key to value + } + + /** Adds a pair whose value is bridged from a Kotlin object and owned by this builder. */ + fun bridged(key: CFTypeRef?, value: Any) { + val ref = CFBridgingRetain(value) + if (ref != null) owned += ref + pairs += key to ref + } +} + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) private class KeychainKeyValueStore(private val service: String) : KeyValueStore { override fun put(key: String, value: String) { remove(key) val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return - val query = mapOf( - kSecClass to kSecClassGenericPassword, - kSecAttrService to service, - kSecAttrAccount to key, - kSecValueData to data - ) - @Suppress("UNCHECKED_CAST") - SecItemAdd(CFBridgingRetain(query) as CFDictionaryRef, null) + withQuery({ + constant(kSecClass, kSecClassGenericPassword) + bridged(kSecAttrService, service as NSString) + bridged(kSecAttrAccount, key as NSString) + bridged(kSecValueData, data) + }) { query -> + SecItemAdd(query, null) + } } - override fun get(key: String): String? { - val query = mapOf( - kSecClass to kSecClassGenericPassword, - kSecAttrService to service, - kSecAttrAccount to key, - kSecReturnData to true - ) + override fun get(key: String): String? = withQuery({ + constant(kSecClass, kSecClassGenericPassword) + bridged(kSecAttrService, service as NSString) + bridged(kSecAttrAccount, key as NSString) + constant(kSecReturnData, kCFBooleanTrue) + }) { query -> memScoped { val result = alloc() - @Suppress("UNCHECKED_CAST") - val status: OSStatus = SecItemCopyMatching( - CFBridgingRetain(query) as CFDictionaryRef, - result.ptr - ) - if (status != errSecSuccess) return null - val data = CFBridgingRelease(result.value) as? NSData ?: return null - return NSString.create(data = data, encoding = NSUTF8StringEncoding) as? String + val status: OSStatus = SecItemCopyMatching(query, result.ptr) + if (status != errSecSuccess) return@memScoped null + val data = CFBridgingRelease(result.value) as? NSData ?: return@memScoped null + NSString.create(data = data, encoding = NSUTF8StringEncoding) as? String } } override fun remove(key: String) { - val query = mapOf( - kSecClass to kSecClassGenericPassword, - kSecAttrService to service, - kSecAttrAccount to key - ) - @Suppress("UNCHECKED_CAST") - SecItemDelete(CFBridgingRetain(query) as CFDictionaryRef) + withQuery({ + constant(kSecClass, kSecClassGenericPassword) + bridged(kSecAttrService, service as NSString) + bridged(kSecAttrAccount, key as NSString) + }) { query -> + SecItemDelete(query) + } } - override fun getAll(): Map { - val query = mapOf( - kSecClass to kSecClassGenericPassword, - kSecAttrService to service, - kSecReturnAttributes to true, - kSecReturnData to true, - kSecMatchLimit to kSecMatchLimitAll - ) + override fun getAll(): Map = withQuery({ + constant(kSecClass, kSecClassGenericPassword) + bridged(kSecAttrService, service as NSString) + constant(kSecReturnAttributes, kCFBooleanTrue) + constant(kSecReturnData, kCFBooleanTrue) + constant(kSecMatchLimit, kSecMatchLimitAll) + }) { query -> memScoped { val result = alloc() - @Suppress("UNCHECKED_CAST") - val status: OSStatus = SecItemCopyMatching( - CFBridgingRetain(query) as CFDictionaryRef, - result.ptr - ) - if (status != errSecSuccess) return emptyMap() + val status: OSStatus = SecItemCopyMatching(query, result.ptr) + if (status != errSecSuccess) return@memScoped emptyMap() @Suppress("UNCHECKED_CAST") val items = CFBridgingRelease(result.value) as? List> - ?: return emptyMap() - return items.mapNotNull { item -> - val account = item[kSecAttrAccount] as? String ?: return@mapNotNull null - val data = item[kSecValueData] as? NSData ?: return@mapNotNull null + ?: return@memScoped emptyMap() + items.mapNotNull { item -> + val account = item[kSecAttrAccount.bridgedKey()] as? String ?: return@mapNotNull null + val data = item[kSecValueData.bridgedKey()] as? NSData ?: return@mapNotNull null val value = NSString.create(data = data, encoding = NSUTF8StringEncoding) as? String ?: return@mapNotNull null account to value @@ -108,3 +155,13 @@ private class KeychainKeyValueStore(private val service: String) : KeyValueStore } } } + +/** + * The dictionary returned by `SecItemCopyMatching` is bridged to a Kotlin `Map` whose keys + * are the `kSec*` attribute constants as bridged `NSString`s. `CFBridgingRelease` of a + * retained copy yields that same `NSString` for lookup, without consuming the immortal + * constant's own reference. + */ +@OptIn(ExperimentalForeignApi::class) +private fun CFTypeRef?.bridgedKey(): Any? = + CFBridgingRelease(this?.let { platform.CoreFoundation.CFRetain(it) }) diff --git a/native/swift/Tests/BlocksE2ETests/RealtimeE2ETests.swift b/native/swift/Tests/BlocksE2ETests/RealtimeE2ETests.swift index ad69d5ad..58aea237 100644 --- a/native/swift/Tests/BlocksE2ETests/RealtimeE2ETests.swift +++ b/native/swift/Tests/BlocksE2ETests/RealtimeE2ETests.swift @@ -21,6 +21,27 @@ final class RealtimeE2ETests: BlocksE2ETestCase { XCTAssertTrue(result.success) } + func testSubscribeAndReceive() async throws { + let channel = try await api.realtimeGetChannel(channel: "swift-sub-test") + let stream = channel.subscribe() + + try await Task.sleep(nanoseconds: 500_000_000) + + let published = Cursor(color: "#00ff00", userId: "swift-sub-test", x: 42, y: 99) + _ = try await api.realtimePublish(cursor: published, channel: "swift-sub-test") + + let deadline = Date().addingTimeInterval(5) + for try await msg in stream { + XCTAssertEqual(msg.userId, "swift-sub-test") + XCTAssertEqual(msg.x, 42) + XCTAssertEqual(msg.y, 99) + XCTAssertEqual(msg.color, "#00ff00") + break + } + XCTAssertTrue(Date() < deadline, "Timed out waiting for message") + channel.close() + } + func testMultiplePublishes() async throws { for idx in 0 ..< 5 { let cursor = Cursor(color: "#000", userId: "burst-\(idx)", x: Double(idx), y: Double(idx * 10)) diff --git a/test-apps/native-bindings/aws-blocks/index.cdk.ts b/test-apps/native-bindings/aws-blocks/index.cdk.ts index f42b8824..4b624f22 100644 --- a/test-apps/native-bindings/aws-blocks/index.cdk.ts +++ b/test-apps/native-bindings/aws-blocks/index.cdk.ts @@ -35,6 +35,12 @@ export const blocksStack = await BlocksStack.create(app, stackName, { backendCDKPath: join(__dirname, 'index.ts'), }); +// Tag for the scheduled stack janitor (cleanup-stacks.yml). It only deletes +// stacks tagged blocks:purpose=e2e-*, so without this a leaked per-run sandbox +// (failed `npm run destroy`) matches the bb-test- prefix but is skipped and +// accumulates forever. Mirrors every other e2e test-app's index.cdk.ts. +cdk.Tags.of(blocksStack).add('blocks:purpose', 'e2e-native-bindings'); + // E2E stacks must be fully deletable so the CI teardown (`npm run destroy`) // can't leave a stuck DELETE_FAILED stack or deletion-protected resources behind. RemovalPolicies.of(blocksStack).destroy(); diff --git a/test-apps/native-bindings/aws-blocks/index.ts b/test-apps/native-bindings/aws-blocks/index.ts index b2b84c3c..e6015a2c 100644 --- a/test-apps/native-bindings/aws-blocks/index.ts +++ b/test-apps/native-bindings/aws-blocks/index.ts @@ -15,6 +15,7 @@ import { stubIdp, relayOrigin, Realtime, + RealtimeChannel, FileBucket, DistributedTable, } from '@aws-blocks/blocks'; @@ -339,7 +340,7 @@ export const api = new ApiNamespace(scope, 'api', (context) => ({ // Realtime // -------------------------------------------------------------------------- - async realtimeGetChannel(channel?: string) { + async realtimeGetChannel(channel?: string): Promise> { return realtime.getChannel('cursors', channel ?? 'default'); },