8 files changed
+40484
No files matched your search
@@ -311,6 +311,7 @@ com.m57.hermescontrol/
|
|||||||
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
|
- [README.md](README.md) — human-facing overview, features, screenshots, tech stack
|
||||||
- [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
|
- [DESIGN.md](DESIGN.md) — visual and interaction requirements, accessibility, and token source map
|
||||||
- [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation
|
- [THEMES.md](app/src/main/java/com/m57/hermescontrol/theme/THEMES.md) — theme template, dispatcher, and mode fallback implementation
|
||||||
|
- [Gateway contract test](CONTRIBUTING.md#gateway-contract-snapshot) — backend drift check; refresh with `scripts/sync-gateway-contract.sh`
|
||||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — contributor workflow, PR checklist, code style
|
||||||
- [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth
|
- [.github/workflows/android.yml](.github/workflows/android.yml) — CI pipeline source of truth
|
||||||
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs
|
- [.github/workflows/merge-conflict-detector.yml](.github/workflows/merge-conflict-detector.yml) — auto-labels conflicting PRs
|
||||||
@@ -112,6 +112,15 @@ We enforce Kotlin coding conventions and Jetpack Compose best practices.
|
|||||||
- Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`), restore `Dispatchers.Main` in teardown, and cancel test-owned scopes. Follow neighboring tests for the affected component.
|
- Mock time/dispatchers explicitly (`StandardTestDispatcher` + `Dispatchers.setMain`), restore `Dispatchers.Main` in teardown, and cancel test-owned scopes. Follow neighboring tests for the affected component.
|
||||||
- For UI changes, exercise the changed flow on a device or emulator and record the behavior observed. A built or downloaded CI APK alone is not behavioral verification. If device verification is unavailable, state that gate explicitly rather than marking it passed.
|
- For UI changes, exercise the changed flow on a device or emulator and record the behavior observed. A built or downloaded CI APK alone is not behavioral verification. If device verification is unavailable, state that gate explicitly rather than marking it passed.
|
||||||
|
|
||||||
|
|
||||||
|
### Gateway contract snapshot
|
||||||
|
|
||||||
|
`GatewayContractTest` checks `WsMethods`, handled server requests and literal param keys against a vendored copy of
|
||||||
|
hermes-agent's `gateway-contract.openrpc.json` (`app/src/test/resources/gateway-contract/`, provenance in `SOURCE`).
|
||||||
|
It is test-only. Refresh it with `scripts/sync-gateway-contract.sh [ref]`, then run
|
||||||
|
`./gradlew testDebugUnitTest --tests '*GatewayContract*'` and review the diff. Allowlist exceptions need a reason in
|
||||||
|
`GatewayContractAllowlist`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dependency & Build Hygiene
|
## Dependency & Build Hygiene
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.boolean
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vendored snapshot of the backend's published gateway contract
|
||||||
|
* (`apps/shared/src/gateway-contract.openrpc.json` in hermes-agent), test-only.
|
||||||
|
* Refresh with `scripts/sync-gateway-contract.sh`; provenance is in `SOURCE`.
|
||||||
|
*/
|
||||||
|
object GatewayContract {
|
||||||
|
private val root: JsonObject by lazy {
|
||||||
|
val stream =
|
||||||
|
requireNotNull(GatewayContract::class.java.getResourceAsStream("/gateway-contract/openrpc.json")) {
|
||||||
|
"gateway-contract/openrpc.json missing from test resources"
|
||||||
|
}
|
||||||
|
Json.parseToJsonElement(stream.bufferedReader().use { it.readText() }).jsonObject
|
||||||
|
}
|
||||||
|
|
||||||
|
private val schemas: JsonObject get() = root["components"]!!.jsonObject["schemas"]!!.jsonObject
|
||||||
|
|
||||||
|
private fun names(key: String): Set<String> =
|
||||||
|
(root[key] as JsonArray).map { it.jsonObject["name"]!!.jsonPrimitive.content }.toSet()
|
||||||
|
|
||||||
|
val methods: Set<String> by lazy { names("methods") }
|
||||||
|
|
||||||
|
/** Server→client request methods (`x-server-requests`). */
|
||||||
|
val serverRequests: Set<String> by lazy { names("x-server-requests") }
|
||||||
|
|
||||||
|
private fun paramsSchema(method: String): JsonObject {
|
||||||
|
val entry =
|
||||||
|
(root["methods"] as JsonArray)
|
||||||
|
.map { it.jsonObject }
|
||||||
|
.first { it["name"]!!.jsonPrimitive.content == method }
|
||||||
|
val ref =
|
||||||
|
entry["params"]!!
|
||||||
|
.jsonArray
|
||||||
|
.first()
|
||||||
|
.jsonObject["schema"]!!
|
||||||
|
.jsonObject["\$ref"]!!
|
||||||
|
.jsonPrimitive.content
|
||||||
|
return schemas[ref.substringAfterLast('/')]!!.jsonObject
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys the backend would reject for [method]: unknown keys when the params
|
||||||
|
* schema forbids extras, plus required keys that are absent. Empty means OK.
|
||||||
|
* Keys only; value types are not checked.
|
||||||
|
*/
|
||||||
|
fun paramKeyProblems(
|
||||||
|
method: String,
|
||||||
|
keys: Set<String>,
|
||||||
|
): List<String> {
|
||||||
|
if (method !in methods) return listOf("$method is not a contract method")
|
||||||
|
val schema = paramsSchema(method)
|
||||||
|
val properties = schema["properties"]?.jsonObject?.keys.orEmpty()
|
||||||
|
val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()
|
||||||
|
val strict = schema["additionalProperties"]?.jsonPrimitive?.boolean == false
|
||||||
|
val problems = mutableListOf<String>()
|
||||||
|
if (strict) {
|
||||||
|
(keys - properties).sorted().forEach {
|
||||||
|
problems += "$method: unknown param '$it' (backend forbids extra keys; allowed: ${properties.sorted()})"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WsMethods constants the backend contract does not list. Every entry needs a
|
||||||
|
* reason; the test fails if an entry appears in the contract or is no longer a
|
||||||
|
* WsMethods constant, so stale exceptions get removed.
|
||||||
|
*/
|
||||||
|
object GatewayContractAllowlist {
|
||||||
|
private const val LEGACY = "legacy fallback for gateways before srq request frames (#1119)"
|
||||||
|
|
||||||
|
val entries: Map<String, String> =
|
||||||
|
mapOf(
|
||||||
|
"gateway.ping" to "handled inline by the WS transport itself (tui_gateway/ws.py), not a contract method",
|
||||||
|
"clarify.respond" to LEGACY,
|
||||||
|
"sudo.respond" to LEGACY,
|
||||||
|
"secret.respond" to LEGACY,
|
||||||
|
"vault.unlock.respond" to LEGACY,
|
||||||
|
"vault.code.respond" to LEGACY,
|
||||||
|
"vault.save_login.respond" to LEGACY,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.CookieManager
|
||||||
|
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the client-owned RPCs through the real [HermesWsClient] and checks the
|
||||||
|
* exact frame on the wire against the vendored gateway contract (#1374).
|
||||||
|
*/
|
||||||
|
class GatewayContractFramesTest {
|
||||||
|
private lateinit var mockWebServer: MockWebServer
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockkStatic(Log::class)
|
||||||
|
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||||
|
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||||
|
|
||||||
|
mockWebServer = MockWebServer()
|
||||||
|
mockWebServer.start()
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||||
|
every { AuthManager.isAutoReconnect() } returns false
|
||||||
|
every { AuthManager.getSessionCookie() } returns null
|
||||||
|
every { AuthManager.serverStore } returns
|
||||||
|
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||||
|
every { it.getLatestState() } returns
|
||||||
|
com.m57.hermescontrol.data.config
|
||||||
|
.ServerStoreState()
|
||||||
|
}
|
||||||
|
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||||
|
mockkObject(DashboardSessionTokenRefresher)
|
||||||
|
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||||
|
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||||
|
|
||||||
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
|
HermesWsClient.setAppForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||||
|
HermesWsClient.releaseBackgroundConnectionLease()
|
||||||
|
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||||
|
}
|
||||||
|
runCatching { mockWebServer.shutdown() }
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
|
||||||
|
private fun captureFrame(
|
||||||
|
method: String,
|
||||||
|
beforeConnect: () -> Unit = {},
|
||||||
|
trigger: () -> Unit = {},
|
||||||
|
): JsonObject {
|
||||||
|
val opened = CountDownLatch(1)
|
||||||
|
val captured = CountDownLatch(1)
|
||||||
|
val frame = AtomicReference<JsonObject?>(null)
|
||||||
|
mockWebServer.enqueue(
|
||||||
|
MockResponse().withWebSocketUpgrade(
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onOpen(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
response: Response,
|
||||||
|
) = opened.countDown()
|
||||||
|
|
||||||
|
override fun onMessage(
|
||||||
|
webSocket: WebSocket,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||||
|
val name = (json["method"] as? JsonPrimitive)?.content
|
||||||
|
if (frame.get() == null && name == method) {
|
||||||
|
frame.set(json)
|
||||||
|
captured.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
beforeConnect()
|
||||||
|
HermesWsClient.connect()
|
||||||
|
runBlocking {
|
||||||
|
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||||
|
}
|
||||||
|
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||||
|
trigger()
|
||||||
|
assertTrue("No '$method' frame reached the server", captured.await(5, TimeUnit.SECONDS))
|
||||||
|
return requireNotNull(frame.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertMatchesContract(frame: JsonObject) {
|
||||||
|
val method = (frame["method"] as JsonPrimitive).content
|
||||||
|
val params = frame["params"] as JsonObject
|
||||||
|
val problems = GatewayContract.paramKeyProblems(method, params.keys)
|
||||||
|
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun promptSubmitFrameMatchesContract() {
|
||||||
|
assertMatchesContract(
|
||||||
|
captureFrame(WsMethods.PROMPT_SUBMIT) { HermesWsClient.sendMessage("s1", "hello", queued = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eventsSinceReplayFrameMatchesContract() {
|
||||||
|
assertMatchesContract(
|
||||||
|
captureFrame(WsMethods.SESSION_EVENTS_SINCE, beforeConnect = { HermesWsClient.setSeqWatermark("s1", 5) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.m57.hermescontrol.data.ws
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.lang.reflect.Modifier
|
||||||
|
|
||||||
|
/** Fails CI when the app drifts from the backend's published gateway contract (#1374). */
|
||||||
|
class GatewayContractTest {
|
||||||
|
private val wsMethodConstants: Map<String, String> =
|
||||||
|
WsMethods::class.java.declaredFields
|
||||||
|
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
|
||||||
|
.associate { it.name to it.get(null) as String }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyWsMethodIsInTheContractOrAllowlisted() {
|
||||||
|
val unknown =
|
||||||
|
wsMethodConstants.filter { (_, method) ->
|
||||||
|
method !in GatewayContract.methods && method !in GatewayContractAllowlist.entries
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
"WsMethods constants missing from the gateway contract (backend removed or renamed them?): " +
|
||||||
|
unknown.entries.joinToString { "${it.key}=\"${it.value}\"" } +
|
||||||
|
". Fix the constant, or allowlist it with a reason in GatewayContractAllowlist.",
|
||||||
|
unknown.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun profileScopedMethodsAreContractMethods() {
|
||||||
|
val unknown = WsMethods.PROFILE_SCOPED_METHODS - GatewayContract.methods
|
||||||
|
assertTrue("PROFILE_SCOPED_METHODS not in the contract: $unknown", unknown.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allowlistEntriesHaveReasonsAndAreNotStale() {
|
||||||
|
val sent = wsMethodConstants.values.toSet()
|
||||||
|
GatewayContractAllowlist.entries.forEach { (method, reason) ->
|
||||||
|
assertTrue("Allowlist entry '$method' needs a reason", reason.isNotBlank())
|
||||||
|
assertTrue(
|
||||||
|
"Allowlist entry '$method' is now in the contract; remove it from GatewayContractAllowlist",
|
||||||
|
method !in GatewayContract.methods,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
"Allowlist entry '$method' is no longer a WsMethods constant; remove it",
|
||||||
|
method in sent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handledServerRequestsExistInTheContract() {
|
||||||
|
val unknown = APP_HANDLED_SERVER_REQUESTS - GatewayContract.serverRequests
|
||||||
|
assertTrue("Handled server requests missing from the contract: $unknown", unknown.isEmpty())
|
||||||
|
|
||||||
|
// Informational: unsupported requests are already answered with -32601.
|
||||||
|
val unhandled = (GatewayContract.serverRequests - APP_HANDLED_SERVER_REQUESTS).sorted()
|
||||||
|
println("Contract server requests the app does not handle: $unhandled")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun paramKeyCheckRejectsTheRegressionsItExistsFor() {
|
||||||
|
// #1118: the backend forbids extra keys, so since_seq broke replay with error 4000.
|
||||||
|
val problems = GatewayContract.paramKeyProblems("session.events.since", setOf("session_id", "since_seq"))
|
||||||
|
assertEquals(1, problems.count { "since_seq" in it })
|
||||||
|
assertTrue(GatewayContract.paramKeyProblems("session.events.since", setOf("session_id")).isEmpty())
|
||||||
|
assertTrue(GatewayContract.paramKeyProblems("session.nope", emptySet()).isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun literalParamKeysAtCallSitesMatchTheContract() {
|
||||||
|
CALL_SITE_PARAM_KEYS.forEach { (method, keys) ->
|
||||||
|
val problems = GatewayContract.paramKeyProblems(method, keys)
|
||||||
|
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
|
||||||
|
val APP_HANDLED_SERVER_REQUESTS =
|
||||||
|
setOf("approval", "clarify", "sudo", "secret", "vault.code", "vault.save_login", "vault.unlock_prompt")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of literal param keys at production call sites (the two client-owned
|
||||||
|
* ones, prompt.submit and session.events.since, are checked against real frames
|
||||||
|
* in [GatewayContractFramesTest]). Update together with the call site.
|
||||||
|
*/
|
||||||
|
val CALL_SITE_PARAM_KEYS: Map<String, Set<String>> =
|
||||||
|
mapOf(
|
||||||
|
// ChatViewModel, GroupChatViewModel
|
||||||
|
"session.create" to setOf("profile", "title", "source", "hidden"),
|
||||||
|
// ChatViewModel.resumeSession, NotificationReplyReceiver
|
||||||
|
"session.resume" to setOf("session_id", "omit_messages"),
|
||||||
|
// ChatViewModel, GroupChatViewModel
|
||||||
|
"session.interrupt" to setOf("session_id"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
repo: NousResearch/hermes-agent
|
||||||
|
ref: main
|
||||||
|
sha: ea114c3e98c3339e13004adfc6098cf28ed7d754
|
||||||
|
date: 2026-09-29
|
||||||
|
path: apps/shared/src/gateway-contract.openrpc.json
|
||||||
File diff suppressed because it is too large.
Load diff
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the vendored gateway contract snapshot from hermes-agent (#1374).
|
||||||
|
# Usage: scripts/sync-gateway-contract.sh [ref] (default: main)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REF="${1:-main}"
|
||||||
|
REPO="NousResearch/hermes-agent"
|
||||||
|
CONTRACT_PATH="apps/shared/src/gateway-contract.openrpc.json"
|
||||||
|
DEST="$(cd "$(dirname "$0")/.." && pwd)/app/src/test/resources/gateway-contract"
|
||||||
|
|
||||||
|
SHA="$(gh api "repos/$REPO/commits/$REF" --jq .sha)"
|
||||||
|
DATE="$(gh api "repos/$REPO/commits/$SHA" --jq .commit.committer.date)"
|
||||||
|
|
||||||
|
mkdir -p "$DEST"
|
||||||
|
gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$CONTRACT_PATH?ref=$SHA" > "$DEST/openrpc.json"
|
||||||
|
jq -e '.methods | length > 0' "$DEST/openrpc.json" > /dev/null
|
||||||
|
|
||||||
|
printf 'repo: %s\nref: %s\nsha: %s\ndate: %s\npath: %s\n' "$REPO" "$REF" "$SHA" "${DATE%%T*}" "$CONTRACT_PATH" > "$DEST/SOURCE"
|
||||||
|
echo "Vendored $REPO@$SHA. Run ./gradlew testDebugUnitTest --tests '*GatewayContract*' and review the diff."
|
||||||
Reference in new issue
Block a user