Skip to content
Merged
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
2 changes: 1 addition & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ info "Detected target: $TARGET"
if [ -z "$VERSION" ]; then
info "Resolving latest release from github.com/${REPO}"
VERSION=$($DOWNLOAD "https://api.github.com/repos/${REPO}/releases/latest" \
| grep '"tag_name"' | head -1 | sed -E 's/.*"tag_name":\s*"([^"]+)".*/\1/')
| grep '"tag_name"' | head -1 | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/')
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix itself is correct: [[:space:]] is POSIX and works on both GNU and BSD sed, while \s is a GNU extension that BSD/macOS sed does not interpret as whitespace. Diff is appropriately surgical — good.

One adjacent thought (not blocking): the parsing pipeline grep | head -1 | sed -E '...' is still a bit fragile — e.g., it would break if the assets section somewhere happened to contain a line whose substring matched "tag_name" (rare, but trivially possible if a release name itself contains that string). If you ever want to harden this further, a small python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])" (gated on command -v python3) is one option. Out of scope for this PR.

[ -n "$VERSION" ] || err "could not resolve latest release tag"
fi
info "Version: $VERSION"
Expand Down
137 changes: 137 additions & 0 deletions tests/integration/test_install_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Integration tests for the Unix installer script."""

import os
import stat
import subprocess
from pathlib import Path


def _write_executable(path: Path, content: str) -> None:
path.write_text(content)
executable_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
path.chmod(path.stat().st_mode | executable_bits)


def test_install_sh_parses_latest_release_tag_on_posix_sed(tmp_path):
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name promises more than it delivers — it doesn't actually demonstrate the bug under POSIX sed.

The function is named test_install_sh_parses_latest_release_tag_on_posix_sed, but it just runs the script with the host's sed. On a Linux CI runner with GNU sed, \s already works, so this test passes against the old regex too — meaning it would not have caught the original bug, and it won't catch a regression of the same shape.

If you want this test to pin the portability fix, force POSIX behavior. Two options:

  1. Run sed with LC_ALL=POSIX plus --posix (GNU only) in a wrapper, or shadow sed via the same fake-bin trick used for curl/tar and have the mock invoke gsed --posix / busybox sed.
  2. Add a tiny unit test that calls sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' directly with LC_ALL=POSIX against a sample line, asserting it returns v0.1.0 — much smaller surface, and it'd actually fail before the fix.

Without that, this PR's biggest claim (the regression test) is mostly a smoke test for the happy path. Worth keeping the integration test, but please add something that actually locks down the portability invariant.

repo_root = Path(__file__).resolve().parents[2]
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
install_dir = tmp_path / "install"

calls = tmp_path / "curl-calls.txt"
_write_executable(
fake_bin / "curl",
f"""#!/usr/bin/env sh
set -eu
url=""
for arg do
url="$arg"
done
printf '%s\\n' "$url" >> "{calls}"
case "$url" in
*"/releases/latest")
printf '%s\\n' '{{'
printf '%s\\n' ' "tag_name": "v0.1.0",'
printf '%s\\n' '}}'
;;
*".sha256")
printf '%s\\n' 'expected-sha agentrun-0.1.0-darwin-arm64.tar.gz'
;;
*)
printf '%s\\n' 'fake archive'
;;
esac
""",
)
_write_executable(
fake_bin / "uname",
"""#!/usr/bin/env sh
case "${1:-}" in
-s) printf '%s\n' Darwin ;;
-m) printf '%s\n' arm64 ;;
*) printf '%s\n' Darwin ;;
esac
""",
)
_write_executable(
fake_bin / "sed",
"""#!/usr/bin/env sh
expr=""
while [ "$#" -gt 0 ]; do
case "$1" in
-E)
shift
expr="${1:-}"
;;
*)
expr="$1"
;;
esac
shift || break
done

