chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,84 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_for_health(url: str, timeout: float = 8.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
r = httpx.get(f"{url}/v1/health", timeout=0.3)
|
||||
if r.status_code == 200:
|
||||
return
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"daemon at {url} did not start within {timeout}s")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daemon(tmp_path):
|
||||
"""Spin up a real daemon subprocess for the test, on a free port."""
|
||||
port = _free_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
env = dict(os.environ)
|
||||
env["MIRAGE_DAEMON_URL"] = url
|
||||
env["MIRAGE_IDLE_GRACE_SECONDS"] = "60"
|
||||
env["MIRAGE_HOME"] = str(tmp_path)
|
||||
env["MIRAGE_VERSION_ROOT"] = str(tmp_path / "repos")
|
||||
env["MIRAGE_SNAPSHOT_ROOT"] = str(tmp_path)
|
||||
env.pop("MIRAGE_AUTH_TOKEN", None)
|
||||
env.pop("MIRAGE_TOKEN", None)
|
||||
log_file = tmp_path / "daemon.log"
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"mirage.server.daemon:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--log-level",
|
||||
"warning",
|
||||
],
|
||||
env=env,
|
||||
stdout=open(log_file, "wb"),
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
_wait_for_health(url)
|
||||
yield {"url": url, "env": env, "log": log_file}
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
@@ -0,0 +1,398 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
"""
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(CONFIG_YAML, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _run_cli(env: dict,
|
||||
*args: str,
|
||||
stdin: bytes | None = None,
|
||||
expect_exit: int = 0) -> dict | list:
|
||||
cmd = [sys.executable, "-m", "mirage.cli.main", *args]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if proc.returncode != expect_exit:
|
||||
raise AssertionError(
|
||||
f"exit={proc.returncode} (expected {expect_exit})\n"
|
||||
f"stdout: {proc.stdout.decode()}\nstderr: {proc.stderr.decode()}")
|
||||
if expect_exit != 0:
|
||||
return {}
|
||||
if not proc.stdout.strip():
|
||||
return {}
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_workspace_lifecycle(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg))
|
||||
wid = created["id"]
|
||||
assert wid.startswith("ws_")
|
||||
|
||||
listed = _run_cli(daemon["env"], "workspace", "list")
|
||||
assert any(w["id"] == wid for w in listed)
|
||||
|
||||
detail = _run_cli(daemon["env"], "workspace", "get", wid)
|
||||
assert detail["id"] == wid
|
||||
|
||||
deleted = _run_cli(daemon["env"], "workspace", "delete", wid)
|
||||
assert deleted["id"] == wid
|
||||
|
||||
|
||||
def test_workspace_create_with_explicit_id(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"custom-ws")
|
||||
assert created["id"] == "custom-ws"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "custom-ws")
|
||||
|
||||
|
||||
def test_session_lifecycle(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"sess-test-ws")
|
||||
wid = created["id"]
|
||||
sess = _run_cli(daemon["env"], "session", "create", wid, "--id", "agent_a")
|
||||
assert sess["session_id"] == "agent_a"
|
||||
|
||||
listed = _run_cli(daemon["env"], "session", "list", wid)
|
||||
assert any(s["session_id"] == "agent_a" for s in listed)
|
||||
|
||||
_run_cli(daemon["env"], "session", "delete", wid, "agent_a")
|
||||
_run_cli(daemon["env"], "workspace", "delete", wid)
|
||||
|
||||
|
||||
def test_execute_returns_json_io_result(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"exec-test")
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id",
|
||||
created["id"], "--command", "echo hello")
|
||||
assert result["kind"] == "io"
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"].startswith("hello")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "exec-test")
|
||||
|
||||
|
||||
def test_execute_background_returns_job_id(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id", "bg-test")
|
||||
submitted = _run_cli(daemon["env"], "execute", "--workspace_id", "bg-test",
|
||||
"--command", "echo bg", "--background")
|
||||
job_id = submitted["job_id"]
|
||||
assert job_id.startswith("job_")
|
||||
|
||||
waited = _run_cli(daemon["env"], "job", "wait", job_id)
|
||||
assert waited["status"] == "done"
|
||||
assert waited["result"]["stdout"].startswith("bg")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "bg-test")
|
||||
|
||||
|
||||
def test_execute_with_stdin_pipe(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"stdin-test")
|
||||
result = _run_cli(daemon["env"],
|
||||
"execute",
|
||||
"--workspace_id",
|
||||
"stdin-test",
|
||||
"--command",
|
||||
"wc -l",
|
||||
stdin=b"a\nb\nc\n")
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"].strip().startswith("3")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "stdin-test")
|
||||
|
||||
|
||||
def test_save_then_load_round_trip(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"save-test")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "save-test",
|
||||
"--command", "echo persisted > /report.txt")
|
||||
|
||||
tar_path = tmp_path / "snap.tar"
|
||||
saved = _run_cli(daemon["env"], "workspace", "snapshot", "save-test",
|
||||
str(tar_path))
|
||||
assert saved["size"] > 0
|
||||
assert tar_path.exists()
|
||||
|
||||
loaded = _run_cli(daemon["env"], "workspace", "load", str(tar_path),
|
||||
str(cfg), "--id", "loaded-ws")
|
||||
assert loaded["id"] == "loaded-ws"
|
||||
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id", "loaded-ws",
|
||||
"--command", "cat /report.txt")
|
||||
assert "persisted" in result["stdout"]
|
||||
_run_cli(daemon["env"], "workspace", "delete", "save-test")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "loaded-ws")
|
||||
|
||||
|
||||
def test_workspace_clone_round_trip(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"clone-src")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "clone-src",
|
||||
"--command", "echo source > /report.txt")
|
||||
|
||||
cloned = _run_cli(daemon["env"], "workspace", "clone", "clone-src", "--id",
|
||||
"clone-dst")
|
||||
assert cloned["id"] == "clone-dst"
|
||||
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id", "clone-dst",
|
||||
"--command", "cat /report.txt")
|
||||
assert "source" in result["stdout"]
|
||||
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "clone-dst",
|
||||
"--command", "echo clone > /report.txt")
|
||||
original = _run_cli(daemon["env"], "execute", "--workspace_id",
|
||||
"clone-src", "--command", "cat /report.txt")
|
||||
assert "source" in original["stdout"]
|
||||
|
||||
_run_cli(daemon["env"], "workspace", "delete", "clone-src")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "clone-dst")
|
||||
|
||||
|
||||
def test_workspace_get_verbose_includes_internals(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"verbose-test")
|
||||
plain = _run_cli(daemon["env"], "workspace", "get", "verbose-test")
|
||||
assert plain["internals"] is None
|
||||
|
||||
verbose = _run_cli(daemon["env"], "workspace", "get", "verbose-test",
|
||||
"--verbose")
|
||||
assert verbose["internals"] is not None
|
||||
assert "cache_bytes" in verbose["internals"]
|
||||
_run_cli(daemon["env"], "workspace", "delete", "verbose-test")
|
||||
|
||||
|
||||
def test_unknown_workspace_returns_nonzero(daemon, tmp_path):
|
||||
_run_cli(daemon["env"],
|
||||
"workspace",
|
||||
"get",
|
||||
"ws_doesnotexist",
|
||||
expect_exit=2)
|
||||
|
||||
|
||||
def test_env_interpolation_uses_cli_environment(daemon, tmp_path):
|
||||
"""${VAR}s in the YAML must be resolved from the CLI's env, not the
|
||||
daemon's. The cross-mount workflow assumes the user sources their
|
||||
creds before running `mirage workspace create` -- the daemon
|
||||
process won't have those vars."""
|
||||
cfg = tmp_path / "interp.yaml"
|
||||
cfg.write_text(
|
||||
"mounts:\n"
|
||||
" /:\n"
|
||||
" resource: ram\n"
|
||||
" mode: ${MOUNT_MODE_FROM_ENV}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = {**daemon["env"], "MOUNT_MODE_FROM_ENV": "WRITE"}
|
||||
_run_cli(env, "workspace", "create", str(cfg), "--id", "interp-test")
|
||||
detail = _run_cli(env, "workspace", "get", "interp-test")
|
||||
assert any(m["mode"] == "write" for m in detail["mounts"])
|
||||
_run_cli(env, "workspace", "delete", "interp-test")
|
||||
|
||||
|
||||
def test_daemon_status_running(daemon, tmp_path):
|
||||
out = _run_cli(daemon["env"], "daemon", "status")
|
||||
assert out["running"] is True
|
||||
assert out["pid"] is not None
|
||||
assert out["workspaces"] == 0
|
||||
|
||||
|
||||
def test_daemon_stop_then_status_not_running(daemon, tmp_path):
|
||||
out = _run_cli(daemon["env"], "daemon", "stop")
|
||||
assert out["stopped"] is True
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
out = _run_cli(daemon["env"], "daemon", "status", expect_exit=1)
|
||||
assert out == {} or out.get("running") is False
|
||||
|
||||
|
||||
def test_provision_returns_dry_run_result(daemon, tmp_path):
|
||||
"""`mirage provision` hits /execute with provision=True and returns
|
||||
a {"kind": "provision", ...} payload instead of actually running."""
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"provision-test")
|
||||
result = _run_cli(
|
||||
daemon["env"],
|
||||
"provision",
|
||||
"--workspace_id",
|
||||
"provision-test",
|
||||
"--command",
|
||||
"echo would-not-run",
|
||||
)
|
||||
assert result["kind"] == "provision"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "provision-test")
|
||||
|
||||
|
||||
def test_execute_propagates_inner_exit_code(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"exit-test")
|
||||
|
||||
ok = _run_cli(daemon["env"], "execute", "-w", "exit-test", "-c", "echo ok")
|
||||
assert ok["exit_code"] == 0
|
||||
|
||||
_run_cli(daemon["env"],
|
||||
"execute",
|
||||
"-w",
|
||||
"exit-test",
|
||||
"-c",
|
||||
"false",
|
||||
expect_exit=1)
|
||||
|
||||
bg = _run_cli(daemon["env"], "execute", "-w", "exit-test", "-c", "false",
|
||||
"--background")
|
||||
job_id = bg["job_id"]
|
||||
_run_cli(daemon["env"], "job", "wait", job_id, expect_exit=1)
|
||||
|
||||
_run_cli(daemon["env"], "workspace", "delete", "exit-test")
|
||||
|
||||
|
||||
def test_execute_subshell_cwd_does_not_leak(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"subshell")
|
||||
_run_cli(daemon["env"], "execute", "-w", "subshell", "-c", "mkdir /sub")
|
||||
inside = _run_cli(daemon["env"], "execute", "-w", "subshell", "-c",
|
||||
"(cd /sub && pwd)")
|
||||
assert inside["stdout"].strip() == "/sub"
|
||||
after = _run_cli(daemon["env"], "execute", "-w", "subshell", "-c", "pwd")
|
||||
assert after["stdout"].strip() == "/"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "subshell")
|
||||
|
||||
|
||||
def test_execute_env_prefix_does_not_leak(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id", "envpref")
|
||||
inside = _run_cli(daemon["env"], "execute", "-w", "envpref", "-c",
|
||||
"(export FOO=bar; printenv FOO)")
|
||||
assert inside["stdout"].strip() == "bar"
|
||||
after = _run_cli(daemon["env"], "execute", "-w", "envpref", "-c",
|
||||
"printenv FOO || echo absent")
|
||||
assert after["stdout"].strip() == "absent"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "envpref")
|
||||
|
||||
|
||||
def test_execute_background_then_cancel(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"cancel-test")
|
||||
submitted = _run_cli(daemon["env"], "execute", "-w", "cancel-test", "-c",
|
||||
"sleep 30", "--background")
|
||||
job_id = submitted["job_id"]
|
||||
_run_cli(daemon["env"], "job", "cancel", job_id)
|
||||
waited = _run_cli(daemon["env"], "job", "wait", job_id, expect_exit=2)
|
||||
assert waited == {}
|
||||
_run_cli(daemon["env"], "workspace", "delete", "cancel-test")
|
||||
|
||||
|
||||
def test_missing_env_var_fails_fast_before_daemon_call(daemon, tmp_path):
|
||||
cfg = tmp_path / "missing.yaml"
|
||||
cfg.write_text(
|
||||
"mounts:\n"
|
||||
" /:\n"
|
||||
" resource: ram\n"
|
||||
" mode: ${THIS_VAR_IS_NOT_SET_ANYWHERE}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = {
|
||||
k: v
|
||||
for k, v in daemon["env"].items()
|
||||
if k != "THIS_VAR_IS_NOT_SET_ANYWHERE"
|
||||
}
|
||||
_run_cli(env, "workspace", "create", str(cfg), expect_exit=2)
|
||||
|
||||
|
||||
SAFEGUARD_TRUNCATE_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
command_safeguards:
|
||||
cat:
|
||||
max_lines: 2
|
||||
on_exceed: truncate
|
||||
"""
|
||||
|
||||
SAFEGUARD_ERROR_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
command_safeguards:
|
||||
cat:
|
||||
max_lines: 2
|
||||
on_exceed: error
|
||||
"""
|
||||
|
||||
_SEED_5_LINES = "printf '1\\n2\\n3\\n4\\n5\\n' > /f.txt"
|
||||
|
||||
|
||||
def _write_named(tmp_path: Path, name: str, text: str) -> Path:
|
||||
p = tmp_path / name
|
||||
p.write_text(text, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_execute_safeguard_truncates_output(daemon, tmp_path):
|
||||
cfg = _write_named(tmp_path, "sg_trunc.yaml", SAFEGUARD_TRUNCATE_YAML)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"sg-trunc")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "sg-trunc",
|
||||
"--command", _SEED_5_LINES)
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id", "sg-trunc",
|
||||
"--command", "cat /f.txt")
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == "1\n2\n"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "sg-trunc")
|
||||
|
||||
|
||||
def test_execute_safeguard_error_exits_1(daemon, tmp_path):
|
||||
cfg = _write_named(tmp_path, "sg_err.yaml", SAFEGUARD_ERROR_YAML)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id", "sg-err")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "sg-err", "--command",
|
||||
_SEED_5_LINES)
|
||||
_run_cli(daemon["env"],
|
||||
"execute",
|
||||
"--workspace_id",
|
||||
"sg-err",
|
||||
"--command",
|
||||
"cat /f.txt",
|
||||
expect_exit=1)
|
||||
_run_cli(daemon["env"], "workspace", "delete", "sg-err")
|
||||
@@ -0,0 +1,105 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cli.client import DaemonClient
|
||||
from mirage.cli.settings import DaemonSettings
|
||||
from mirage.server.daemon_config import DaemonConfigError
|
||||
from mirage.server.env import ENV_HOME
|
||||
|
||||
|
||||
def test_spawn_daemon_uses_mirage_home_for_log_and_token(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
spawned = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda *args, **kwargs: spawned.append(kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert spawned, "daemon process must be spawned"
|
||||
assert (tmp_path / "daemon.log").exists()
|
||||
assert (tmp_path / "auth_token").exists()
|
||||
assert client.settings.auth_token
|
||||
|
||||
|
||||
def test_spawn_daemon_rejects_bad_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\ntypo_key = "x"\n')
|
||||
spawned = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda *args, **kwargs: spawned.append(kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
with pytest.raises(DaemonConfigError, match="typo_key"):
|
||||
client._spawn_daemon()
|
||||
assert not spawned
|
||||
|
||||
|
||||
class _FakePopen:
|
||||
|
||||
def __init__(self, sink, cmd, **kwargs):
|
||||
sink.append(cmd)
|
||||
|
||||
|
||||
def test_spawn_port_config_beats_url(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
(tmp_path / "config.toml").write_text("[daemon]\nport = 9100\n")
|
||||
cmds = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda cmd, **kwargs: _FakePopen(cmds, cmd, **kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert "9100" in cmds[0]
|
||||
|
||||
|
||||
def test_spawn_port_env_beats_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.setenv("MIRAGE_DAEMON_PORT", "9200")
|
||||
(tmp_path / "config.toml").write_text("[daemon]\nport = 9100\n")
|
||||
cmds = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda cmd, **kwargs: _FakePopen(cmds, cmd, **kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert "9200" in cmds[0]
|
||||
|
||||
|
||||
def test_spawn_port_falls_back_to_url(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
cmds = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda cmd, **kwargs: _FakePopen(cmds, cmd, **kwargs))
|
||||
with DaemonClient(DaemonSettings(url="http://127.0.0.1:9331")) as client:
|
||||
client._spawn_daemon()
|
||||
assert "9331" in cmds[0]
|
||||
|
||||
|
||||
def test_spawn_respects_config_auth_mode(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\nauth_mode = "token"\n')
|
||||
spawned = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda *args, **kwargs: spawned.append(kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert "MIRAGE_AUTH_MODE" not in spawned[0]["env"]
|
||||
@@ -0,0 +1,105 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from mirage.cli.config import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _isolate_home(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_PID_FILE", raising=False)
|
||||
|
||||
|
||||
def test_config_set_then_get(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["set", "version_root", "/data/repos"])
|
||||
assert r.exit_code == 0
|
||||
r = runner.invoke(app, ["get", "version_root"])
|
||||
assert r.exit_code == 0
|
||||
assert "/data/repos" in r.stdout
|
||||
|
||||
|
||||
def test_config_list(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
runner.invoke(app, ["set", "url", "http://a:1"])
|
||||
r = runner.invoke(app, ["list"])
|
||||
assert r.exit_code == 0
|
||||
assert "url" in r.stdout and "http://a:1" in r.stdout
|
||||
|
||||
|
||||
def test_config_unset(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
runner.invoke(app, ["set", "socket", "/tmp/s.sock"])
|
||||
runner.invoke(app, ["unset", "socket"])
|
||||
r = runner.invoke(app, ["get", "socket"])
|
||||
assert r.exit_code != 0
|
||||
|
||||
|
||||
def test_config_get_unset_exits_nonzero(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["get", "pid_file"])
|
||||
assert r.exit_code != 0
|
||||
|
||||
|
||||
def test_config_set_rejects_unknown_key(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["set", "MIRAGE_HOME", "/x"])
|
||||
assert r.exit_code == 2
|
||||
|
||||
|
||||
def test_config_set_unknown_key_message_is_clean(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["set", "nope", "/x"])
|
||||
assert r.exit_code == 2
|
||||
assert "unknown config key" in r.output
|
||||
assert not r.output.strip().startswith("'")
|
||||
|
||||
|
||||
def test_config_list_malformed_toml_fails_cleanly(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
(tmp_path / "config.toml").write_text("[daemon\nnot toml")
|
||||
r = runner.invoke(app, ["list"])
|
||||
assert r.exit_code == 2
|
||||
assert "malformed" in r.output
|
||||
assert "Traceback" not in r.output
|
||||
|
||||
|
||||
def test_config_list_warns_on_unknown_keys(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\ntypo_key = "x"\n')
|
||||
r = runner.invoke(app, ["list"])
|
||||
assert r.exit_code == 0
|
||||
assert "typo_key" in r.output
|
||||
assert "unknown" in r.output.lower()
|
||||
|
||||
|
||||
def test_config_list_resolved_shows_origin(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("MIRAGE_VERSION_ROOT", "/env/repos")
|
||||
r = runner.invoke(app, ["list", "--resolved"])
|
||||
assert r.exit_code == 0
|
||||
assert "/env/repos" in r.output
|
||||
assert "MIRAGE_VERSION_ROOT" in r.output
|
||||
|
||||
|
||||
def test_config_list_resolved_masks_auth_token(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("MIRAGE_TOKEN", "supersecret")
|
||||
r = runner.invoke(app, ["list", "--resolved"])
|
||||
assert r.exit_code == 0
|
||||
assert "supersecret" not in r.output
|
||||
assert "***" in r.output
|
||||
@@ -0,0 +1,160 @@
|
||||
from mirage.cli.output import exit_code_from_response
|
||||
|
||||
|
||||
def test_io_zero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_io_nonzero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 1
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 42,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 42
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 127,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 127
|
||||
|
||||
|
||||
def test_clamp_high():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": 300}) == 255
|
||||
|
||||
|
||||
def test_clamp_negative():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": -1}) == 0
|
||||
|
||||
|
||||
def test_truncate_float():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": 1.9}) == 1
|
||||
|
||||
|
||||
def test_nan_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": float("nan")
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_inf_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": float("inf")
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_bool_returns_zero():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": True}) == 0
|
||||
|
||||
|
||||
def test_bg_submission():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_abc",
|
||||
"workspace_id": "ws",
|
||||
"submitted_at": 0,
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_provision_kind():
|
||||
assert exit_code_from_response({"kind": "provision", "detail": "ok"}) == 0
|
||||
|
||||
|
||||
def test_raw_kind():
|
||||
assert exit_code_from_response({"kind": "raw", "value": "hi"}) == 0
|
||||
|
||||
|
||||
def test_job_detail_done():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "done",
|
||||
"result": {
|
||||
"kind": "io",
|
||||
"exit_code": 7,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
"error": None,
|
||||
}) == 7
|
||||
|
||||
|
||||
def test_job_pending_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_job_running_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "running",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_job_failed_no_result_returns_two():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "failed",
|
||||
"result": None,
|
||||
"error": "boom",
|
||||
}) == 2
|
||||
|
||||
|
||||
def test_job_canceled_no_result_returns_two():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "canceled",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}) == 2
|
||||
|
||||
|
||||
def test_job_failed_with_result_prefers_inner():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "failed",
|
||||
"result": {
|
||||
"kind": "io",
|
||||
"exit_code": 9,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
"error": None,
|
||||
}) == 9
|
||||
|
||||
|
||||
def test_non_dict_inputs():
|
||||
assert exit_code_from_response(None) == 0
|
||||
assert exit_code_from_response("string") == 0
|
||||
assert exit_code_from_response(42) == 0
|
||||
assert exit_code_from_response([1, 2, 3]) == 0
|
||||
|
||||
|
||||
def test_io_missing_exit_code():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_io_non_numeric_exit_code():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": "one"}) == 0
|
||||
@@ -0,0 +1,102 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from mirage.cli import session as session_cli
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 201
|
||||
content = b'{"session_id": "agent", "cwd": "/"}'
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"session_id": "agent", "cwd": "/"}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last_body: dict | None = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def ensure_running(self, allow_spawn: bool = False) -> None:
|
||||
return None
|
||||
|
||||
def request(self, method: str, path: str, json: dict | None = None):
|
||||
self.last_body = json
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched_client(fake: _FakeClient):
|
||||
real = session_cli.make_client
|
||||
session_cli.make_client = lambda: fake
|
||||
try:
|
||||
yield fake
|
||||
finally:
|
||||
session_cli.make_client = real
|
||||
|
||||
|
||||
def test_session_create_passes_mount_flags():
|
||||
fake = _FakeClient()
|
||||
with _patched_client(fake):
|
||||
result = CliRunner().invoke(
|
||||
session_cli.app,
|
||||
[
|
||||
"create", "demo", "--id", "agent", "-m", "/s3", "--mount",
|
||||
"/slack"
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert fake.last_body == {
|
||||
"session_id": "agent",
|
||||
"allowed_mounts": ["/s3", "/slack"],
|
||||
}
|
||||
|
||||
|
||||
def test_session_create_no_mount_flag_omits_field():
|
||||
fake = _FakeClient()
|
||||
with _patched_client(fake):
|
||||
result = CliRunner().invoke(
|
||||
session_cli.app,
|
||||
["create", "demo", "--id", "agent"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert fake.last_body == {"session_id": "agent"}
|
||||
|
||||
|
||||
def test_session_create_without_id_or_mount_sends_empty_body():
|
||||
fake = _FakeClient()
|
||||
with _patched_client(fake):
|
||||
result = CliRunner().invoke(session_cli.app, ["create", "demo"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert fake.last_body == {}
|
||||
|
||||
|
||||
def test_session_create_help_lists_mount_flag():
|
||||
result = CliRunner().invoke(session_cli.app, ["create", "--help"])
|
||||
assert result.exit_code == 0
|
||||
plain = _ANSI_RE.sub("", result.output)
|
||||
assert "--mount" in plain
|
||||
@@ -0,0 +1,203 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cli.settings import (DEFAULT_DAEMON_URL, config_path, get_config,
|
||||
list_config, load_daemon_settings,
|
||||
resolved_config, set_config, unset_config)
|
||||
from mirage.server.daemon_config import DaemonConfigError
|
||||
from mirage.server.env import ENV_HOME
|
||||
|
||||
|
||||
def test_load_daemon_settings_falls_back_to_token_file(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
token_file = tmp_path / "auth_token"
|
||||
token_file.write_text("file-token\n")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == "file-token"
|
||||
|
||||
|
||||
def test_load_daemon_settings_env_wins_over_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_TOKEN", "from-env")
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
token_file = tmp_path / "auth_token"
|
||||
token_file.write_text("from-file")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == "from-env"
|
||||
|
||||
|
||||
def test_load_daemon_settings_config_wins_over_file(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text('[daemon]\nauth_token = "from-config"\n')
|
||||
token_file = tmp_path / "auth_token"
|
||||
token_file.write_text("from-file")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == "from-config"
|
||||
|
||||
|
||||
def test_load_daemon_settings_no_sources_yields_empty_token(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == ""
|
||||
|
||||
|
||||
def test_config_path_follows_mirage_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
assert config_path() == tmp_path / "config.toml"
|
||||
|
||||
|
||||
def test_load_daemon_settings_reads_config_under_mirage_home(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
(tmp_path /
|
||||
"config.toml").write_text('[daemon]\nurl = "http://127.0.0.1:9999"\n')
|
||||
settings = load_daemon_settings()
|
||||
assert settings.url == "http://127.0.0.1:9999"
|
||||
|
||||
|
||||
def test_set_config_creates_file(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("version_root", "/data/repos", path=p)
|
||||
assert get_config("version_root", path=p) == "/data/repos"
|
||||
assert '[daemon]' in p.read_text()
|
||||
|
||||
|
||||
def test_set_config_updates_existing_key(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("url", "http://a:1", path=p)
|
||||
set_config("url", "http://b:2", path=p)
|
||||
assert get_config("url", path=p) == "http://b:2"
|
||||
assert p.read_text().count("url =") == 1
|
||||
|
||||
|
||||
def test_set_config_preserves_comments_and_other_keys(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text('[daemon]\n# keep me\nurl = "http://a:1"\n')
|
||||
set_config("pid_file", "/tmp/x.pid", path=p)
|
||||
text = p.read_text()
|
||||
assert "# keep me" in text
|
||||
assert 'url = "http://a:1"' in text
|
||||
assert get_config("pid_file", path=p) == "/tmp/x.pid"
|
||||
|
||||
|
||||
def test_unset_config_removes_key(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("socket", "/tmp/s.sock", path=p)
|
||||
unset_config("socket", path=p)
|
||||
assert get_config("socket", path=p) is None
|
||||
|
||||
|
||||
def test_list_config_returns_written_keys(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("url", "http://a:1", path=p)
|
||||
set_config("snapshot_root", "/snaps", path=p)
|
||||
assert list_config(path=p) == {
|
||||
"url": "http://a:1",
|
||||
"snapshot_root": "/snaps"
|
||||
}
|
||||
|
||||
|
||||
def test_set_config_rejects_unknown_key(tmp_path):
|
||||
with pytest.raises(DaemonConfigError, match="unknown config key"):
|
||||
set_config("MIRAGE_HOME", "/x", path=tmp_path / "config.toml")
|
||||
|
||||
|
||||
def test_numeric_key_written_bare(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("idle_grace_seconds", "45", path=p)
|
||||
assert "idle_grace_seconds = 45" in p.read_text()
|
||||
|
||||
|
||||
def test_unset_config_accepts_unknown_key(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text('[daemon]\ntypo_key = "x"\nurl = "http://a:1"\n')
|
||||
unset_config("typo_key", path=p)
|
||||
assert "typo_key" not in p.read_text()
|
||||
assert 'url = "http://a:1"' in p.read_text()
|
||||
|
||||
|
||||
def test_set_config_chmods_0600(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("auth_token", "s3cret", path=p)
|
||||
assert (p.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_unset_config_chmods_0600(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text('[daemon]\nurl = "http://a:1"\nsocket = "/tmp/s"\n')
|
||||
unset_config("socket", path=p)
|
||||
assert (p.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_load_daemon_settings_missing_explicit_path_returns_defaults(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
settings = load_daemon_settings(path=tmp_path / "nope.toml")
|
||||
assert settings.url == DEFAULT_DAEMON_URL
|
||||
|
||||
|
||||
def test_resolved_config_reports_origins(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.setenv("MIRAGE_VERSION_ROOT", "/env/repos")
|
||||
monkeypatch.delenv("MIRAGE_SNAPSHOT_ROOT", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
(tmp_path / "config.toml").write_text(
|
||||
'[daemon]\nversion_root = "/file/repos"\nurl = "http://f:1"\n')
|
||||
resolved = resolved_config()
|
||||
assert resolved["version_root"] == ("/env/repos",
|
||||
"env MIRAGE_VERSION_ROOT")
|
||||
assert resolved["url"] == ("http://f:1", "file")
|
||||
assert resolved["snapshot_root"] == (str(tmp_path / "snapshots"),
|
||||
"default")
|
||||
|
||||
|
||||
def test_resolved_config_defaults_when_nothing_set(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
for name in ("MIRAGE_DAEMON_URL", "MIRAGE_TOKEN", "MIRAGE_PID_FILE",
|
||||
"MIRAGE_VERSION_ROOT", "MIRAGE_SNAPSHOT_ROOT",
|
||||
"MIRAGE_IDLE_GRACE_SECONDS"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
resolved = resolved_config()
|
||||
assert resolved["url"] == (DEFAULT_DAEMON_URL, "default")
|
||||
assert resolved["pid_file"] == (str(tmp_path / "daemon.pid"), "default")
|
||||
assert resolved["idle_grace_seconds"] == ("30", "default")
|
||||
|
||||
|
||||
def test_resolved_config_includes_port(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
resolved = resolved_config()
|
||||
assert resolved["port"] == ("8765", "default")
|
||||
monkeypatch.setenv("MIRAGE_DAEMON_PORT", "9100")
|
||||
assert resolved_config()["port"] == ("9100", "env MIRAGE_DAEMON_PORT")
|
||||
@@ -0,0 +1,202 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# 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.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
"""
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(CONFIG_YAML, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _run(env: dict, *args: str, expect_exit: int = 0) -> dict | list:
|
||||
cmd = [sys.executable, "-m", "mirage.cli.main", *args]
|
||||
proc = subprocess.run(cmd, env=env, capture_output=True, timeout=30)
|
||||
if proc.returncode != expect_exit:
|
||||
raise AssertionError(
|
||||
f"exit={proc.returncode} (expected {expect_exit})\n"
|
||||
f"stdout: {proc.stdout.decode()}\nstderr: {proc.stderr.decode()}")
|
||||
if expect_exit != 0 or not proc.stdout.strip():
|
||||
return {}
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_commit_log_checkout_clone(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo v1 > /notes.txt")
|
||||
v1 = _run(env, "workspace", "commit", wid, "-m", "first")["version"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo v2 > /notes.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "second")
|
||||
|
||||
log = _run(env, "workspace", "log", wid)
|
||||
assert [e["message"] for e in log] == ["second", "first"]
|
||||
|
||||
_run(env, "workspace", "checkout", wid, v1)
|
||||
reverted = _run(env, "execute", "-w", wid, "-c", "cat /notes.txt")
|
||||
assert reverted["stdout"] == "v1\n"
|
||||
|
||||
clone = _run(env, "workspace", "clone", wid, "--at", v1)
|
||||
assert clone["id"] != wid
|
||||
|
||||
|
||||
def test_log_empty_before_commit(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
assert _run(env, "workspace", "log", wid) == []
|
||||
|
||||
|
||||
def test_diff_versions_and_live(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
v1 = _run(env, "workspace", "commit", wid, "-m", "first")["version"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo new > /b.txt")
|
||||
v2 = _run(env, "workspace", "commit", wid, "-m", "second")["version"]
|
||||
|
||||
by_version = _run(env, "workspace", "diff", wid, v1, v2)
|
||||
assert by_version["modified"] == ["a.txt"]
|
||||
assert by_version["added"] == ["b.txt"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo three > /a.txt")
|
||||
live = _run(env, "workspace", "diff", wid)
|
||||
assert live["modified"] == ["a.txt"]
|
||||
|
||||
|
||||
def test_branch_diverges_and_guards_commit(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-b", "exp", "-m", "on exp")
|
||||
|
||||
exp_log = _run(env, "workspace", "log", wid, "-b", "exp")
|
||||
main_log = _run(env, "workspace", "log", wid, "-b", "main")
|
||||
assert [e["message"] for e in exp_log] == ["on exp", "first"]
|
||||
assert [e["message"] for e in main_log] == ["first"]
|
||||
|
||||
_run(env,
|
||||
"workspace",
|
||||
"commit",
|
||||
wid,
|
||||
"-b",
|
||||
"ghost",
|
||||
"-m",
|
||||
"x",
|
||||
expect_exit=2)
|
||||
|
||||
|
||||
def test_diff_includes_deleted(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /b.txt")
|
||||
v1 = _run(env, "workspace", "commit", wid, "-m", "first")["version"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "rm /b.txt")
|
||||
v2 = _run(env, "workspace", "commit", wid, "-m", "second")["version"]
|
||||
|
||||
changes = _run(env, "workspace", "diff", wid, v1, v2)
|
||||
assert changes["deleted"] == ["b.txt"]
|
||||
|
||||
|
||||
def test_clone_live_and_explicit_id(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
_run(env, "execute", "-w", wid, "-c", "echo hello > /a.txt")
|
||||
|
||||
auto = _run(env, "workspace", "clone", wid)
|
||||
assert auto["id"] != wid
|
||||
|
||||
named = _run(env, "workspace", "clone", wid, "--id", "myclone")
|
||||
assert named["id"] == "myclone"
|
||||
got = _run(env, "execute", "-w", "myclone", "-c", "cat /a.txt")
|
||||
assert got["stdout"] == "hello\n"
|
||||
|
||||
|
||||
def test_branch_from_non_main(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-b", "exp", "-m", "on exp")
|
||||
|
||||
_run(env, "workspace", "branch", wid, "exp2", "--from", "exp")
|
||||
log = _run(env, "workspace", "log", wid, "-b", "exp2")
|
||||
assert [e["message"] for e in log] == ["on exp", "first"]
|
||||
|
||||
|
||||
def test_checkout_by_branch_name(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-b", "exp", "-m", "on exp")
|
||||
|
||||
_run(env, "workspace", "checkout", wid, "main")
|
||||
on_main = _run(env, "execute", "-w", wid, "-c", "cat /a.txt")
|
||||
assert on_main["stdout"] == "one\n"
|
||||
|
||||
_run(env, "workspace", "checkout", wid, "exp")
|
||||
on_exp = _run(env, "execute", "-w", wid, "-c", "cat /a.txt")
|
||||
assert on_exp["stdout"] == "two\n"
|
||||
|
||||
|
||||
def test_error_paths_exit_2(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
|
||||
_run(env, "workspace", "branch", wid, "exp", expect_exit=2)
|
||||
_run(env, "workspace", "commit", "ghost-ws", "-m", "x", expect_exit=2)
|
||||
_run(env, "workspace", "checkout", wid, "nope", expect_exit=2)
|
||||
_run(env, "workspace", "diff", wid, "nope", "nope2", expect_exit=2)
|
||||
Reference in New Issue
Block a user