parsed = objectMapper.readValue(in, new TypeReference<>() {});
+ return parsed == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed);
+ } catch (IOException e) {
+ log.warn("Could not read the bundled dev UI runtime config at {}.", resource, e);
+ return new LinkedHashMap<>();
+ }
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerConfiguredUiDirTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerConfiguredUiDirTest.java
new file mode 100644
index 000000000..0d4c0109d
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/AdkWebServerConfiguredUiDirTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.adk.web;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * A directory named by {@code adk.web.ui.dir} is really served under {@code /dev-ui/}. The other
+ * tests cover the bundled classpath copy, and {@code DevUiAssetsTest} only checks the string this
+ * normalizes to, which cannot tell whether the resource handler can resolve it.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+public class AdkWebServerConfiguredUiDirTest {
+
+ @TempDir static Path uiDir;
+
+ @DynamicPropertySource
+ static void configuredUiDir(DynamicPropertyRegistry registry) {
+ // @SpringBootTest(properties=...) needs a constant; the temp directory is only known now.
+ registry.add("adk.web.ui.dir", () -> uiDir.toString());
+ }
+
+ @BeforeAll
+ static void writeUi() throws IOException {
+ Files.writeString(uiDir.resolve("index.html"), "temp ui");
+ Files.writeString(uiDir.resolve("asset.txt"), "from the configured dir");
+ }
+
+ @Autowired private MockMvc mockMvc;
+
+ @Test
+ public void configuredUiDir_shouldBeServedBelowDevUi() throws Exception {
+ // The body, not just a 200: it proves the bytes came from the configured directory.
+ mockMvc
+ .perform(get("/dev-ui/asset.txt"))
+ .andExpect(status().isOk())
+ .andExpect(content().string("from the configured dir"));
+ }
+
+ @Test
+ public void configuredUiDir_shouldSupplyTheForwardTarget() throws Exception {
+ mockMvc
+ .perform(get("/dev-ui/index.html"))
+ .andExpect(status().isOk())
+ .andExpect(content().string("temp ui"));
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java
new file mode 100644
index 000000000..af866ee44
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java
@@ -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.
+ */
+
+package com.google.adk.web;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * Spring's own forwarded-header support keeps working: with {@code
+ * server.forward-headers-strategy=framework} the operator opts in, {@code ForwardedHeaderFilter}
+ * turns the forwarded prefix into the request's context path, and the redirect picks it up. This
+ * server adds nothing here and reads no header itself; the test exists so enabling that Spring
+ * feature keeps behaving as it did.
+ *
+ * The filter honours the standard {@code Forwarded} header as well as {@code X-Forwarded-*}, so
+ * both are covered.
+ *
+ *
This is the path taken when {@code adk.web.backend-url} is unset. Setting it supplies the
+ * prefix directly and the forwarded one is ignored, though the host and scheme still come from
+ * these headers while the filter is enabled.
+ */
+@SpringBootTest(properties = "server.forward-headers-strategy=framework")
+@AutoConfigureMockMvc
+public class AdkWebServerProxyRedirectTest {
+
+ @Autowired private MockMvc mockMvc;
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_withSpringForwardedHeaderSupport_shouldKeepThePrefix(String path)
+ throws Exception {
+ mockMvc
+ .perform(
+ get(path)
+ .header("X-Forwarded-Prefix", "/my-app")
+ .header("X-Forwarded-Host", "gw.example.com")
+ .header("X-Forwarded-Proto", "https"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("https://gw.example.com/my-app/dev-ui/"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_withTheStandardForwardedHeader_shouldUseItsHost(String path)
+ throws Exception {
+ // RFC 7239 defines no prefix parameter, so this one moves the host and scheme but not the path.
+ mockMvc
+ .perform(get(path).header("Forwarded", "host=gw.example.com;proto=https"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("https://gw.example.com/dev-ui/"));
+ }
+
+ @Test
+ public void devUiEntryPoint_withNoForwardedHeaders_shouldNotGainAPrefix() throws Exception {
+ // Control arm: the filter is installed here, so this pins that it leaves this case alone.
+ mockMvc
+ .perform(get("/"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dev-ui/"));
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java
index cfb162db2..5d1b46cce 100644
--- a/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java
+++ b/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java
@@ -17,6 +17,7 @@
package com.google.adk.web;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -40,22 +41,52 @@ public class AdkWebServerUITest {
@Autowired private MockMvc mockMvc;
- @Test
- public void rootShouldRedirectToDevUi() throws Exception {
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_shouldRedirectToTrailingSlashForm(String path) throws Exception {
+ // index.html declares , which only resolves correctly from "/dev-ui/".
mockMvc
- .perform(get("/"))
+ .perform(get(path))
.andExpect(status().is3xxRedirection())
- .andExpect(redirectedUrl("/dev-ui"));
+ .andExpect(redirectedUrl("/dev-ui/"));
+ }
+
+ @Test
+ public void devUiRoot_shouldForwardToIndexHtml() throws Exception {
+ // MockMvc records a forward without running it, so assert the target, not a status.
+ mockMvc.perform(get("/dev-ui/")).andExpect(forwardedUrl("/dev-ui/index.html"));
+ }
+
+ @Test
+ public void devUiIndexHtml_shouldBeServed() throws Exception {
+ // The other half: the target the forward names actually resolves through the mount.
+ mockMvc.perform(get("/dev-ui/index.html")).andExpect(status().isOk());
+ }
+
+ @Test
+ public void devUiAssets_shouldBeServedBelowDevUi() throws Exception {
+ mockMvc.perform(get("/dev-ui/adk_favicon.svg")).andExpect(status().isOk());
}
@ParameterizedTest
- @ValueSource(strings = {"/dev-ui", "/dev-ui/"})
- public void devUiEndpointsShouldReturnOk(String path) throws Exception {
- mockMvc.perform(get(path)).andExpect(status().isOk());
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_shouldKeepQueryString(String path) throws Exception {
+ // The UI picks its agent from ?app=, and the sample READMEs send users to "/dev-ui".
+ mockMvc
+ .perform(get(path + "?app=my-agent"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dev-ui/?app=my-agent"));
+ }
+
+ @Test
+ public void devUiAssets_shouldNotBeServedAtRoot() throws Exception {
+ // Narrowing the handler from "/**" to "/dev-ui/**" makes this deliberately unreachable.
+ mockMvc.perform(get("/adk_favicon.svg")).andExpect(status().isNotFound());
}
@Test
- public void nonExistentUiPageShouldReturnNotFound() throws Exception {
- mockMvc.perform(get("/non-existent-page")).andExpect(status().isNotFound());
+ public void nonExistentDevUiPath_shouldReturnNotFound() throws Exception {
+ // No SPA fallback, deliberately: a deep link 404s rather than serving index.html.
+ mockMvc.perform(get("/dev-ui/non-existent-page")).andExpect(status().isNotFound());
}
}
diff --git a/dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java b/dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java
new file mode 100644
index 000000000..79cdc24b7
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.adk.web;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * With {@code adk.web.backend-url} set, the entry redirect carries the gateway's path prefix on its
+ * own, so a deployment behind a path-stripping proxy needs nothing forwarded from the proxy. The
+ * property governs the path only: host and scheme still come from forwarded headers wherever the
+ * operator has enabled {@code server.forward-headers-strategy}.
+ */
+public class BackendUrlRedirectTest {
+
+ @Nested
+ @SpringBootTest(
+ properties = {
+ "adk.web.backend-url=https://gw.example.com/my-app",
+ // Without this Spring discards the forwarded header, and the test below could not fail if
+ // the redirect stopped ignoring it.
+ "server.forward-headers-strategy=framework"
+ })
+ @AutoConfigureMockMvc
+ class ConfiguredWithTheFilter {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_shouldCarryTheConfiguredPrefix(
+ String path, @Autowired MockMvc mockMvc) throws Exception {
+ // No forwarded headers: the configured value is the only thing that knows the prefix.
+ mockMvc
+ .perform(get(path))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/my-app/dev-ui/"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_shouldNotStackAForwardedPrefix(
+ String path, @Autowired MockMvc mockMvc) throws Exception {
+ // The filter makes this a context path; the configured prefix replaces it.
+ mockMvc
+ .perform(get(path).header("X-Forwarded-Prefix", "/evil"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("http://localhost/my-app/dev-ui/"));
+ }
+
+ @Test
+ public void devUiEntryPoint_forwardedHostAndProto_stillSetTheRedirectsOrigin(
+ @Autowired MockMvc mockMvc) throws Exception {
+ // The configured value supplies the path; host and scheme still come from the headers.
+ mockMvc
+ .perform(
+ get("/")
+ .header("X-Forwarded-Host", "gw.example.com")
+ .header("X-Forwarded-Proto", "https"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("https://gw.example.com/my-app/dev-ui/"));
+ }
+ }
+
+ @Nested
+ @SpringBootTest(properties = "adk.web.backend-url=https://gw.example.com/my-app")
+ @AutoConfigureMockMvc
+ class ConfiguredWithoutTheFilter {
+
+ @Test
+ public void devUiEntryPoint_forwardedHeaders_reachNothingUnderTheShippedStrategy(
+ @Autowired MockMvc mockMvc) throws Exception {
+ // No strategy set, which is what ships: the property supplies the path, these reach nothing.
+ mockMvc
+ .perform(
+ get("/")
+ .header("X-Forwarded-Prefix", "/evil")
+ .header("X-Forwarded-Host", "evil.example.com")
+ .header("Forwarded", "host=evil.example.com;proto=https"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/my-app/dev-ui/"));
+ }
+ }
+
+ @Nested
+ @SpringBootTest
+ @AutoConfigureMockMvc
+ class UnconfiguredWithoutTheFilter {
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_shouldRedirectWithoutAPrefix(
+ String path, @Autowired MockMvc mockMvc) throws Exception {
+ mockMvc
+ .perform(get(path))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dev-ui/"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "/dev-ui"})
+ public void devUiEntryPoints_withForwardedHeaders_shouldStillRedirectWithoutAPrefix(
+ String path, @Autowired MockMvc mockMvc) throws Exception {
+ // The shipped default: no forwarded-header strategy is set, so nothing in the request can
+ // reach the redirect.
+ mockMvc
+ .perform(
+ get(path)
+ .header("X-Forwarded-Prefix", "/evil")
+ .header("X-Forwarded-Host", "evil.example.com")
+ .header("Forwarded", "host=evil.example.com;proto=https"))
+ .andExpect(status().is3xxRedirection())
+ .andExpect(redirectedUrl("/dev-ui/"));
+ }
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/config/BackendUrlTest.java b/dev/src/test/java/com/google/adk/web/config/BackendUrlTest.java
new file mode 100644
index 000000000..73cbbfa3d
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/config/BackendUrlTest.java
@@ -0,0 +1,158 @@
+/*
+ * 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.adk.web.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.slf4j.LoggerFactory;
+
+/**
+ * One reading of {@code adk.web.backend-url}. The value the dev UI is served and the prefix its
+ * entry redirect carries come from the same parse, so they cannot disagree.
+ */
+public class BackendUrlTest {
+
+ private static final String CONFIGURED = "https://gw.example.com/my-app";
+
+ @Test
+ public void unset_isEmptyEverywhere() {
+ for (String in : new String[] {null, "", " "}) {
+ assertThat(BackendUrl.from(in).value()).isEmpty();
+ assertThat(BackendUrl.from(in).pathPrefix()).isEmpty();
+ }
+ }
+
+ @Test
+ public void absoluteUrl_isServedAndSuppliesThePrefix() {
+ BackendUrl url = BackendUrl.from(CONFIGURED);
+
+ assertThat(url.value()).isEqualTo(CONFIGURED);
+ assertThat(url.pathPrefix()).isEqualTo("/my-app");
+ }
+
+ @Test
+ public void trailingSlashes_areStripped() {
+ // The UI appends paths starting with a slash, so a trailing one would yield //run_live.
+ assertThat(BackendUrl.from(CONFIGURED + "/").value()).isEqualTo(CONFIGURED);
+ assertThat(BackendUrl.from(CONFIGURED + "///").pathPrefix()).isEqualTo("/my-app");
+ assertThat(BackendUrl.from(" " + CONFIGURED + " ").value()).isEqualTo(CONFIGURED);
+ }
+
+ @Test
+ public void hostWithoutPath_hasNoPrefix() {
+ assertThat(BackendUrl.from("https://gw.example.com").pathPrefix()).isEmpty();
+ assertThat(BackendUrl.from("https://gw.example.com/").pathPrefix()).isEmpty();
+ }
+
+ @Test
+ public void percentEncoding_isKept() {
+ // This goes into a Location header, so decoding would re-encode wrongly and %2F would
+ // turn into a path separator.
+ assertThat(BackendUrl.from("https://gw.example.com/my%20app").pathPrefix())
+ .isEqualTo("/my%20app");
+ assertThat(BackendUrl.from("https://gw.example.com/a%2Fb").pathPrefix()).isEqualTo("/a%2Fb");
+ }
+
+ @Test
+ public void doubledSlash_doesNotBecomeAHost() {
+ // "//my-app/dev-ui/" is protocol-relative: a browser resolves it to the host "my-app".
+ assertThat(BackendUrl.from("https://gw.example.com//my-app").pathPrefix()).isEqualTo("/my-app");
+ }
+
+ @Test
+ public void unusableValue_isStillServedButWarns() {
+ // Never silently discarded, because it is an explicit setting.
+ assertThat(BackendUrl.from("/my-app").value()).isEqualTo("/my-app");
+ assertThat(BackendUrl.from("HTTPS://gw.example.com/x").value())
+ .isEqualTo("HTTPS://gw.example.com/x");
+ assertThat(BackendUrl.from("http://").value()).isEqualTo("http://");
+
+ assertThat(warningsFor("/my-app")).hasSize(1);
+ assertThat(warningsFor("/my-app").get(0)).contains("/my-app");
+ assertThat(warningsFor("HTTPS://gw.example.com/x")).hasSize(1);
+ assertThat(warningsFor("http://")).hasSize(1);
+ }
+
+ @Test
+ public void slashOnly_isNotEmptiedByTheSlashTrim() {
+ // Emptying this would make the served value fall back to whatever the bundled config says,
+ // silently losing an explicit setting.
+ assertThat(BackendUrl.from("/").value()).isEqualTo("/");
+ assertThat(BackendUrl.from("/").pathPrefix()).isEmpty();
+ }
+
+ @Test
+ public void queryFragmentOrUserinfo_yieldsNoPrefixAndWarns() {
+ // The UI appends onto the value, so these would land in the middle of every request.
+ for (String in :
+ new String[] {
+ "https://gw.example.com/a/b?q=1",
+ "https://gw.example.com/a/b#f",
+ "https://user:pass@gw.example.com/my-app"
+ }) {
+ assertThat(BackendUrl.from(in).pathPrefix()).isEmpty();
+ assertThat(warningsFor(in)).hasSize(1);
+ }
+ }
+
+ @Test
+ public void valueWithoutALeadingSlashPath_yieldsNoPrefix() {
+ // "gw.example.com/dev-ui/" in a Location is a relative redirect, i.e. a guess.
+ assertThat(BackendUrl.from("gw.example.com").pathPrefix()).isEmpty();
+ assertThat(warningsFor("gw.example.com")).hasSize(1);
+ }
+
+ @Test
+ public void unparseableValue_yieldsNoPrefixAndWarns() {
+ // The old split lost the prefix here silently; one parse now warns instead.
+ String malformed = "https://gw.example.com/my app";
+
+ assertThat(BackendUrl.from(malformed).value()).isEqualTo(malformed);
+ assertThat(BackendUrl.from(malformed).pathPrefix()).isEmpty();
+ assertThat(warningsFor(malformed)).hasSize(1);
+ }
+
+ @Test
+ public void usableValue_doesNotWarn() {
+ assertThat(warningsFor(CONFIGURED)).isEmpty();
+ assertThat(warningsFor(CONFIGURED + "/")).isEmpty();
+ assertThat(warningsFor("")).isEmpty();
+ }
+
+ /** The WARN messages logged while interpreting {@code configured}. */
+ private static List warningsFor(String configured) {
+ Logger logger = (Logger) LoggerFactory.getLogger(BackendUrl.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ BackendUrl unused = BackendUrl.from(configured);
+ } finally {
+ logger.detachAppender(appender);
+ }
+ return appender.list.stream()
+ .filter(event -> event.getLevel() == Level.WARN)
+ .map(ILoggingEvent::getFormattedMessage)
+ .toList();
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java b/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java
new file mode 100644
index 000000000..c7d53382d
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.adk.web.config;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+/** The {@code adk.web.ui.dir} normalization the resource handler resolves assets through. */
+public class DevUiAssetsTest {
+
+ @Test
+ public void assetRoot_noDirConfigured_fallsBackToTheBundledCopy() {
+ assertThat(DevUiAssets.assetRoot(null)).isEqualTo("classpath:/browser/");
+ assertThat(DevUiAssets.assetRoot("")).isEqualTo("classpath:/browser/");
+ }
+
+ @Test
+ public void assetRoot_configuredDir_becomesAFileUrl() {
+ assertThat(DevUiAssets.assetRoot("/srv/ui")).isEqualTo("file:/srv/ui/");
+ assertThat(DevUiAssets.assetRoot("/srv/ui/")).isEqualTo("file:/srv/ui/");
+ assertThat(DevUiAssets.assetRoot("file:/srv/ui")).isEqualTo("file:/srv/ui/");
+ }
+
+ @Test
+ public void assetRoot_windowsSeparators_areNormalized() {
+ assertThat(DevUiAssets.assetRoot("C:\\srv\\ui")).isEqualTo("file:C:/srv/ui/");
+ }
+
+ @Test
+ public void assetLocation_appendsToTheRoot() {
+ assertThat(DevUiAssets.assetLocation(null, DevUiAssets.RUNTIME_CONFIG_PATH))
+ .isEqualTo("classpath:/browser/assets/config/runtime-config.json");
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java
new file mode 100644
index 000000000..a01dd9462
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.adk.web.controller;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.adk.web.config.BackendUrl;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.core.io.DescriptiveResource;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+
+/**
+ * The served runtime config merges into the bundled document rather than replacing it, so keys the
+ * dev UI bundle gains later are not silently dropped. {@code adk.web.backend-url} overrides one
+ * key; an unset value leaves the bundled document exactly as it was served before.
+ */
+public class RuntimeConfigControllerMergeTest {
+
+ private static final String CONFIGURED = "https://gw.example.com/my-app";
+
+ @Test
+ public void runtimeConfig_shouldPreserveOtherBundledKeys() {
+ String bundled = "{\"backendUrl\":\"\",\"telemetry\":null,\"logo\":{\"text\":\"x\"}}";
+ RuntimeConfigController controller = controllerFor(bundled, CONFIGURED);
+
+ Map config = controller.runtimeConfig().getBody();
+
+ assertThat(config).containsEntry("backendUrl", CONFIGURED);
+ assertThat(config).containsKey("telemetry");
+ assertThat(config).containsEntry("logo", Map.of("text", "x"));
+ }
+
+ @Test
+ public void runtimeConfig_shouldOverrideTheBundledBackendUrl() {
+ RuntimeConfigController controller =
+ controllerFor("{\"backendUrl\":\"http://stale\"}", CONFIGURED);
+
+ assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", CONFIGURED);
+ }
+
+ @Test
+ public void runtimeConfig_propertyUnset_shouldKeepTheBundledBackendUrl() {
+ // The static handler served this file verbatim, so a hand-set value survived; it still does.
+ RuntimeConfigController controller =
+ controllerFor("{\"backendUrl\":\"http://elsewhere:9000\"}");
+
+ assertThat(controller.runtimeConfig().getBody())
+ .containsExactly("backendUrl", "http://elsewhere:9000");
+ }
+
+ @Test
+ public void runtimeConfig_propertyUnsetAndNoBundledKey_shouldStillReportBackendUrl() {
+ RuntimeConfigController controller = controllerFor("{\"telemetry\":null}");
+
+ assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", "");
+ }
+
+ @Test
+ public void runtimeConfig_bundledFileMissing_shouldStillServe() {
+ RuntimeConfigController controller = controllerFor(null, CONFIGURED);
+
+ assertThat(controller.runtimeConfig().getBody()).containsExactly("backendUrl", CONFIGURED);
+ }
+
+ @Test
+ public void runtimeConfig_bundledFileMalformed_shouldStillServe() {
+ RuntimeConfigController controller = controllerFor("not json at all", CONFIGURED);
+
+ assertThat(controller.runtimeConfig().getBody()).containsExactly("backendUrl", CONFIGURED);
+ }
+
+ @Test
+ public void runtimeConfig_bundledFileNotAnObject_shouldStillServe() {
+ // A config that is not a JSON object is ignored rather than failing the request.
+ RuntimeConfigController controller = controllerFor("[1, 2, 3]", CONFIGURED);
+
+ assertThat(controller.runtimeConfig().getBody()).containsExactly("backendUrl", CONFIGURED);
+ }
+
+ /** A controller with {@code adk.web.backend-url} unset. */
+ private static RuntimeConfigController controllerFor(String body) {
+ return controllerFor(body, "");
+ }
+
+ /** A controller whose bundled config is {@code body}, or absent when {@code body} is null. */
+ private static RuntimeConfigController controllerFor(String body, String backendUrl) {
+ ResourceLoader loader =
+ new ResourceLoader() {
+ @Override
+ public Resource getResource(String location) {
+ return body == null
+ ? new DescriptiveResource("absent")
+ : new ByteArrayResource(body.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public ClassLoader getClassLoader() {
+ return RuntimeConfigControllerMergeTest.class.getClassLoader();
+ }
+ };
+ return new RuntimeConfigController(
+ loader, new ObjectMapper(), null, BackendUrl.from(backendUrl));
+ }
+}
diff --git a/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java
new file mode 100644
index 000000000..111c62e30
--- /dev/null
+++ b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.adk.web.controller;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * The dev UI learns where its backend lives from {@code adk.web.backend-url}, so its API calls
+ * reach a server published under a path prefix. Nothing is read from the request, so a client
+ * cannot influence the value it is served.
+ */
+public class RuntimeConfigControllerTest {
+
+ private static final String CONFIG = "/dev-ui/assets/config/runtime-config.json";
+
+ @Nested
+ @SpringBootTest(
+ properties = {
+ "adk.web.backend-url=https://gw.example.com/my-app",
+ // Without this Spring ignores the forwarded headers anyway, so the tests below could
+ // not fail if the value went back to being derived from the request.
+ "server.forward-headers-strategy=framework"
+ })
+ @AutoConfigureMockMvc
+ class ConfiguredWithTheFilter {
+
+ @Test
+ public void runtimeConfig_shouldReportTheConfiguredBackendUrl(@Autowired MockMvc mockMvc)
+ throws Exception {
+ mockMvc
+ .perform(get(CONFIG))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.backendUrl").value("https://gw.example.com/my-app"))
+ .andExpect(header().string("Cache-Control", "no-store"));
+ }
+
+ @Test
+ public void runtimeConfig_forwardedHeaders_shouldNotChangeTheValue(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // Configuration alone decides this; a client naming its own host cannot move it.
+ mockMvc
+ .perform(
+ get(CONFIG)
+ .header("X-Forwarded-Prefix", "/evil")
+ .header("X-Forwarded-Host", "evil.example.com")
+ .header("X-Forwarded-Proto", "https"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.backendUrl").value("https://gw.example.com/my-app"));
+ }
+ }
+
+ @Nested
+ @SpringBootTest
+ @AutoConfigureMockMvc
+ class UnconfiguredWithoutTheFilter {
+
+ @Test
+ public void runtimeConfig_forwardedHeaders_areNotReadFromTheRequest(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // What ships: no filter, so these reach the controller and nothing reads them.
+ mockMvc
+ .perform(
+ get(CONFIG)
+ .header("X-Forwarded-Prefix", "/evil")
+ .header("X-Forwarded-Host", "evil.example.com")
+ .header("Forwarded", "host=evil.example.com;proto=https"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.backendUrl").value(""));
+ }
+ }
+
+ @Nested
+ @SpringBootTest(properties = "server.forward-headers-strategy=framework")
+ @AutoConfigureMockMvc
+ class UnconfiguredWithTheFilter {
+
+ @Test
+ public void runtimeConfig_shouldServeWhatTheBundledFileSaid(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // Unset is the default, so an unproxied deployment sees exactly what it saw before.
+ mockMvc
+ .perform(get(CONFIG))
+ .andExpect(status().isOk())
+ // Byte-exact: the bundled file is pretty-printed, so this passes only if the
+ // controller (which serializes compactly) answered rather than the static handler.
+ .andExpect(content().string("{\"backendUrl\":\"\"}"));
+ }
+
+ @Test
+ public void runtimeConfig_forwardedHeaders_shouldStillReportEmpty(@Autowired MockMvc mockMvc)
+ throws Exception {
+ // The filter makes the prefix a real context path here; the served value still ignores it.
+ mockMvc
+ .perform(
+ get(CONFIG)
+ .header("X-Forwarded-Prefix", "/evil")
+ .header("X-Forwarded-Host", "evil.example.com"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.backendUrl").value(""));
+ }
+ }
+}