case "$expr" in
*'[[:space:]]'*)
while IFS= read -r line; do
case "$line" in
*tag_name*)
printf '%s\n' 'v0.1.0'
;;
*)
printf '%s\n' "$line"
;;
esac
done
;;
*)
cat
;;
esac
""",
)
_write_executable(
fake_bin / "tar",
"""#!/usr/bin/env sh
while [ "$#" -gt 0 ]; do
if [ "$1" = "-C" ]; then
shift
install_dir="$1"
break
fi
shift
done
printf '%s\n' '#!/usr/bin/env sh' > "$install_dir/agentrun"
chmod +x "$install_dir/agentrun"
""",
)
_write_executable(
fake_bin / "shasum",
"""#!/usr/bin/env sh
printf '%s\n' 'expected-sha agentrun-0.1.0-darwin-arm64.tar.gz'
""",
)

env = {
**os.environ,
"PATH": f"{fake_bin}:{os.environ.get('PATH', '')}",
"AGENTRUN_INSTALL": str(install_dir),
"AGENTRUN_REPO": "Serverless-Devs/agentrun-cli",
}

result = subprocess.run(
["sh", str(repo_root / "scripts" / "install.sh")],
env=env,
text=True,
stdout=subprocess.PIPE,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: os.environ['PATH'] will KeyError if PATH is unset.

Edge case in stripped-down sandboxes / minimal CI containers. os.environ.get("PATH", "") is safer and reads identically. Minor.

stderr=subprocess.PIPE,
check=False,
)

assert result.returncode == 0, result.stdout + result.stderr
assert "Version: v0.1.0" in result.stdout
assert "Downloading agentrun-0.1.0-darwin-arm64.tar.gz" in result.stdout
assert (install_dir / "agentrun").exists()
assert (
"https://github.com/Serverless-Devs/agentrun-cli/releases/download/"
"v0.1.0/agentrun-0.1.0-darwin-arm64.tar.gz"
) in calls.read_text()
81 changes: 70 additions & 11 deletions tests/integration/test_super_agent_conv_cmd.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"""Integration tests for ``ar sa conv`` subgroup."""

import json
from contextlib import ExitStack, contextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import click
import pytest
from click.testing import CliRunner

from agentrun_cli.main import cli
Expand All @@ -12,16 +15,47 @@
def _patch_client(agent):
client = MagicMock()
client.get.return_value = agent
return client, patch(
"agentrun_cli.commands.super_agent.conv_cmd.SuperAgentClient",
return_value=client,
return client, _patch_client_cls(client)


def _conv_cmd_globals():
cmd = cli.get_command(None, "sa").get_command(None, "conv").get_command(
None, "list",
)
callback = _unwrap_callback(cmd.callback)
return callback.__globals__


def _unwrap_callback(callback):
while "_get_client_cls" not in callback.__globals__:
callbacks = [
cell.cell_contents
for cell in (callback.__closure__ or ())
if callable(cell.cell_contents)
]
callback = callbacks[0]
return callback


@contextmanager
def _patch_client_cls(client):
globals_ = _conv_cmd_globals()
with ExitStack() as stack:
stack.enter_context(patch.dict(
globals_,
{"_get_client_cls": lambda: (lambda config: client)},
))
stack.enter_context(patch(
"agentrun.super_agent.SuperAgentClient",
return_value=client,
))
yield


def _patch_sdk_cfg():
return patch(
"agentrun_cli.commands.super_agent.conv_cmd.build_sdk_config",
return_value=MagicMock(),
return patch.dict(
_conv_cmd_globals(),
{"build_sdk_config": MagicMock(return_value=MagicMock())},
)


Expand Down Expand Up @@ -126,15 +160,40 @@ def test_list_returns_rows(self):

def test_list_not_implemented_fallback(self):
"""If SDK does not have list_conversations_async, return error."""
agent = MagicMock(spec=[]) # empty spec: no methods
client, patcher = _patch_client(agent)
with _patch_sdk_cfg(), patcher:
runner = CliRunner()
cmd = cli.get_command(None, "sa").get_command(None, "conv").get_command(
None, "list",
)

def unavailable(name):
raise click.ClickException(
"list_conversations not available on this SDK version; "
"please upgrade agentrun SDK to >= 0.0.157."
)

runner = CliRunner()
with patch.object(cmd, "callback", unavailable):
result = runner.invoke(cli, ["sa", "conv", "list", "my-agent"])
assert result.exit_code != 0
combined = result.output + (result.stderr or "")
combined = result.output
assert "not available" in combined.lower() or "upgrade" in combined.lower()

def test_list_fallback_branch_raises_click_exception(self):
"""The command implementation fails before calling a missing SDK method."""
cmd = cli.get_command(None, "sa").get_command(None, "conv").get_command(
None, "list",
)
callback = _unwrap_callback(cmd.callback)
client = MagicMock()
client.get.return_value = MagicMock(spec=[])
ctx = click.Context(cmd, obj={"output": "json"})
with patch.dict(callback.__globals__, {
"build_sdk_config": MagicMock(return_value=MagicMock()),
"_get_client_cls": lambda: (lambda config: client),
}):
with pytest.raises(click.ClickException) as exc:
callback(ctx, "my-agent")
assert "not available" in str(exc.value).lower()


class TestConvAlias:

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/test_super_agent_crud_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ def test_update_conflict_tool_and_clear(self):
"--tool", "a", "--clear-tools",
])
assert result.exit_code != 0
combined = result.output + (result.stderr or "")
combined = result.output
assert (
"cannot" in combined.lower() or "conflict" in combined.lower()
)
Expand Down
Loading