Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,20 @@ RUN apt-get update && apt-get install -y \
maven \
&& rm -rf /var/lib/apt/lists/*

# Install Go 1.23 (latest stable)
RUN wget https://go.dev/dl/go1.23.5.linux-amd64.tar.gz \
&& tar -C /usr/local -xzf go1.23.5.linux-amd64.tar.gz \
&& rm go1.23.5.linux-amd64.tar.gz
ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64"
# Install Go 1.23 (latest stable). The tarball is arch-specific — `dpkg --print-architecture`
# yields amd64/arm64, which matches Go's naming — so this builds on both x86_64 CI and
# Apple-Silicon (arm64) hosts instead of installing an unrunnable amd64 binary on arm64.
RUN GOARCH="$(dpkg --print-architecture)" \
&& wget "https://go.dev/dl/go1.23.5.linux-${GOARCH}.tar.gz" \
&& tar -C /usr/local -xzf "go1.23.5.linux-${GOARCH}.tar.gz" \
&& rm "go1.23.5.linux-${GOARCH}.tar.gz"

# JAVA_HOME must point at the JDK dir, which Debian names with an arch suffix
# (java-21-openjdk-amd64 / -arm64). `java` on PATH works either way via update-alternatives,
# but sdkmanager/gradle read JAVA_HOME directly and fail hard if it's wrong — so pin a stable,
# arch-independent symlink and point JAVA_HOME at that (fixes sdkmanager on arm64 hosts).
RUN ln -s "/usr/lib/jvm/java-21-openjdk-$(dpkg --print-architecture)" /usr/lib/jvm/java-21-openjdk
ENV JAVA_HOME="/usr/lib/jvm/java-21-openjdk"
ENV PATH="${JAVA_HOME}/bin:${PATH}"

# Gradle — small JVM build-tool binary, installed like the other runtimes (Go/Java).
Expand Down
62 changes: 42 additions & 20 deletions backend/scripts/init-mobile-sdk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
# JDK, Gradle, Kotlin) are baked into the Docker image; only the large SDKs that are
# impractical to bake — the Android SDK and the Flutter SDK — live here.
#
# Idempotent per component: an already-provisioned SDK is detected and skipped, so this
# script is safe to re-run (e.g. after bumping a version below).
# Idempotent per component via a COMPLETION MARKER: each component writes its marker file
# only AFTER every one of its steps has succeeded. Because `set -e` aborts the script the
# moment any step fails, a component that fails partway (e.g. a transient download during the
# multi-GB Android package install) never gets its marker and is therefore RETRIED in full on
# the next run. Keying the skip on the marker — not on the first artifact a block happens to
# create (the sdkmanager binary / the flutter checkout, both of which exist before licenses and
# package install run) — is what makes a re-run actually finish a half-done install rather than
# skip it. The heavy downloads inside each block stay guarded so a retry reuses what's present.
set -eu

# ---------------------------------------------------------------------------
Expand All @@ -15,18 +21,23 @@ set -eu
ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-/workspaces/caches/common/android}"
ANDROID_SDK_CMDLINE_TOOLS_VERSION="${ANDROID_SDK_CMDLINE_TOOLS_VERSION:-11076708}"
SDKMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager"
ANDROID_MARKER="${ANDROID_SDK_ROOT}/.specflow-provisioned"

if [ -x "${SDKMANAGER}" ]; then
echo "Android SDK already exists in volume. Skipping download."
if [ -f "${ANDROID_MARKER}" ]; then
echo "Android SDK already provisioned. Skipping."
else
echo "Android SDK not found in volume. Downloading..."

mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_CMDLINE_TOOLS_VERSION}_latest.zip" -O /tmp/android-cmdline-tools.zip
unzip -q /tmp/android-cmdline-tools.zip -d /tmp/android-cmdline-tools
mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools/latest"
mv /tmp/android-cmdline-tools/cmdline-tools/* "${ANDROID_SDK_ROOT}/cmdline-tools/latest/"
rm -rf /tmp/android-cmdline-tools /tmp/android-cmdline-tools.zip
# cmdline-tools bootstrap is itself idempotent: download+extract only when sdkmanager is
# absent, so a retry after a failed package install reuses the already-extracted tools.
if [ ! -x "${SDKMANAGER}" ]; then
echo "Downloading Android cmdline-tools..."
mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"
wget -q "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_CMDLINE_TOOLS_VERSION}_latest.zip" -O /tmp/android-cmdline-tools.zip
rm -rf /tmp/android-cmdline-tools
unzip -q /tmp/android-cmdline-tools.zip -d /tmp/android-cmdline-tools
mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools/latest"
mv /tmp/android-cmdline-tools/cmdline-tools/* "${ANDROID_SDK_ROOT}/cmdline-tools/latest/"
rm -rf /tmp/android-cmdline-tools /tmp/android-cmdline-tools.zip
fi

echo "Accepting licenses and installing packages..."
yes | "${SDKMANAGER}" --sdk_root="${ANDROID_SDK_ROOT}" --licenses > /dev/null
Expand All @@ -36,7 +47,9 @@ else
#
# Several recent platform / build-tools versions are pre-installed so projects targeting
# different API levels find a match without each agent running sdkmanager against this
# SHARED, read-only SDK (which would race across concurrent workspaces).
# SHARED, read-only SDK (which would race across concurrent workspaces). sdkmanager is
# idempotent — already-installed packages are a fast no-op — so re-running this after a
# partial failure only fills the gaps.
"${SDKMANAGER}" --sdk_root="${ANDROID_SDK_ROOT}" \
"platform-tools" \
"build-tools;34.0.0" \
Expand All @@ -48,6 +61,7 @@ else
"cmake;3.22.1" \
"ndk;27.1.12297006" > /dev/null

touch "${ANDROID_MARKER}"
echo "Android SDK initialization complete."
fi

Expand All @@ -66,20 +80,28 @@ fi
# ---------------------------------------------------------------------------
FLUTTER_TEMPLATE_ROOT="${FLUTTER_TEMPLATE_ROOT:-/workspaces/caches/common/flutter}"
FLUTTER_VERSION="${FLUTTER_VERSION:-3.27.4}"
FLUTTER_MARKER="${FLUTTER_TEMPLATE_ROOT}/.specflow-provisioned"

if [ -x "${FLUTTER_TEMPLATE_ROOT}/bin/flutter" ]; then
echo "Flutter template already exists in volume. Skipping download."
if [ -f "${FLUTTER_MARKER}" ]; then
echo "Flutter template already provisioned. Skipping."
else
echo "Flutter template not found in volume. Downloading..."

mkdir -p "$(dirname "${FLUTTER_TEMPLATE_ROOT}")"
git clone --depth 1 --branch "${FLUTTER_VERSION}" https://github.com/flutter/flutter.git "${FLUTTER_TEMPLATE_ROOT}"
# Clone only when the checkout is absent/incomplete. `rm -rf` first so a retry after an
# interrupted clone doesn't fail on `git clone` into a non-empty dir; a good checkout from a
# prior run (failed later, at config/precache) is kept and reused.
if [ ! -x "${FLUTTER_TEMPLATE_ROOT}/bin/flutter" ]; then
echo "Downloading Flutter template..."
rm -rf "${FLUTTER_TEMPLATE_ROOT}"
mkdir -p "$(dirname "${FLUTTER_TEMPLATE_ROOT}")"
git clone --depth 1 --branch "${FLUTTER_VERSION}" https://github.com/flutter/flutter.git "${FLUTTER_TEMPLATE_ROOT}"
fi

# Bootstrap the bundled Dart SDK and pre-warm Android build artefacts so per-workspace
# copies do minimal first-run downloading; disable telemetry for unattended runs.
# copies do minimal first-run downloading; disable telemetry for unattended runs. Both are
# idempotent, so they re-run cleanly on a retry that reused an existing checkout.
"${FLUTTER_TEMPLATE_ROOT}/bin/flutter" config --no-analytics > /dev/null
"${FLUTTER_TEMPLATE_ROOT}/bin/flutter" precache --android > /dev/null

touch "${FLUTTER_MARKER}"
echo "Flutter template initialization complete."
fi

Expand Down
23 changes: 23 additions & 0 deletions mcp_server/services/local_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ def _container_names() -> tuple[str, str]:
return backend, firestore


def backend_container_name() -> str:
"""Name of the backend container (env override or compose default)."""
return _container_names()[0]


def containers_running(root: Path | None = None) -> bool:
"""True iff BOTH SpecFlow containers are currently running.

Expand Down Expand Up @@ -359,6 +364,24 @@ async def start_containers(root: Path, on_line: Callable[[str], None] | None = N
return await _stream_subprocess(["docker", "compose", "up", "-d", "--no-build"], root, on_line)


async def exec_in_backend(
root: Path,
in_container_argv: list[str],
on_line: Callable[[str], None] | None = None,
) -> int:
"""Run ``in_container_argv`` inside the running backend container, streamed.

Wraps ``docker exec <backend> <argv...>``. Used to invoke provisioning
scripts already baked into the image (e.g. ``init-mobile-sdk.sh``) without
reimplementing them client-side — the in-image script stays the single source
of truth for what gets installed. Returns the process exit code; a stopped
container or missing docker CLI surfaces through the streamed output and a
non-zero code.
"""
argv = ["docker", "exec", backend_container_name(), *in_container_argv]
return await _stream_subprocess(argv, root, on_line)


# ---------------------------------------------------------------------------
# specflow-init.sh wrapper
# ---------------------------------------------------------------------------
Expand Down
47 changes: 47 additions & 0 deletions mcp_server/tests/test_extra_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Tests for the extra-dependency script catalog (tui.extra_deps)."""

from tui.extra_deps import (
EXTRA_DEPENDENCY_SCRIPTS,
MOBILE_SDK_SCRIPT,
DependencyComponent,
ExtraDependencyScript,
script_by_key,
)


def test_version_summary_joins_components():
script = ExtraDependencyScript(
key="demo",
title="Demo",
description="d",
components=(
DependencyComponent("Foo", "1.2"),
DependencyComponent("Bar", "3.4"),
),
container_path="/usr/local/bin/x.sh",
)
assert script.version_summary == "Foo 1.2, Bar 3.4"


def test_command_is_sh_plus_container_path():
assert MOBILE_SDK_SCRIPT.command() == ["sh", "/usr/local/bin/init-mobile-sdk.sh"]


def test_mobile_sdk_advertises_android_and_flutter():
summary = MOBILE_SDK_SCRIPT.version_summary
assert "Android SDK" in summary
assert "Flutter" in summary


def test_script_by_key_roundtrip():
for script in EXTRA_DEPENDENCY_SCRIPTS:
assert script_by_key(script.key) is script


def test_script_by_key_unknown_returns_none():
assert script_by_key("does-not-exist") is None


def test_keys_are_unique():
keys = [script.key for script in EXTRA_DEPENDENCY_SCRIPTS]
assert len(keys) == len(set(keys))
24 changes: 24 additions & 0 deletions mcp_server/tests/test_local_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,30 @@ async def test_start_containers_argv(self, tmp_path):
]
assert exec_mock.call_args.kwargs["cwd"] == str(tmp_path)

@pytest.mark.asyncio
async def test_exec_in_backend_wraps_docker_exec(self, tmp_path, monkeypatch):
# docker exec <backend> <in-container argv...>, streamed, cwd = root.
monkeypatch.setenv("SPECFLOW_BACKEND_CONTAINER", "specflow-backend-test")
captured: list[str] = []
fake = _FakeProc([b"line 1\n"], code=0)
with patch(
"services.local_env.asyncio.create_subprocess_exec",
new=AsyncMock(return_value=fake),
) as exec_mock:
rc = await local_env.exec_in_backend(
tmp_path, ["sh", "/usr/local/bin/init-mobile-sdk.sh"], on_line=captured.append
)
assert rc == 0
assert captured == ["line 1\n"]
assert list(exec_mock.call_args.args) == [
"docker",
"exec",
"specflow-backend-test",
"sh",
"/usr/local/bin/init-mobile-sdk.sh",
]
assert exec_mock.call_args.kwargs["cwd"] == str(tmp_path)


class TestRunCommand:
"""run_command runs against real child processes — the point is to prove the
Expand Down
89 changes: 88 additions & 1 deletion mcp_server/tests/test_tui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from textual.widgets import Input
from textual.widgets import Button, Input, RichLog

import httpx
import pytest
Expand Down Expand Up @@ -801,6 +801,93 @@ async def test_partial_langfuse_save_is_blocked(self, tmp_path):
).read_text()


class TestExtraDependencies:
"""The Settings → Extra Dependencies section lists provisioning scripts and
installs them by `docker exec`-ing the in-image script."""

@staticmethod
def _land_on_sessions(tmp_path):
a, b, c = _gate_ready()
return (
a,
b,
c,
patch("tui.app.fetch_sessions", new=AsyncMock(return_value=[])),
patch.object(tui_app.ClientSetupScreen, "_probe_verifiable", new=AsyncMock()),
patch("tui.app.mcp_clients.is_any_client_connected", return_value=True),
)

@pytest.mark.asyncio
async def test_section_lists_script_with_versions_and_button(self, tmp_path):
a, b, c, d, e, f = self._land_on_sessions(tmp_path)
with a, b, c, d, e, f:
app = tui_app.SpecFlowTUI(root=tmp_path, generation_id=None, poll_interval=999)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("s")
await pilot.pause()
from tui.extra_deps import MOBILE_SDK_SCRIPT

button = app.screen.query_one(f"#extra-dep-{MOBILE_SDK_SCRIPT.key}", Button)
assert str(button.label) == "Install"
versions = " ".join(
str(s.render()) for s in app.screen.query(".extra-dep-versions").results()
)
assert "Android SDK" in versions
assert "Flutter" in versions

@pytest.mark.asyncio
async def test_install_click_runs_docker_exec(self, tmp_path):
a, b, c, d, e, f = self._land_on_sessions(tmp_path)
exec_mock = AsyncMock(return_value=0)
with (
a,
b,
c,
d,
e,
f,
patch("tui.app.local_env.containers_running", return_value=True),
patch("tui.app.local_env.exec_in_backend", new=exec_mock),
):
app = tui_app.SpecFlowTUI(root=tmp_path, generation_id=None, poll_interval=999)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("s")
await pilot.pause()
from tui.extra_deps import MOBILE_SDK_SCRIPT

# press() (not pilot.click) — the button sits below the fold of the
# scrolling settings form, so it isn't in the clickable viewport.
app.screen.query_one(f"#extra-dep-{MOBILE_SDK_SCRIPT.key}", Button).press()
await pilot.pause()
exec_mock.assert_awaited_once()
argv = exec_mock.await_args.args[1]
assert argv == MOBILE_SDK_SCRIPT.command()
log = app.screen.query_one("#extra-dep-log", RichLog)
assert log.display is True

@pytest.mark.asyncio
async def test_install_refuses_when_containers_down(self, tmp_path):
# The gate needs containers up to reach Settings; the refusal is checked
# by flipping `containers_running` to False only around the Install click.
a, b, c, d, e, f = self._land_on_sessions(tmp_path)
exec_mock = AsyncMock(return_value=0)
with a, b, c, d, e, f, patch("tui.app.local_env.exec_in_backend", new=exec_mock):
app = tui_app.SpecFlowTUI(root=tmp_path, generation_id=None, poll_interval=999)
async with app.run_test() as pilot:
await pilot.pause()
await pilot.press("s")
await pilot.pause()
from tui.extra_deps import MOBILE_SDK_SCRIPT

with patch("tui.app.local_env.containers_running", return_value=False):
app.screen.query_one(f"#extra-dep-{MOBILE_SDK_SCRIPT.key}", Button).press()
await pilot.pause()
# Nothing exec'd because the stack is down.
exec_mock.assert_not_awaited()


class TestQuitBinding:
@pytest.mark.asyncio
async def test_q_exits_dashboard(self):
Expand Down
Loading