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,85 @@
|
||||
# ========= 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 import MountMode, RAMResource, Workspace
|
||||
|
||||
|
||||
async def _workspace() -> Workspace:
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("mkdir -p /data/sub")
|
||||
await ws.execute("tee /data/plain.txt > /dev/null", stdin=b"y\n")
|
||||
await ws.execute("cd /data")
|
||||
return ws
|
||||
|
||||
|
||||
async def _rc(ws: Workspace, cmd: str) -> int:
|
||||
io = await ws.execute(cmd)
|
||||
return io.exit_code
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f_relative_resolves_against_cwd():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, "test -f plain.txt") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f_relative_missing():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, "test -f missing.txt") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f_relative_with_dotdot():
|
||||
ws = await _workspace()
|
||||
await ws.execute("cd /data/sub")
|
||||
assert await _rc(ws, "test -f ../plain.txt") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_d_relative_resolves_against_cwd():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, "test -d sub") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_d_relative_missing():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, "test -d nosuch") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f_absolute_unchanged():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, "test -f /data/plain.txt") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_f_empty_operand_false():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, 'test -f ""') == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bracket_form_relative():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, "[ -f plain.txt ]") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negation_relative():
|
||||
ws = await _workspace()
|
||||
assert await _rc(ws, "test ! -f missing.txt") == 0
|
||||
@@ -0,0 +1,55 @@
|
||||
# ========= 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 time
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.workspace.executor.builtins.script import handle_sleep
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sleep_missing_operand_exits_1():
|
||||
_, io, node = await handle_sleep([])
|
||||
assert io.exit_code == 1
|
||||
assert io.stderr == b"sleep: missing operand\n"
|
||||
assert node.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
["abc", "-1", "inf", "Infinity", "nan", "NaN", "0x10", "1_0", "1e309", ""])
|
||||
async def test_sleep_invalid_interval_exits_1(raw):
|
||||
_, io, node = await handle_sleep([raw])
|
||||
assert io.exit_code == 1
|
||||
assert io.stderr == f"sleep: invalid time interval '{raw}'\n".encode()
|
||||
assert node.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("raw", ["0", "0.", ".01", "+0.01", "1e-3"])
|
||||
async def test_sleep_valid_interval_exits_0(raw):
|
||||
_, io, node = await handle_sleep([raw])
|
||||
assert io.exit_code == 0
|
||||
assert not io.stderr
|
||||
assert node.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sleep_zero_returns_promptly():
|
||||
start = time.monotonic()
|
||||
_, io, _ = await handle_sleep(["0"])
|
||||
assert io.exit_code == 0
|
||||
assert time.monotonic() - start < 0.05
|
||||
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
|
||||
from mirage.workspace.executor.builtins.text import handle_echo
|
||||
|
||||
|
||||
async def echo_bytes(args: list[str]) -> bytes:
|
||||
out, io, _ = await handle_echo(args)
|
||||
assert io.exit_code == 0
|
||||
assert isinstance(out, bytes)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_words_join_with_newline():
|
||||
assert await echo_bytes(["hi", "there"]) == b"hi there\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leading_n_suppresses_newline():
|
||||
assert await echo_bytes(["-n", "hi"]) == b"hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trailing_n_prints_literally():
|
||||
assert await echo_bytes(["hi", "-n"]) == b"hi -n\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_ne():
|
||||
assert await echo_bytes(["-ne", "a\\tb"]) == b"a\tb"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capital_e_disables_escapes():
|
||||
assert await echo_bytes(["-e", "-E", "a\\tb"]) == b"a\\tb\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_of_e_and_E_wins_within_cluster():
|
||||
assert await echo_bytes(["-eE", "a\\tb"]) == b"a\\tb\n"
|
||||
assert await echo_bytes(["-Ee", "a\\tb"]) == b"a\tb\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_char_makes_word_literal():
|
||||
assert await echo_bytes(["-nq", "hi"]) == b"-nq hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_option_after_operand_is_literal():
|
||||
assert await echo_bytes(["hi", "-e", "a\\tb"]) == b"hi -e a\\tb\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lone_dash_is_literal():
|
||||
assert await echo_bytes(["-"]) == b"-\n"
|
||||
@@ -0,0 +1,99 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import materialize
|
||||
from mirage.workspace.executor.builtins.timeout import (handle_timeout,
|
||||
parse_duration)
|
||||
from mirage.workspace.session.session import Session
|
||||
|
||||
|
||||
class FakeShell:
|
||||
|
||||
def __init__(self, delay: float = 0.0, exit_code: int = 0):
|
||||
self.lines: list[str] = []
|
||||
self.delay = delay
|
||||
self.exit_code = exit_code
|
||||
|
||||
async def __call__(self, line: str, session_id: str) -> IOResult:
|
||||
self.lines.append(line)
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
return IOResult(stdout=b"done\n", exit_code=self.exit_code)
|
||||
|
||||
|
||||
def make_session() -> Session:
|
||||
return Session(session_id="s1")
|
||||
|
||||
|
||||
def test_parse_duration_units():
|
||||
assert parse_duration("1") == 1.0
|
||||
assert parse_duration("0.5") == 0.5
|
||||
assert parse_duration("2s") == 2.0
|
||||
assert parse_duration("2m") == 120.0
|
||||
assert parse_duration("1h") == 3600.0
|
||||
assert parse_duration("1d") == 86400.0
|
||||
assert parse_duration(".5") == 0.5
|
||||
|
||||
|
||||
def test_parse_duration_rejects_garbage():
|
||||
assert parse_duration("xx") is None
|
||||
assert parse_duration("-1") is None
|
||||
assert parse_duration("1x") is None
|
||||
assert parse_duration("") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_finishing_in_time_passes_through():
|
||||
shell = FakeShell(exit_code=3)
|
||||
stdout, io, _ = await handle_timeout(shell, ["5", "wc", "-l"],
|
||||
make_session())
|
||||
assert shell.lines == ["wc -l"]
|
||||
assert io.exit_code == 3
|
||||
assert stdout == b"done\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overrun_exits_124():
|
||||
shell = FakeShell(delay=1.0)
|
||||
_, io, node = await handle_timeout(shell, ["0.05", "sleep", "1"],
|
||||
make_session())
|
||||
assert io.exit_code == 124
|
||||
assert node.exit_code == 124
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_duration_exits_125():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_timeout(shell, ["xx", "sleep", "1"],
|
||||
make_session())
|
||||
assert io.exit_code == 125
|
||||
assert (await
|
||||
materialize(io.stderr)) == b"timeout: invalid time interval 'xx'\n"
|
||||
assert shell.lines == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_operand_exits_125():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_timeout(shell, ["5"], make_session())
|
||||
assert io.exit_code == 125
|
||||
assert await materialize(io.stderr) == b"timeout: missing operand\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_option_rejected():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_timeout(shell, ["-s", "KILL", "1", "sleep", "3"],
|
||||
make_session())
|
||||
assert io.exit_code == 125
|
||||
assert (await
|
||||
materialize(io.stderr)) == b"timeout: unsupported option -- '-s'\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quoting_survives_rejoin():
|
||||
shell = FakeShell()
|
||||
await handle_timeout(shell, ["1", "grep", "a b", "f.txt"], make_session())
|
||||
assert shell.lines == ["grep 'a b' f.txt"]
|
||||
@@ -0,0 +1,76 @@
|
||||
import pytest
|
||||
|
||||
from mirage.io.stream import materialize
|
||||
from mirage.workspace.executor.builtins.vars import (handle_read,
|
||||
handle_return,
|
||||
handle_shift)
|
||||
from mirage.workspace.executor.control import ReturnSignal
|
||||
from mirage.workspace.session.session import Session
|
||||
|
||||
|
||||
def make_session() -> Session:
|
||||
return Session(session_id="s1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_non_numeric_errors_like_bash():
|
||||
_, io, _ = await handle_shift(["x"], None, session=make_session())
|
||||
assert io.exit_code == 1
|
||||
assert (await
|
||||
materialize(io.stderr)) == b"shift: x: numeric argument required\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_too_many_arguments():
|
||||
_, io, _ = await handle_shift(["1", "2"], None, session=make_session())
|
||||
assert io.exit_code == 1
|
||||
assert await materialize(io.stderr) == b"shift: too many arguments\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shift_default_one():
|
||||
session = make_session()
|
||||
session.positional_args = ["a", "b"]
|
||||
_, io, _ = await handle_shift([], None, session=session)
|
||||
assert io.exit_code == 0
|
||||
assert session.positional_args == ["b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_non_numeric_raises_2_with_message():
|
||||
with pytest.raises(ReturnSignal) as exc:
|
||||
await handle_return(["x"])
|
||||
assert exc.value.exit_code == 2
|
||||
assert exc.value.stderr == b"return: x: numeric argument required\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_numeric():
|
||||
with pytest.raises(ReturnSignal) as exc:
|
||||
await handle_return(["7"])
|
||||
assert exc.value.exit_code == 7
|
||||
assert exc.value.stderr == b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_invalid_option_exits_2():
|
||||
_, io, _ = await handle_read(["-q", "v"], make_session(), b"line\n")
|
||||
assert io.exit_code == 2
|
||||
assert await materialize(io.stderr) == b"read: -q: invalid option\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_dash_r_consumed_not_a_variable():
|
||||
session = make_session()
|
||||
_, io, _ = await handle_read(["-r", "v"], session, b"hello world\n")
|
||||
assert io.exit_code == 0
|
||||
assert session.env["v"] == "hello world"
|
||||
assert "-r" not in session.env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_defaults_to_reply():
|
||||
session = make_session()
|
||||
_, io, _ = await handle_read([], session, b"hi\n")
|
||||
assert io.exit_code == 0
|
||||
assert session.env["REPLY"] == "hi"
|
||||
@@ -0,0 +1,124 @@
|
||||
import pytest
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.io.stream import materialize
|
||||
from mirage.workspace.executor.builtins.xargs import handle_xargs
|
||||
from mirage.workspace.session.session import Session
|
||||
|
||||
|
||||
class FakeShell:
|
||||
|
||||
def __init__(self, exit_codes: list[int] | None = None):
|
||||
self.lines: list[str] = []
|
||||
self.exit_codes = exit_codes or []
|
||||
|
||||
async def __call__(self, line: str, session_id: str) -> IOResult:
|
||||
self.lines.append(line)
|
||||
code = (self.exit_codes[len(self.lines) - 1]
|
||||
if len(self.lines) <= len(self.exit_codes) else 0)
|
||||
return IOResult(stdout=f"ran:{line}\n".encode(), exit_code=code)
|
||||
|
||||
|
||||
def make_session() -> Session:
|
||||
return Session(session_id="s1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batches_one_arg_per_run_with_n1():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_xargs(shell, ["-n1", "echo"], make_session(),
|
||||
b"a b c")
|
||||
assert shell.lines == ["echo a", "echo b", "echo c"]
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_run_without_n():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_xargs(shell, ["echo"], make_session(), b"a b c")
|
||||
assert shell.lines == ["echo a b c"]
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failing_invocation_exits_123_but_continues():
|
||||
shell = FakeShell(exit_codes=[1, 0])
|
||||
_, io, _ = await handle_xargs(shell, ["-n1", "wc"], make_session(), b"a b")
|
||||
assert shell.lines == ["wc a", "wc b"]
|
||||
assert io.exit_code == 123
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_not_found_stops_with_127():
|
||||
shell = FakeShell(exit_codes=[127, 0])
|
||||
_, io, _ = await handle_xargs(shell, ["-n1", "nope"], make_session(),
|
||||
b"a b")
|
||||
assert shell.lines == ["nope a"]
|
||||
assert io.exit_code == 127
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_run_if_empty():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_xargs(shell, ["-r", "echo", "hi"], make_session(),
|
||||
b"")
|
||||
assert shell.lines == []
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_input_without_r_runs_once():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_xargs(shell, ["echo", "hi"], make_session(), b"")
|
||||
assert shell.lines == ["echo hi"]
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_delimited_input():
|
||||
shell = FakeShell()
|
||||
await handle_xargs(shell, ["-0", "echo"], make_session(), b"a b\0c\0")
|
||||
assert shell.lines == ["echo 'a b' c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_delimiter():
|
||||
shell = FakeShell()
|
||||
await handle_xargs(shell, ["-d,", "echo"], make_session(), b"a,b,c")
|
||||
assert shell.lines == ["echo a b c"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_option_exits_1():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_xargs(shell, ["-q", "echo"], make_session(), b"x")
|
||||
assert io.exit_code == 1
|
||||
assert await materialize(io.stderr) == b"xargs: invalid option -- 'q'\n"
|
||||
assert shell.lines == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_option_exits_1():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_xargs(shell, ["-I", "{}", "echo"], make_session(),
|
||||
b"x")
|
||||
assert io.exit_code == 1
|
||||
assert await materialize(io.stderr
|
||||
) == b"xargs: unsupported option -- 'I'\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_n_zero_rejected():
|
||||
shell = FakeShell()
|
||||
_, io, _ = await handle_xargs(shell, ["-n0", "echo"], make_session(), b"x")
|
||||
assert io.exit_code == 1
|
||||
assert (await
|
||||
materialize(io.stderr
|
||||
)) == b"xargs: value 0 for -n option should be >= 1\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_input_words_stay_single_tokens():
|
||||
shell = FakeShell()
|
||||
await handle_xargs(shell, ["echo"], make_session(), b"don't $(reboot)")
|
||||
assert shell.lines == ["echo 'don'\"'\"'t' '$(reboot)'"]
|
||||
@@ -0,0 +1,99 @@
|
||||
# ========= 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 mirage.workspace.executor.builtins import _interpret_escapes
|
||||
|
||||
|
||||
def test_newline():
|
||||
assert _interpret_escapes("a\\nb") == "a\nb"
|
||||
|
||||
|
||||
def test_tab():
|
||||
assert _interpret_escapes("a\\tb") == "a\tb"
|
||||
|
||||
|
||||
def test_carriage_return():
|
||||
assert _interpret_escapes("\\r") == "\r"
|
||||
|
||||
|
||||
def test_bell():
|
||||
assert _interpret_escapes("\\a") == "\a"
|
||||
|
||||
|
||||
def test_backspace():
|
||||
assert _interpret_escapes("\\b") == "\b"
|
||||
|
||||
|
||||
def test_form_feed():
|
||||
assert _interpret_escapes("\\f") == "\f"
|
||||
|
||||
|
||||
def test_vertical_tab():
|
||||
assert _interpret_escapes("\\v") == "\v"
|
||||
|
||||
|
||||
def test_literal_backslash():
|
||||
assert _interpret_escapes("a\\\\b") == "a\\b"
|
||||
|
||||
|
||||
def test_double_backslash_before_n():
|
||||
assert _interpret_escapes("\\\\n") == "\\n"
|
||||
|
||||
|
||||
def test_double_backslash_before_b():
|
||||
assert _interpret_escapes("a\\\\b") == "a\\b"
|
||||
|
||||
|
||||
def test_hex_escape():
|
||||
assert _interpret_escapes("\\x41") == "A"
|
||||
|
||||
|
||||
def test_hex_single_digit():
|
||||
assert _interpret_escapes("\\x9") == "\t"
|
||||
|
||||
|
||||
def test_hex_no_digits():
|
||||
assert _interpret_escapes("\\x") == "\\x"
|
||||
|
||||
|
||||
def test_octal_escape():
|
||||
assert _interpret_escapes("\\0101") == "A"
|
||||
|
||||
|
||||
def test_octal_null():
|
||||
assert _interpret_escapes("\\0") == "\0"
|
||||
|
||||
|
||||
def test_stop_output():
|
||||
assert _interpret_escapes("hello\\cworld") == "hello"
|
||||
|
||||
|
||||
def test_unknown_escape_passthrough():
|
||||
assert _interpret_escapes("\\z") == "\\z"
|
||||
|
||||
|
||||
def test_no_escapes():
|
||||
assert _interpret_escapes("hello world") == "hello world"
|
||||
|
||||
|
||||
def test_empty():
|
||||
assert _interpret_escapes("") == ""
|
||||
|
||||
|
||||
def test_trailing_backslash():
|
||||
assert _interpret_escapes("end\\") == "end\\"
|
||||
|
||||
|
||||
def test_mixed():
|
||||
assert _interpret_escapes("a\\tb\\nc\\\\d") == "a\tb\nc\\d"
|
||||
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.executor.fanout import (_adjust_depth_texts,
|
||||
_synthesize_find_mount_entries)
|
||||
|
||||
|
||||
def _mounts(*prefixes):
|
||||
return [SimpleNamespace(prefix=p) for p in prefixes]
|
||||
|
||||
|
||||
def test_synthesize_no_expression_emits_all():
|
||||
desc = _mounts("/ram/", "/disk/")
|
||||
assert _synthesize_find_mount_entries("/", desc, []) == "/ram\n/disk"
|
||||
|
||||
|
||||
def test_synthesize_positive_name():
|
||||
desc = _mounts("/ram/", "/disk/")
|
||||
assert _synthesize_find_mount_entries("/", desc,
|
||||
["-name", "ram"]) == "/ram"
|
||||
|
||||
|
||||
def test_synthesize_honors_not():
|
||||
desc = _mounts("/ram/", "/disk/", "/notes/")
|
||||
out = _synthesize_find_mount_entries("/", desc, ["-not", "-name", "ram"])
|
||||
assert out == "/disk\n/notes"
|
||||
|
||||
|
||||
def test_synthesize_honors_or():
|
||||
desc = _mounts("/ram/", "/disk/", "/notes/")
|
||||
out = _synthesize_find_mount_entries(
|
||||
"/", desc, ["-name", "ram", "-o", "-name", "disk"])
|
||||
assert out == "/ram\n/disk"
|
||||
|
||||
|
||||
def test_synthesize_type_file_excludes_mount_dirs():
|
||||
desc = _mounts("/ram/", "/disk/")
|
||||
assert _synthesize_find_mount_entries("/", desc, ["-type", "f"]) == ""
|
||||
|
||||
|
||||
def test_synthesize_type_dir_includes_mount_dirs():
|
||||
desc = _mounts("/ram/", "/disk/")
|
||||
assert _synthesize_find_mount_entries("/", desc,
|
||||
["-type", "d"]) == "/ram\n/disk"
|
||||
|
||||
|
||||
def test_synthesize_maxdepth_window():
|
||||
desc = _mounts("/ram/", "/a/b/")
|
||||
assert _synthesize_find_mount_entries("/", desc,
|
||||
["-maxdepth", "1"]) == "/ram"
|
||||
|
||||
|
||||
def test_adjust_depth_texts_reduces_maxdepth_by_delta():
|
||||
out = _adjust_depth_texts(["-maxdepth", "3", "-name", "x"], "/",
|
||||
"/data/sub")
|
||||
assert out == ["-maxdepth", "1", "-name", "x"]
|
||||
|
||||
|
||||
def test_adjust_depth_texts_clamps_mindepth_at_zero():
|
||||
out = _adjust_depth_texts(["-mindepth", "1"], "/", "/data")
|
||||
assert out == ["-mindepth", "0"]
|
||||
|
||||
|
||||
def test_adjust_depth_texts_no_depth_tokens_unchanged():
|
||||
out = _adjust_depth_texts(["-name", "x", "-o", "-name", "y"], "/", "/data")
|
||||
assert out == ["-name", "x", "-o", "-name", "y"]
|
||||
|
||||
|
||||
def test_adjust_depth_texts_same_mount_unchanged():
|
||||
assert _adjust_depth_texts(["-maxdepth", "3"], "/data",
|
||||
"/data") == ["-maxdepth", "3"]
|
||||
|
||||
|
||||
def test_maxdepth_applies_to_child_mount_depth_end_to_end():
|
||||
parent = RAMResource()
|
||||
child = RAMResource()
|
||||
child._store.dirs.add("/a")
|
||||
child._store.files["/a/b.txt"] = b"deep\n"
|
||||
ws = Workspace(resources={
|
||||
"/": (parent, MountMode.EXEC),
|
||||
"/data/": (child, MountMode.EXEC),
|
||||
}, )
|
||||
io = asyncio.run(ws.execute("find / -maxdepth 2"))
|
||||
out = (io.stdout if isinstance(io.stdout, bytes) else b"").decode()
|
||||
assert "/data/a" in out
|
||||
assert "/data/a/b.txt" not in out
|
||||
@@ -0,0 +1,190 @@
|
||||
# ========= 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 asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from mirage.commands.config import RegisteredCommand
|
||||
from mirage.commands.spec.types import CommandSpec, OperandKind, Option
|
||||
from mirage.workspace.executor.builtins import (_collect_man_hits,
|
||||
_render_man_entry,
|
||||
_render_man_index, handle_man)
|
||||
from mirage.workspace.session import Session
|
||||
|
||||
|
||||
def _mk_cmd(name, spec, filetype=None, resource="ram"):
|
||||
return RegisteredCommand(
|
||||
name=name,
|
||||
spec=spec,
|
||||
resource=resource,
|
||||
filetype=filetype,
|
||||
fn=lambda *a, **kw: None,
|
||||
)
|
||||
|
||||
|
||||
def _mk_mount(prefix, kind, cmds=None, general=None):
|
||||
mount = MagicMock()
|
||||
mount.prefix = prefix
|
||||
mount.resource = MagicMock()
|
||||
mount.resource.name = kind
|
||||
cmds = cmds or {}
|
||||
general = general or {}
|
||||
|
||||
def _resolve(name, extension=None):
|
||||
if name in cmds:
|
||||
return cmds[name]
|
||||
if name in general:
|
||||
return general[name]
|
||||
return None
|
||||
|
||||
def _is_general(name):
|
||||
return name in general and name not in cmds
|
||||
|
||||
def _all():
|
||||
seen = set()
|
||||
out = []
|
||||
for rc in cmds.values():
|
||||
if rc.name in seen:
|
||||
continue
|
||||
seen.add(rc.name)
|
||||
out.append(rc)
|
||||
for rc in general.values():
|
||||
if rc.name in seen:
|
||||
continue
|
||||
seen.add(rc.name)
|
||||
out.append(rc)
|
||||
return out
|
||||
|
||||
mount.resolve_command = MagicMock(side_effect=_resolve)
|
||||
mount.is_general_command = MagicMock(side_effect=_is_general)
|
||||
mount.all_commands = MagicMock(side_effect=_all)
|
||||
return mount
|
||||
|
||||
|
||||
def _mk_registry(mounts, cwd_mount=None):
|
||||
reg = MagicMock()
|
||||
reg.mounts = MagicMock(return_value=mounts)
|
||||
|
||||
def _mount_for(path):
|
||||
if cwd_mount is not None:
|
||||
return cwd_mount
|
||||
raise ValueError(f"no mount: {path}")
|
||||
|
||||
reg.mount_for = MagicMock(side_effect=_mount_for)
|
||||
return reg
|
||||
|
||||
|
||||
def test_collect_man_hits_skips_dev():
|
||||
spec = CommandSpec(description="x")
|
||||
cat_cmd = _mk_cmd("cat", spec)
|
||||
mount_dev = _mk_mount("/dev/", "dev", cmds={"cat": cat_cmd})
|
||||
mount_ram = _mk_mount("/ram/", "ram", cmds={"cat": cat_cmd})
|
||||
reg = _mk_registry([mount_dev, mount_ram])
|
||||
hits = _collect_man_hits("cat", reg)
|
||||
assert len(hits) == 1
|
||||
assert hits[0].mount is mount_ram
|
||||
|
||||
|
||||
def test_render_man_entry_no_options():
|
||||
spec = CommandSpec(description="Concatenate files.")
|
||||
cat_cmd = _mk_cmd("cat", spec)
|
||||
mount = _mk_mount("/ram/", "ram", cmds={"cat": cat_cmd})
|
||||
hits = _collect_man_hits("cat", _mk_registry([mount]))
|
||||
out = _render_man_entry("cat", hits)
|
||||
assert out.startswith("# cat\n")
|
||||
assert "Concatenate files." in out
|
||||
assert "## OPTIONS" not in out
|
||||
assert "## RESOURCES\n\n- ram\n" in out
|
||||
|
||||
|
||||
def test_render_man_entry_with_options():
|
||||
spec = CommandSpec(
|
||||
description="Print a sequence.",
|
||||
options=(
|
||||
Option(short="-s",
|
||||
value_kind=OperandKind.TEXT,
|
||||
description="separator"),
|
||||
Option(short="-w", description="zero-pad"),
|
||||
),
|
||||
)
|
||||
cmd = _mk_cmd("seq", spec)
|
||||
mount = _mk_mount("/ram/", "ram", cmds={"seq": cmd})
|
||||
hits = _collect_man_hits("seq", _mk_registry([mount]))
|
||||
out = _render_man_entry("seq", hits)
|
||||
assert "## OPTIONS" in out
|
||||
assert "| short | long | value | description |" in out
|
||||
assert "| -s | | text | separator |" in out
|
||||
assert "| -w | | none | zero-pad |" in out
|
||||
|
||||
|
||||
def test_render_man_entry_dedupes_by_kind_and_filetype():
|
||||
spec = CommandSpec(description="cat")
|
||||
plain = _mk_cmd("cat", spec)
|
||||
parquet = _mk_cmd("cat", spec, filetype=".parquet")
|
||||
m1 = _mk_mount("/a/", "ram", cmds={"cat": plain})
|
||||
m2 = _mk_mount("/b/", "ram", cmds={"cat": plain})
|
||||
m3 = _mk_mount("/c/", "ram", cmds={"cat": parquet})
|
||||
reg = _mk_registry([m1, m2, m3])
|
||||
hits = _collect_man_hits("cat", reg)
|
||||
out = _render_man_entry("cat", hits)
|
||||
assert out.count("- ram\n") == 1
|
||||
assert "- ram (filetype: .parquet)" in out
|
||||
|
||||
|
||||
def test_render_man_entry_general_first():
|
||||
spec = CommandSpec(description="x")
|
||||
cmd = _mk_cmd("bc", spec, resource=None)
|
||||
mount = _mk_mount("/ram/", "ram", general={"bc": cmd})
|
||||
hits = _collect_man_hits("bc", _mk_registry([mount]))
|
||||
out = _render_man_entry("bc", hits)
|
||||
assert out.endswith("- general\n")
|
||||
|
||||
|
||||
def test_handle_man_missing_entry():
|
||||
reg = _mk_registry([])
|
||||
out, io, node = asyncio.run(
|
||||
handle_man(["nope"], Session(session_id="t"), reg))
|
||||
assert out is None
|
||||
assert io.exit_code == 1
|
||||
assert io.stderr == b"man: no entry for nope\n"
|
||||
assert node.exit_code == 1
|
||||
|
||||
|
||||
def test_handle_man_index_cwd_first():
|
||||
spec_a = CommandSpec(description="ls files")
|
||||
spec_b = CommandSpec(description="cat files")
|
||||
ls = _mk_cmd("ls", spec_a)
|
||||
cat = _mk_cmd("cat", spec_b)
|
||||
mount_z = _mk_mount("/z/", "zfs", cmds={"ls": ls})
|
||||
mount_r = _mk_mount("/ram/", "ram", cmds={"cat": cat})
|
||||
reg = _mk_registry([mount_z, mount_r], cwd_mount=mount_r)
|
||||
out, io, node = asyncio.run(
|
||||
handle_man([], Session(session_id="t", cwd="/ram/x"), reg))
|
||||
assert io.exit_code == 0
|
||||
text = out.decode()
|
||||
ram_pos = text.index("# ram")
|
||||
zfs_pos = text.index("# zfs")
|
||||
assert ram_pos < zfs_pos
|
||||
|
||||
|
||||
def test_render_man_index_dedupes_general_across_mounts():
|
||||
spec_g = CommandSpec(description="bc desc")
|
||||
bc = _mk_cmd("bc", spec_g, resource=None)
|
||||
spec_a = CommandSpec(description="ls files")
|
||||
ls = _mk_cmd("ls", spec_a)
|
||||
m1 = _mk_mount("/a/", "ram", cmds={"ls": ls}, general={"bc": bc})
|
||||
m2 = _mk_mount("/b/", "s3", cmds={"ls": ls}, general={"bc": bc})
|
||||
reg = _mk_registry([m1, m2])
|
||||
text = _render_man_index(Session(session_id="t"), reg)
|
||||
assert text.count("- bc \u2014 bc desc") == 1
|
||||
@@ -0,0 +1,77 @@
|
||||
# ========= 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.io import IOResult
|
||||
from mirage.io.types import materialize
|
||||
from mirage.workspace.executor.pipes import handle_pipe
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
class FakeNode:
|
||||
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_pipe_passes_empty_stdin_when_left_returns_none():
|
||||
calls: list[dict] = []
|
||||
|
||||
async def execute_node(nd, _session, stdin, _call_stack=None):
|
||||
stdin_was_none = stdin is None
|
||||
materialized = await materialize(stdin)
|
||||
calls.append({
|
||||
"text": nd.text,
|
||||
"stdin_was_none": stdin_was_none,
|
||||
"stdin_bytes": materialized,
|
||||
})
|
||||
if nd.text == "left":
|
||||
return (None, IOResult(stderr=b"boom", exit_code=1),
|
||||
ExecutionNode(command=nd.text, exit_code=1))
|
||||
return (b"right-out", IOResult(exit_code=0),
|
||||
ExecutionNode(command=nd.text, exit_code=0))
|
||||
|
||||
await handle_pipe(
|
||||
execute_node,
|
||||
[FakeNode("left"), FakeNode("right")],
|
||||
[False],
|
||||
Session(session_id="t"),
|
||||
None,
|
||||
)
|
||||
right = next(c for c in calls if c["text"] == "right")
|
||||
assert right["stdin_was_none"] is False
|
||||
assert right["stdin_bytes"] == b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_pipe_threads_stdout_to_next_stdin():
|
||||
seen: list[bytes] = []
|
||||
|
||||
async def execute_node(nd, _session, stdin, _call_stack=None):
|
||||
seen.append(await materialize(stdin))
|
||||
return (f"{nd.text}-out".encode(), IOResult(exit_code=0),
|
||||
ExecutionNode(command=nd.text, exit_code=0))
|
||||
|
||||
await handle_pipe(
|
||||
execute_node,
|
||||
[FakeNode("a"), FakeNode("b")],
|
||||
[False],
|
||||
Session(session_id="t"),
|
||||
None,
|
||||
)
|
||||
assert seen[0] == b""
|
||||
assert seen[1] == b"a-out"
|
||||
@@ -0,0 +1,103 @@
|
||||
# ========= 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 import MountMode, RAMResource, Workspace
|
||||
|
||||
|
||||
async def _workspace() -> Workspace:
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("mkdir -p /data")
|
||||
return ws
|
||||
|
||||
|
||||
async def _out(ws: Workspace, cmd: str) -> str:
|
||||
io = await ws.execute(cmd)
|
||||
return (io.stdout or b"").decode()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_target_expands_after_cd_in_list():
|
||||
# tree-sitter hoists the trailing redirect over the && list; the
|
||||
# target must still expand with the cwd the last command sees.
|
||||
ws = await _workspace()
|
||||
await ws.execute("cd /data && echo hi > OUT")
|
||||
assert await _out(ws, "cat /data/OUT") == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_captures_only_last_command():
|
||||
ws = await _workspace()
|
||||
out = await _out(ws, "echo one && echo two > /data/f")
|
||||
assert out == "one\n"
|
||||
assert await _out(ws, "cat /data/f") == "two\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_short_circuit_and():
|
||||
ws = await _workspace()
|
||||
io = await ws.execute("false && echo never > /data/f3")
|
||||
assert io.exit_code == 1
|
||||
io = await ws.execute("test -f /data/f3")
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_short_circuit_or():
|
||||
ws = await _workspace()
|
||||
await ws.execute("false || echo fallback > /data/f4")
|
||||
assert await _out(ws, "cat /data/f4") == "fallback\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_chain_compounds():
|
||||
# Each redirect re-associates independently, so a multi-redirect
|
||||
# chain executes left to right instead of hoisting.
|
||||
ws = await _workspace()
|
||||
out = await _out(
|
||||
ws, "echo a > /data/c && echo b >> /data/c && cat /data/c"
|
||||
" && wc -l < /data/c")
|
||||
assert out == "a\nb\n2\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_group_keeps_whole_body():
|
||||
# Compound bodies are real bash group redirects, not hoists.
|
||||
ws = await _workspace()
|
||||
await ws.execute("{ echo g1; echo g2; } > /data/grp")
|
||||
assert await _out(ws, "cat /data/grp") == "g1\ng2\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_subshell_keeps_whole_body():
|
||||
ws = await _workspace()
|
||||
await ws.execute("(echo s1; echo s2) > /data/subq")
|
||||
assert await _out(ws, "cat /data/subq") == "s1\ns2\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_pipeline_right_side():
|
||||
ws = await _workspace()
|
||||
out = await _out(
|
||||
ws, "echo x && echo y | tr a-z A-Z > /data/up && cat /data/up")
|
||||
assert out == "x\nY\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdin_redirect_binds_last_command():
|
||||
ws = await _workspace()
|
||||
await ws.execute("printf 'l1\\nl2\\n' | tee /data/seed > /dev/null")
|
||||
out = await _out(ws, "echo lead && wc -l < /data/seed")
|
||||
assert out == "lead\n2\n"
|
||||
@@ -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,80 @@
|
||||
# ========= 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 mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace.expand.classify.heuristic import (_unescape_path,
|
||||
classify_word)
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
|
||||
|
||||
def test_unescape_backslash_apostrophe():
|
||||
assert _unescape_path(r"Zecheng\'s\ Server") == "Zecheng's Server"
|
||||
|
||||
|
||||
def test_unescape_no_backslash():
|
||||
assert _unescape_path("normal") == "normal"
|
||||
|
||||
|
||||
def test_unescape_only_space():
|
||||
assert _unescape_path(r"hello\ world") == "hello world"
|
||||
|
||||
|
||||
def test_classify_backslash_escaped_absolute():
|
||||
registry = MountRegistry()
|
||||
resource = RAMResource()
|
||||
resource._store.dirs.add("/")
|
||||
registry.mount("/ram/", resource, MountMode.WRITE)
|
||||
result = classify_word(r"/ram/Zecheng\'s\ Server/", registry, "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/ram/Zecheng's Server"
|
||||
|
||||
|
||||
def test_classify_quoted_path():
|
||||
registry = MountRegistry()
|
||||
resource = RAMResource()
|
||||
resource._store.dirs.add("/")
|
||||
registry.mount("/ram/", resource, MountMode.WRITE)
|
||||
result = classify_word("/ram/Zecheng's Server/", registry, "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/ram/Zecheng's Server"
|
||||
|
||||
|
||||
def test_bare_filename_stays_text():
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
assert classify_word("file.txt", registry, "/ram") == "file.txt"
|
||||
|
||||
|
||||
def test_relative_subdir_path_resolves():
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
result = classify_word("sub/file.txt", registry, "/ram")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/ram/sub/file.txt"
|
||||
|
||||
|
||||
def test_relative_glob_resolves_against_cwd():
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
result = classify_word("*.txt", registry, "/ram")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.pattern == "*.txt"
|
||||
assert result.directory == "/ram/"
|
||||
|
||||
|
||||
def test_bare_glob_operator_stays_text():
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
assert classify_word("*", registry, "/ram") == "*"
|
||||
@@ -0,0 +1,71 @@
|
||||
# ========= 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 mirage.commands.spec.types import OperandKind
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace.expand.classify.parts import classify_parts
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
|
||||
|
||||
def _registry() -> MountRegistry:
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
return registry
|
||||
|
||||
|
||||
def test_name_never_classified():
|
||||
result = classify_parts(["/ram/cat", "/ram/x"], _registry(), "/")
|
||||
assert result[0] == "/ram/cat"
|
||||
assert isinstance(result[1], PathSpec)
|
||||
|
||||
|
||||
def test_text_kind_keeps_string():
|
||||
result = classify_parts(["cat", "/ram/x"],
|
||||
_registry(),
|
||||
"/",
|
||||
word_kinds=[OperandKind.TEXT])
|
||||
assert result[1] == "/ram/x"
|
||||
|
||||
|
||||
def test_path_kind_classifies_bare_filename():
|
||||
result = classify_parts(["cat", "file.txt"],
|
||||
_registry(),
|
||||
"/ram",
|
||||
word_kinds=[OperandKind.PATH])
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].virtual == "/ram/file.txt"
|
||||
|
||||
|
||||
def test_duplicate_word_kinds_per_slot():
|
||||
result = classify_parts(["grep", "*.txt", "*.txt"],
|
||||
_registry(),
|
||||
"/ram",
|
||||
word_kinds=[OperandKind.TEXT, OperandKind.PATH])
|
||||
assert result[1] == "*.txt"
|
||||
assert isinstance(result[2], PathSpec)
|
||||
assert result[2].pattern == "*.txt"
|
||||
|
||||
|
||||
def test_none_kind_falls_back_to_heuristic():
|
||||
result = classify_parts(["cat", "/ram/x", "plain"],
|
||||
_registry(),
|
||||
"/",
|
||||
word_kinds=[None, None])
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[2] == "plain"
|
||||
|
||||
|
||||
def test_empty_parts():
|
||||
assert classify_parts([], _registry(), "/") == []
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= 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 mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace.expand.classify.path import classify_bare_path
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
|
||||
|
||||
def _registry() -> MountRegistry:
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
return registry
|
||||
|
||||
|
||||
def test_bare_filename_resolves_against_cwd():
|
||||
result = classify_bare_path("file.txt", _registry(), "/ram")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/ram/file.txt"
|
||||
assert result.resolved
|
||||
assert result.raw_path == "file.txt"
|
||||
|
||||
|
||||
def test_bare_glob_becomes_pattern():
|
||||
result = classify_bare_path("f?.txt", _registry(), "/ram")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.pattern == "f?.txt"
|
||||
assert result.directory == "/ram/"
|
||||
|
||||
|
||||
def test_absolute_path_delegates_to_heuristic():
|
||||
result = classify_bare_path("/ram/file.txt", _registry(), "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/ram/file.txt"
|
||||
@@ -0,0 +1,62 @@
|
||||
# ========= 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 mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace.expand.classify.relative import relative_spec
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
|
||||
|
||||
def _registry() -> MountRegistry:
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
return registry
|
||||
|
||||
|
||||
def test_plain_word_resolves_and_keeps_raw():
|
||||
result = relative_spec("sub/a.txt", _registry(), "/ram")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/ram/sub/a.txt"
|
||||
assert result.raw_path == "sub/a.txt"
|
||||
assert result.resolved
|
||||
assert result.pattern is None
|
||||
|
||||
|
||||
def test_glob_word_becomes_pattern():
|
||||
result = relative_spec("sub/*.txt", _registry(), "/ram")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.directory == "/ram/sub/"
|
||||
assert result.pattern == "*.txt"
|
||||
assert not result.resolved
|
||||
assert result.raw_path == "sub/*.txt"
|
||||
|
||||
|
||||
def test_dotdot_normalizes():
|
||||
result = relative_spec("../x.txt", _registry(), "/ram/sub")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/ram/x.txt"
|
||||
assert result.raw_path == "../x.txt"
|
||||
|
||||
|
||||
def test_unmounted_stays_text():
|
||||
registry = MountRegistry()
|
||||
registry.mount("/ram/", RAMResource(), MountMode.WRITE)
|
||||
assert relative_spec("a.txt", registry, "/elsewhere") == "a.txt"
|
||||
|
||||
|
||||
def test_raw_path_round_trip():
|
||||
result = relative_spec("./sub/a.txt", _registry(), "/ram")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.raw_path == "./sub/a.txt"
|
||||
assert result.virtual == "/ram/sub/a.txt"
|
||||
@@ -0,0 +1,58 @@
|
||||
# ========= 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 dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.types import PathSpec
|
||||
from mirage.workspace.expand.argv import Argv
|
||||
|
||||
|
||||
def _ps(virtual: str) -> PathSpec:
|
||||
return PathSpec(virtual=virtual,
|
||||
directory=virtual[:virtual.rfind("/") + 1],
|
||||
resource_path="",
|
||||
resolved=True)
|
||||
|
||||
|
||||
def test_words_includes_name():
|
||||
argv = Argv(name="cat", args=("f.txt", ), operands=(_ps("/ram/f.txt"), ))
|
||||
assert argv.words == ["cat", _ps("/ram/f.txt")]
|
||||
|
||||
|
||||
def test_words_empty_command():
|
||||
assert Argv(name="", args=(), operands=()).words == []
|
||||
|
||||
|
||||
def test_views_differ_only_in_type():
|
||||
pattern = _ps("/ram/*.txt")
|
||||
argv = Argv(name="ls", args=("/ram/*.txt", ), operands=(pattern, ))
|
||||
assert len(argv.args) == len(argv.operands)
|
||||
assert argv.args[0] == argv.operands[0].virtual
|
||||
|
||||
|
||||
def test_with_operands_replaces_only_operands():
|
||||
argv = Argv(name="rm", args=("link", ), operands=(_ps("/ram/link"), ))
|
||||
rewritten = argv.with_operands([_ps("/ram/target")])
|
||||
assert rewritten.operands == (_ps("/ram/target"), )
|
||||
assert rewritten.name == "rm"
|
||||
assert rewritten.args == ("link", )
|
||||
assert argv.operands == (_ps("/ram/link"), )
|
||||
|
||||
|
||||
def test_frozen():
|
||||
argv = Argv(name="cat", args=(), operands=())
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
argv.name = "dog"
|
||||
@@ -0,0 +1,296 @@
|
||||
# ========= 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 asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
from mirage.workspace.expand.globs import resolve_globs
|
||||
|
||||
|
||||
def _mock_registry(resolve_result=None):
|
||||
mount = MagicMock()
|
||||
mount.prefix = "/data/"
|
||||
|
||||
async def _resolve_glob(scopes, prefix=""):
|
||||
if resolve_result is not None:
|
||||
return resolve_result
|
||||
return scopes
|
||||
|
||||
mount.resource = MagicMock()
|
||||
mount.resource.resolve_glob = _resolve_glob
|
||||
|
||||
reg = MagicMock()
|
||||
reg.mount_for = MagicMock(return_value=mount)
|
||||
return reg
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_text_passes_through():
|
||||
reg = _mock_registry()
|
||||
classified = ["grep", "pattern"]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert result == ["grep", "pattern"]
|
||||
|
||||
|
||||
def test_pathspec_without_pattern_preserved():
|
||||
reg = _mock_registry()
|
||||
ps = PathSpec(resource_path="data/file.txt",
|
||||
virtual="/data/file.txt",
|
||||
directory="/data/",
|
||||
resolved=True)
|
||||
classified = ["cat", ps]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert len(result) == 2
|
||||
assert result[0] == "cat"
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1] is ps
|
||||
|
||||
|
||||
def test_glob_pathspec_resolved_to_pathspec():
|
||||
resolved_ps = PathSpec(
|
||||
resource_path=mount_key("/data/a.txt", "/data"),
|
||||
virtual="/data/a.txt",
|
||||
directory="/data/",
|
||||
resolved=True,
|
||||
)
|
||||
reg = _mock_registry(resolve_result=[resolved_ps])
|
||||
glob_ps = PathSpec(
|
||||
resource_path="data/*.txt",
|
||||
virtual="/data/*.txt",
|
||||
directory="/data/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
)
|
||||
classified = ["cat", glob_ps]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert len(result) == 2
|
||||
assert result[0] == "cat"
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1] is resolved_ps
|
||||
|
||||
|
||||
def test_glob_multiple_matches_expand():
|
||||
matches = [
|
||||
PathSpec(resource_path="data/a.txt",
|
||||
virtual="/data/a.txt",
|
||||
directory="/data/",
|
||||
resolved=True),
|
||||
PathSpec(resource_path="data/b.txt",
|
||||
virtual="/data/b.txt",
|
||||
directory="/data/",
|
||||
resolved=True),
|
||||
]
|
||||
reg = _mock_registry(resolve_result=matches)
|
||||
glob_ps = PathSpec(
|
||||
resource_path="data/*.txt",
|
||||
virtual="/data/*.txt",
|
||||
directory="/data/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
)
|
||||
classified = ["ls", glob_ps]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert len(result) == 3
|
||||
assert result[0] == "ls"
|
||||
assert all(isinstance(r, PathSpec) for r in result[1:])
|
||||
assert result[1].virtual == "/data/a.txt"
|
||||
assert result[2].virtual == "/data/b.txt"
|
||||
|
||||
|
||||
def test_glob_string_result_wrapped_in_pathspec():
|
||||
reg = _mock_registry(resolve_result=["/a.txt"])
|
||||
glob_ps = PathSpec(
|
||||
resource_path="data/*.txt",
|
||||
virtual="/data/*.txt",
|
||||
directory="/data/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
)
|
||||
classified = ["cat", glob_ps]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].virtual == "/data/a.txt"
|
||||
|
||||
|
||||
def test_glob_no_match_keeps_literal_word():
|
||||
reg = _mock_registry(resolve_result=[])
|
||||
glob_ps = PathSpec(
|
||||
resource_path="data/*.xyz",
|
||||
virtual="/data/*.xyz",
|
||||
directory="/data/",
|
||||
pattern="*.xyz",
|
||||
resolved=False,
|
||||
)
|
||||
classified = ["cat", glob_ps]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert len(result) == 2
|
||||
assert result[0] == "cat"
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].virtual == "/data/*.xyz"
|
||||
assert result[1].pattern
|
||||
|
||||
|
||||
def test_mixed_text_and_pathspec():
|
||||
reg = _mock_registry()
|
||||
ps = PathSpec(resource_path="data/file.txt",
|
||||
virtual="/data/file.txt",
|
||||
directory="/data/",
|
||||
resolved=True)
|
||||
classified = ["grep", "-i", "pattern", ps]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert result[0] == "grep"
|
||||
assert result[1] == "-i"
|
||||
assert result[2] == "pattern"
|
||||
assert isinstance(result[3], PathSpec)
|
||||
assert result[3] is ps
|
||||
|
||||
|
||||
def test_resolve_error_returns_original_pathspec():
|
||||
reg = _mock_registry()
|
||||
reg.mount_for = MagicMock(side_effect=ValueError("no mount"))
|
||||
glob_ps = PathSpec(
|
||||
resource_path="unknown/*.txt",
|
||||
virtual="/unknown/*.txt",
|
||||
directory="/unknown/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
)
|
||||
classified = ["cat", glob_ps]
|
||||
result = _run(resolve_globs(classified, reg))
|
||||
assert len(result) == 2
|
||||
assert isinstance(result[1], PathSpec)
|
||||
|
||||
|
||||
def test_pathspec_dir_carries_pattern():
|
||||
ps = PathSpec(
|
||||
resource_path=mount_key("/data/*.txt", "/data"),
|
||||
virtual="/data/*.txt",
|
||||
directory="/data/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
)
|
||||
d = ps.dir
|
||||
assert d.virtual == "/data/"
|
||||
assert d.pattern == "*.txt"
|
||||
assert d.resource_path == ""
|
||||
|
||||
|
||||
def test_pathspec_dir_no_pattern():
|
||||
ps = PathSpec(
|
||||
resource_path="data/file.txt",
|
||||
virtual="/data/file.txt",
|
||||
directory="/data/",
|
||||
resolved=True,
|
||||
)
|
||||
d = ps.dir
|
||||
assert d.virtual == "/data/"
|
||||
assert d.pattern is None
|
||||
|
||||
|
||||
def test_scope_error_truncates_instead_of_crash():
|
||||
from mirage.core.ram.glob import resolve_glob as ram_resolve_glob
|
||||
|
||||
resource = RAMResource()
|
||||
for i in range(20):
|
||||
resource._store.files[f"/f{i:02d}.txt"] = b""
|
||||
resource._store.dirs.add("/")
|
||||
index = RAMIndexCacheStore()
|
||||
glob_ps = PathSpec(
|
||||
resource_path="*.txt",
|
||||
virtual="/*.txt",
|
||||
directory="/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
)
|
||||
|
||||
async def _run():
|
||||
with patch("mirage.core.ram.glob.SCOPE_ERROR", 5):
|
||||
result = await ram_resolve_glob(resource.accessor, [glob_ps],
|
||||
index)
|
||||
return result
|
||||
|
||||
result = asyncio.run(_run())
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
def test_relative_glob_matches_spelled_as_typed():
|
||||
matches = [
|
||||
PathSpec(resource_path="data/sub/a.txt",
|
||||
virtual="/data/sub/a.txt",
|
||||
directory="/data/sub/",
|
||||
resolved=True),
|
||||
]
|
||||
reg = _mock_registry(resolve_result=matches)
|
||||
glob_ps = PathSpec(
|
||||
resource_path="data/sub/*.txt",
|
||||
virtual="/data/sub/*.txt",
|
||||
directory="/data/sub/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
raw_path="sub/*.txt",
|
||||
)
|
||||
result = _run(resolve_globs(["ls", glob_ps], reg))
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].raw_path == "sub/a.txt"
|
||||
assert result[1].virtual == "/data/sub/a.txt"
|
||||
|
||||
|
||||
def test_absolute_glob_matches_keep_virtual():
|
||||
matches = [
|
||||
PathSpec(resource_path="data/a.txt",
|
||||
virtual="/data/a.txt",
|
||||
directory="/data/",
|
||||
resolved=True),
|
||||
]
|
||||
reg = _mock_registry(resolve_result=matches)
|
||||
glob_ps = PathSpec(
|
||||
resource_path="data/*.txt",
|
||||
virtual="/data/*.txt",
|
||||
directory="/data/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
)
|
||||
result = _run(resolve_globs(["ls", glob_ps], reg))
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].raw_path == result[1].virtual
|
||||
assert result[1].raw_path == "/data/a.txt"
|
||||
|
||||
|
||||
def test_bare_relative_glob_raw_has_no_dir_prefix():
|
||||
matches = [
|
||||
PathSpec(resource_path="data/a.txt",
|
||||
virtual="/data/a.txt",
|
||||
directory="/data/",
|
||||
resolved=True),
|
||||
]
|
||||
reg = _mock_registry(resolve_result=matches)
|
||||
glob_ps = PathSpec(
|
||||
resource_path="data/*.txt",
|
||||
virtual="/data/*.txt",
|
||||
directory="/data/",
|
||||
pattern="*.txt",
|
||||
resolved=False,
|
||||
raw_path="*.txt",
|
||||
)
|
||||
result = _run(resolve_globs(["ls", glob_ps], reg))
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].raw_path == "a.txt"
|
||||
@@ -0,0 +1,68 @@
|
||||
# ========= 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 import MountMode, RAMResource, Workspace
|
||||
|
||||
|
||||
async def _workspace_at(cwd: str) -> Workspace:
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute(f"mkdir -p {cwd}")
|
||||
await ws.execute(f"cd {cwd}")
|
||||
return ws
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_bare_target_resolves_against_cwd():
|
||||
# A redirect target is a path by definition: `> BARE` must write
|
||||
# cwd/BARE even though the bare word would classify as text.
|
||||
ws = await _workspace_at("/data")
|
||||
await ws.execute("echo hi > BARE")
|
||||
io = await ws.execute("cat /data/BARE")
|
||||
assert (io.stdout or b"") == b"hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_extensionless_relative_target():
|
||||
ws = await _workspace_at("/data")
|
||||
await ws.execute("mkdir -p /data/sub")
|
||||
await ws.execute("echo hi > sub/OUT")
|
||||
io = await ws.execute("cat /data/sub/OUT")
|
||||
assert (io.stdout or b"") == b"hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_append_relative_target():
|
||||
ws = await _workspace_at("/data")
|
||||
await ws.execute("echo one > LOG")
|
||||
await ws.execute("echo two >> LOG")
|
||||
io = await ws.execute("cat /data/LOG")
|
||||
assert (io.stdout or b"") == b"one\ntwo\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_stdin_relative_source():
|
||||
ws = await _workspace_at("/data")
|
||||
await ws.execute("echo hi > IN")
|
||||
io = await ws.execute("wc -l < IN")
|
||||
assert (io.stdout or b"").strip() == b"1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redirect_absolute_target_unchanged():
|
||||
ws = await _workspace_at("/data")
|
||||
await ws.execute("echo hi > /data/ABS")
|
||||
io = await ws.execute("cat /data/ABS")
|
||||
assert (io.stdout or b"") == b"hi\n"
|
||||
@@ -0,0 +1,101 @@
|
||||
# ========= 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 import MountMode, RAMResource, Workspace
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.commands.spec.types import OperandKind
|
||||
from mirage.workspace.expand.spec_hints import (spec_for_command,
|
||||
spec_word_kinds)
|
||||
|
||||
PATH = OperandKind.PATH
|
||||
TEXT = OperandKind.TEXT
|
||||
|
||||
|
||||
def test_spec_for_command_prefers_cwd_mount():
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
mount = ws._registry.mount_for("/")
|
||||
spec = spec_for_command("grep", ws._registry, "/")
|
||||
assert spec is mount.spec_for("grep")
|
||||
|
||||
|
||||
def test_spec_for_command_falls_back_to_shared_specs():
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
mount = ws._registry.mount_for("/")
|
||||
name = next(n for n in SPECS if mount.spec_for(n) is None)
|
||||
assert spec_for_command(name, ws._registry, "/") is SPECS[name]
|
||||
|
||||
|
||||
def test_spec_for_command_unknown_name_is_none():
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
assert spec_for_command("no-such-command", ws._registry, "/") is None
|
||||
|
||||
|
||||
def test_basic_grep_pattern_and_path():
|
||||
kinds = spec_word_kinds(SPECS["grep"], ["pattern", "file.txt"])
|
||||
assert kinds == [TEXT, PATH]
|
||||
|
||||
|
||||
def test_text_flag_values_positional():
|
||||
kinds = spec_word_kinds(SPECS["find"], ["/data", "-name", "*.txt"])
|
||||
assert kinds == [PATH, None, TEXT]
|
||||
|
||||
|
||||
def test_long_value_flag_equals_not_classified():
|
||||
kinds = spec_word_kinds(SPECS["du"], ["--max-depth=1", "/data"])
|
||||
assert kinds == [None, PATH]
|
||||
|
||||
|
||||
def test_mixed_cluster_value_is_text():
|
||||
kinds = spec_word_kinds(SPECS["grep"], ["-ne", "pat", "/a.txt"])
|
||||
assert kinds == [None, TEXT, PATH]
|
||||
|
||||
|
||||
def test_repeated_dash_e_values_are_text():
|
||||
kinds = spec_word_kinds(SPECS["grep"],
|
||||
["-e", "foo", "-e", "bar", "/a.txt"])
|
||||
assert kinds == [None, TEXT, None, TEXT, PATH]
|
||||
|
||||
|
||||
def test_numeric_shorthand_not_a_path():
|
||||
kinds = spec_word_kinds(SPECS["head"], ["-5", "file.txt"])
|
||||
assert kinds == [None, PATH]
|
||||
|
||||
|
||||
def test_find_ignore_tokens_not_classified():
|
||||
kinds = spec_word_kinds(SPECS["find"],
|
||||
["/data", "(", "-name", "*.txt", ")"])
|
||||
assert kinds[0] == PATH
|
||||
assert kinds[1] is None
|
||||
assert kinds[4] is None
|
||||
|
||||
|
||||
def test_duplicate_word_text_and_path_slots():
|
||||
# F8: the same word is the pattern (TEXT) and a file glob (PATH);
|
||||
# value sets could not tell the two slots apart.
|
||||
kinds = spec_word_kinds(SPECS["grep"], ["*.txt", "*.txt"])
|
||||
assert kinds == [TEXT, PATH]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_max_depth_equals_at_root_mount():
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("mkdir -p /data/sub")
|
||||
await ws.execute("tee /data/sub/n.txt > /dev/null", stdin=b"x\n")
|
||||
|
||||
io = await ws.execute("du --max-depth=1 /data/sub")
|
||||
out = (io.stdout or b"").decode()
|
||||
assert "--max-depth" not in out
|
||||
assert "/data/sub" in out
|
||||
@@ -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,113 @@
|
||||
# ========= 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 boto3
|
||||
import pytest
|
||||
from moto import mock_aws
|
||||
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3.s3 import S3Config, S3Resource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
|
||||
|
||||
def _ram_write(p: RAMResource, path: str, data: bytes) -> None:
|
||||
"""Write file to RAMResource store directly (sync, for test setup)."""
|
||||
key = "/" + path.strip("/")
|
||||
parts = key.strip("/").split("/")
|
||||
for i in range(len(parts) - 1):
|
||||
p._store.dirs.add("/" + "/".join(parts[:i + 1]))
|
||||
p._store.files[key] = data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ram_resource():
|
||||
"""A RAMResource with test data."""
|
||||
p = RAMResource()
|
||||
_ram_write(p, "/hello.txt", b"hello world\n")
|
||||
_ram_write(p, "/nums.txt", b"3\n1\n2\n")
|
||||
_ram_write(p, "/sub/nested.txt", b"nested\n")
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_resource():
|
||||
"""An empty RAMResource."""
|
||||
return RAMResource()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disk_resource(tmp_path):
|
||||
"""A DiskResource backed by a temporary directory."""
|
||||
data_dir = tmp_path / "disk_data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "readme.txt").write_bytes(b"disk file\n")
|
||||
sub = data_dir / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "deep.txt").write_bytes(b"deep content\n")
|
||||
return DiskResource(root=str(data_dir))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_resource():
|
||||
"""An S3Resource backed by moto mock."""
|
||||
with mock_aws():
|
||||
conn = boto3.client("s3", region_name="us-east-1")
|
||||
conn.create_bucket(Bucket="test-bucket")
|
||||
conn.put_object(Bucket="test-bucket",
|
||||
Key="data/report.csv",
|
||||
Body=b"col1,col2\n1,2\n")
|
||||
conn.put_object(Bucket="test-bucket",
|
||||
Key="data/summary.txt",
|
||||
Body=b"summary\n")
|
||||
|
||||
config = S3Config(
|
||||
bucket="test-bucket",
|
||||
region="us-east-1",
|
||||
endpoint_url=None,
|
||||
)
|
||||
yield S3Resource(config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry(ram_resource):
|
||||
"""MountRegistry with /data/ mounted to RAMResource."""
|
||||
reg = MountRegistry()
|
||||
reg.mount("/data/", ram_resource, MountMode.WRITE)
|
||||
return reg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_registry(s3_resource, disk_resource, ram_resource):
|
||||
"""MountRegistry with S3, disk, and RAM mounts."""
|
||||
reg = MountRegistry()
|
||||
reg.mount("/s3/", s3_resource, MountMode.READ)
|
||||
reg.mount("/disk/", disk_resource, MountMode.WRITE)
|
||||
reg.mount("/ram/", ram_resource, MountMode.WRITE)
|
||||
return reg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def nested_registry():
|
||||
"""MountRegistry with nested prefixes."""
|
||||
p1 = RAMResource()
|
||||
p1._store.files["/file.txt"] = b"outer\n"
|
||||
p2 = RAMResource()
|
||||
p2._store.files["/deep.txt"] = b"inner\n"
|
||||
|
||||
reg = MountRegistry()
|
||||
reg.mount("/data/", p1, MountMode.WRITE)
|
||||
reg.mount("/data/sub/", p2, MountMode.WRITE)
|
||||
return reg
|
||||
@@ -0,0 +1,199 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.mount import MountEntry
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ── prefix validation ──────────────────────────
|
||||
|
||||
|
||||
def test_mount_accepts_root_prefix():
|
||||
m = MountEntry("/", RAMResource())
|
||||
assert m.prefix == "/"
|
||||
|
||||
|
||||
def test_mount_rejects_no_leading_slash():
|
||||
with pytest.raises(ValueError, match="must start with /"):
|
||||
MountEntry("data/", RAMResource())
|
||||
|
||||
|
||||
def test_mount_rejects_no_trailing_slash():
|
||||
with pytest.raises(ValueError, match="must end with /"):
|
||||
MountEntry("/data", RAMResource())
|
||||
|
||||
|
||||
def test_mount_rejects_double_slash():
|
||||
with pytest.raises(ValueError, match="must not contain //"):
|
||||
MountEntry("/data//sub/", RAMResource())
|
||||
|
||||
|
||||
def test_mount_valid_prefix():
|
||||
m = MountEntry("/data/", RAMResource())
|
||||
assert m.prefix == "/data/"
|
||||
|
||||
|
||||
# ── read-only enforcement ──────────────────────
|
||||
|
||||
|
||||
def test_read_only_blocks_write_ops():
|
||||
reg = MountRegistry()
|
||||
reg.mount("/ro/", RAMResource(), MountMode.READ)
|
||||
mount = reg.mount_for("/ro/file.txt")
|
||||
with pytest.raises(PermissionError, match="read-only"):
|
||||
_run(mount.execute_op("write", "/file.txt", data=b"x"))
|
||||
|
||||
|
||||
def test_write_mode_allows_write_ops():
|
||||
reg = MountRegistry()
|
||||
reg.mount("/rw/", RAMResource(), MountMode.WRITE)
|
||||
mount = reg.mount_for("/rw/file.txt")
|
||||
_run(mount.execute_op("write", "/new.txt", data=b"hello"))
|
||||
|
||||
|
||||
def test_read_only_blocks_write_cmd():
|
||||
reg = MountRegistry()
|
||||
reg.mount("/ro/", RAMResource(), MountMode.READ)
|
||||
mount = reg.mount_for("/ro/file.txt")
|
||||
scope = PathSpec(resource_path="ro/newdir",
|
||||
virtual="/ro/newdir",
|
||||
directory="/ro/",
|
||||
resolved=True)
|
||||
stdout, io = _run(mount.execute_cmd("mkdir", [scope], [], {}))
|
||||
assert io.exit_code != 0
|
||||
assert b"read-only" in io.stderr
|
||||
|
||||
|
||||
def test_write_mode_allows_write_cmd():
|
||||
reg = MountRegistry()
|
||||
reg.mount("/rw/", RAMResource(), MountMode.WRITE)
|
||||
mount = reg.mount_for("/rw/file.txt")
|
||||
scope = PathSpec(resource_path="rw/newdir",
|
||||
virtual="/rw/newdir",
|
||||
directory="/rw/",
|
||||
resolved=True)
|
||||
stdout, io = _run(mount.execute_cmd("mkdir", [scope], [], {}))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_read_only_allows_read_cmd():
|
||||
reg = MountRegistry()
|
||||
reg.mount("/ro/", RAMResource(), MountMode.READ)
|
||||
mount = reg.mount_for("/ro/")
|
||||
scope = PathSpec(resource_path="ro",
|
||||
virtual="/ro/",
|
||||
directory="/ro/",
|
||||
resolved=False)
|
||||
stdout, io = _run(mount.execute_cmd("ls", [scope], [], {}))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
# ── execute_cmd ────────────────────────────────
|
||||
|
||||
|
||||
def test_execute_cmd_cat(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
scope = PathSpec(resource_path="data/hello.txt",
|
||||
virtual="/data/hello.txt",
|
||||
directory="/data/",
|
||||
resolved=True)
|
||||
stdout, io = _run(mount.execute_cmd("cat", [scope], [], {}))
|
||||
assert io.exit_code == 0
|
||||
assert stdout is not None
|
||||
|
||||
|
||||
def test_execute_cmd_not_found(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
stdout, io = _run(mount.execute_cmd("nonexistent_cmd", [], [], {}))
|
||||
assert io.exit_code == 127
|
||||
assert b"command not found" in io.stderr
|
||||
|
||||
|
||||
def test_execute_cmd_ls(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
scope = PathSpec(resource_path="data",
|
||||
virtual="/data/",
|
||||
directory="/data/",
|
||||
resolved=False)
|
||||
stdout, io = _run(mount.execute_cmd("ls", [scope], [], {}))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_execute_cmd_with_flag_kwargs(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
scope = PathSpec(resource_path="data/hello.txt",
|
||||
virtual="/data/hello.txt",
|
||||
directory="/data/",
|
||||
resolved=True)
|
||||
stdout, io = _run(mount.execute_cmd("cat", [scope], [], {"n": True}))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_execute_cmd_with_texts(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
scope = PathSpec(resource_path="data/hello.txt",
|
||||
virtual="/data/hello.txt",
|
||||
directory="/data/",
|
||||
resolved=True)
|
||||
stdout, io = _run(mount.execute_cmd("grep", [scope], ["hello"], {}))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
# ── execute_op ─────────────────────────────────
|
||||
|
||||
|
||||
def test_execute_op_stat(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
result = _run(mount.execute_op("stat", "/hello.txt"))
|
||||
assert result is not None
|
||||
assert result.size > 0
|
||||
|
||||
|
||||
def test_execute_op_readdir(registry):
|
||||
mount = registry.mount_for("/data/")
|
||||
result = _run(mount.execute_op("readdir", "/"))
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_execute_op_no_such_op(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
with pytest.raises(AttributeError, match="no op"):
|
||||
_run(mount.execute_op("nonexistent_op", "/file.txt"))
|
||||
|
||||
|
||||
# ── command resolution ─────────────────────────
|
||||
|
||||
|
||||
def test_resolve_command_exists(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
cmd = mount.resolve_command("cat")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "cat"
|
||||
|
||||
|
||||
def test_resolve_command_missing(registry):
|
||||
mount = registry.mount_for("/data/hello.txt")
|
||||
cmd = mount.resolve_command("nonexistent")
|
||||
assert cmd is None
|
||||
@@ -0,0 +1,119 @@
|
||||
# ========= 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.workspace.mount.namespace import Namespace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def namespace(registry):
|
||||
return Namespace(registry)
|
||||
|
||||
|
||||
def test_resolve_delegates_to_registry(namespace, registry):
|
||||
assert namespace.resolve("/data/hello.txt") == registry.resolve(
|
||||
"/data/hello.txt")
|
||||
|
||||
|
||||
def test_resolve_follow_noop_without_links(namespace):
|
||||
assert namespace.resolve(
|
||||
"/data/hello.txt", follow=True) == namespace.resolve("/data/hello.txt",
|
||||
follow=False)
|
||||
|
||||
|
||||
def test_resolve_unknown_path_raises(namespace):
|
||||
with pytest.raises(ValueError, match="no mount"):
|
||||
namespace.resolve("/unknown/x.txt")
|
||||
|
||||
|
||||
def test_mount_for_delegates_to_registry(namespace, registry):
|
||||
assert namespace.mount_for("/data/hello.txt") is registry.mount_for(
|
||||
"/data/hello.txt")
|
||||
|
||||
|
||||
def test_symlink_readlink_roundtrip_verbatim(namespace):
|
||||
namespace.symlink("/data/link", "/data/hello.txt", 1.0)
|
||||
assert namespace.is_link("/data/link")
|
||||
assert namespace.readlink("/data/link") == "/data/hello.txt"
|
||||
|
||||
|
||||
def test_readlink_missing_returns_none(namespace):
|
||||
assert namespace.readlink("/data/nope") is None
|
||||
|
||||
|
||||
def test_symlink_stores_relative_target_verbatim(namespace):
|
||||
namespace.symlink("/data/link", "hello.txt", 1.0)
|
||||
assert namespace.readlink("/data/link") == "hello.txt"
|
||||
|
||||
|
||||
def test_unlink_removes_link(namespace):
|
||||
namespace.symlink("/data/link", "/data/hello.txt", 1.0)
|
||||
assert namespace.unlink("/data/link") is True
|
||||
assert namespace.is_link("/data/link") is False
|
||||
assert namespace.unlink("/data/link") is False
|
||||
|
||||
|
||||
def test_rename_moves_link(namespace):
|
||||
namespace.symlink("/data/a", "/data/hello.txt", 1.0)
|
||||
assert namespace.rename("/data/a", "/data/b") is True
|
||||
assert namespace.is_link("/data/a") is False
|
||||
assert namespace.readlink("/data/b") == "/data/hello.txt"
|
||||
|
||||
|
||||
def test_resolve_follows_link_to_target_mount(namespace):
|
||||
namespace.symlink("/data/link", "/data/hello.txt", 1.0)
|
||||
assert namespace.resolve(
|
||||
"/data/link", follow=True) == namespace.resolve("/data/hello.txt")
|
||||
|
||||
|
||||
def test_resolve_no_follow_keeps_link_path(namespace, registry):
|
||||
namespace.symlink("/data/link", "/data/hello.txt", 1.0)
|
||||
assert namespace.resolve("/data/link",
|
||||
follow=False) == registry.resolve("/data/link")
|
||||
|
||||
|
||||
def test_resolve_cycle_raises(namespace):
|
||||
from mirage.utils.path import CycleError
|
||||
namespace.symlink("/data/a", "/data/b", 1.0)
|
||||
namespace.symlink("/data/b", "/data/a", 1.0)
|
||||
with pytest.raises(CycleError):
|
||||
namespace.resolve("/data/a", follow=True)
|
||||
|
||||
|
||||
def test_follow_resolves_prefix_links(namespace):
|
||||
namespace.symlink("/data/link", "/data/real", 1.0)
|
||||
assert namespace.follow("/data/link/f.txt") == "/data/real/f.txt"
|
||||
assert namespace.follow("/data/other") == "/data/other"
|
||||
|
||||
|
||||
def test_follow_identity_without_links(namespace):
|
||||
assert namespace.follow("/data/x") == "/data/x"
|
||||
|
||||
|
||||
def test_links_under_returns_direct_children_only(namespace):
|
||||
namespace.symlink("/data/a", "/t1", 1.0)
|
||||
namespace.symlink("/data/sub/b", "/t2", 1.0)
|
||||
namespace.symlink("/other/c", "/t3", 1.0)
|
||||
assert namespace.links_under("/data") == {"a": "/t1"}
|
||||
assert namespace.links_under("/data/sub") == {"b": "/t2"}
|
||||
|
||||
|
||||
def test_purge_under_drops_nested_entries(namespace):
|
||||
namespace.symlink("/data/sub/a", "/t1", 1.0)
|
||||
namespace.symlink("/data/sub/deep/b", "/t2", 1.0)
|
||||
namespace.symlink("/data/keep", "/t3", 1.0)
|
||||
assert namespace.purge_under("/data/sub") == 2
|
||||
assert namespace.is_link("/data/keep") is True
|
||||
assert namespace.is_link("/data/sub/a") is False
|
||||
@@ -0,0 +1,89 @@
|
||||
# ========= 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 import MountMode, Workspace
|
||||
from mirage.commands.builtin.utils.safeguard import SafeguardExceededError
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import CommandSafeguard, OnExceed
|
||||
|
||||
|
||||
async def _read_long(accessor, scope, *args, **kwargs):
|
||||
return b"hello world"
|
||||
|
||||
|
||||
async def _read_lines(accessor, scope, *args, **kwargs):
|
||||
return b"a\nb\nc\nd\n"
|
||||
|
||||
|
||||
async def _read_short(accessor, scope, *args, **kwargs):
|
||||
return b"hi"
|
||||
|
||||
|
||||
async def _ws_mount():
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("echo hi > /data/f.txt")
|
||||
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
|
||||
return mount
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_read_truncates_to_max_bytes(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
mount.command_safeguards["read"] = CommandSafeguard(max_bytes=5)
|
||||
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_long)
|
||||
assert await mount.execute_op("read", "/data/f.txt") == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_read_truncates_to_max_lines(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
mount.command_safeguards["read"] = CommandSafeguard(max_lines=2)
|
||||
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_lines)
|
||||
assert await mount.execute_op("read", "/data/f.txt") == b"a\nb\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_read_on_exceed_error_raises(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
mount.command_safeguards["read"] = CommandSafeguard(
|
||||
max_bytes=5, on_exceed=OnExceed.ERROR)
|
||||
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_long)
|
||||
with pytest.raises(SafeguardExceededError):
|
||||
await mount.execute_op("read", "/data/f.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_read_within_limit_untouched(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
mount.command_safeguards["read"] = CommandSafeguard(max_bytes=100)
|
||||
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_short)
|
||||
assert await mount.execute_op("read", "/data/f.txt") == b"hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_unconfigured_read_untouched(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
monkeypatch.setattr(mount._ops[("read", None)], "fn", _read_long)
|
||||
assert await mount.execute_op("read", "/data/f.txt") == b"hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_stat_not_capped_by_byte_limit(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
mount.command_safeguards["stat"] = CommandSafeguard(max_bytes=1)
|
||||
result = await mount.execute_op("stat", "/data/f.txt")
|
||||
assert result is not None
|
||||
assert not isinstance(result, (bytes, bytearray))
|
||||
@@ -0,0 +1,56 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.commands.builtin.utils.safeguard import CommandTimeoutError
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import CommandSafeguard
|
||||
|
||||
|
||||
async def _slow_op(accessor, scope, *args, **kwargs):
|
||||
await asyncio.sleep(5)
|
||||
return None
|
||||
|
||||
|
||||
async def _slowish_op(accessor, scope, *args, **kwargs):
|
||||
await asyncio.sleep(0.2)
|
||||
return "ok"
|
||||
|
||||
|
||||
async def _ws_mount():
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("echo hi > /data/f.txt")
|
||||
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
|
||||
return mount
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_op_honors_per_mount_timeout(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
mount.command_safeguards["stat"] = CommandSafeguard(timeout_seconds=0.05)
|
||||
monkeypatch.setattr(mount._ops[("stat", None)], "fn", _slow_op)
|
||||
with pytest.raises(CommandTimeoutError):
|
||||
await mount.execute_op("stat", "/data/f.txt")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vfs_op_unconfigured_is_not_timed(monkeypatch):
|
||||
mount = await _ws_mount()
|
||||
monkeypatch.setattr(mount._ops[("stat", None)], "fn", _slowish_op)
|
||||
result = await mount.execute_op("stat", "/data/f.txt")
|
||||
assert result == "ok"
|
||||
@@ -0,0 +1,389 @@
|
||||
# ========= 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.cache.file.ram import RAMFileCacheStore
|
||||
from mirage.commands.registry import command
|
||||
from mirage.commands.spec.types import CommandSpec
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.resource.base import BaseResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.ssh import SSHConfig, SSHResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace.mount import MountCommandUnsupported, MountRegistry
|
||||
|
||||
# ── mount_for ──────────────────────────────────
|
||||
|
||||
|
||||
def test_mount_for_exact(registry):
|
||||
mount = registry.mount_for("/data/file.txt")
|
||||
assert mount.prefix == "/data/"
|
||||
|
||||
|
||||
def test_mount_for_nested_path(registry):
|
||||
mount = registry.mount_for("/data/sub/deep/file.txt")
|
||||
assert mount.prefix == "/data/"
|
||||
|
||||
|
||||
def test_mount_for_prefix_root(registry):
|
||||
mount = registry.mount_for("/data")
|
||||
assert mount.prefix == "/data/"
|
||||
|
||||
|
||||
def test_mount_for_no_match(registry):
|
||||
with pytest.raises(ValueError, match="no mount"):
|
||||
registry.mount_for("/unknown/file.txt")
|
||||
|
||||
|
||||
def test_mount_for_dev_default(registry):
|
||||
mount = registry.mount_for("/dev/null")
|
||||
assert mount.prefix == "/dev/"
|
||||
|
||||
|
||||
# ── longest prefix match ──────────────────────
|
||||
|
||||
|
||||
def test_nested_prefix_longest_match(nested_registry):
|
||||
mount = nested_registry.mount_for("/data/sub/deep.txt")
|
||||
assert mount.prefix == "/data/sub/"
|
||||
|
||||
|
||||
def test_nested_prefix_outer(nested_registry):
|
||||
mount = nested_registry.mount_for("/data/file.txt")
|
||||
assert mount.prefix == "/data/"
|
||||
|
||||
|
||||
# ── descendant_mounts ──────────────────────────
|
||||
|
||||
|
||||
def test_descendant_mounts_under_root(multi_registry):
|
||||
descs = multi_registry.descendant_mounts("/")
|
||||
prefixes = [m.prefix for m in descs]
|
||||
# /dev/ is auto-mounted by the registry; ours are /s3/, /disk/, /ram/.
|
||||
assert "/disk/" in prefixes
|
||||
assert "/ram/" in prefixes
|
||||
assert "/s3/" in prefixes
|
||||
|
||||
|
||||
def test_descendant_mounts_under_root_sorted(multi_registry):
|
||||
descs = multi_registry.descendant_mounts("/")
|
||||
prefixes = [m.prefix for m in descs]
|
||||
assert prefixes == sorted(prefixes)
|
||||
|
||||
|
||||
def test_descendant_mounts_at_a_mount_root_is_empty(registry):
|
||||
# /data/ is a mount; nothing is below it.
|
||||
assert registry.descendant_mounts("/data") == []
|
||||
assert registry.descendant_mounts("/data/") == []
|
||||
|
||||
|
||||
def test_descendant_mounts_inside_a_mount_is_empty(registry):
|
||||
assert registry.descendant_mounts("/data/sub") == []
|
||||
|
||||
|
||||
def test_descendant_mounts_nested(nested_registry):
|
||||
# Top: /data/, /data/sub/.
|
||||
descs = nested_registry.descendant_mounts("/")
|
||||
prefixes = [m.prefix for m in descs]
|
||||
assert "/data/" in prefixes
|
||||
assert "/data/sub/" in prefixes
|
||||
# Below /data/, the only descendant is /data/sub/.
|
||||
descs2 = nested_registry.descendant_mounts("/data")
|
||||
assert [m.prefix for m in descs2] == ["/data/sub/"]
|
||||
# /data/ as a mount root returns its own children only (excludes self).
|
||||
assert "/data/" not in [m.prefix for m in descs2]
|
||||
|
||||
|
||||
def test_descendant_mounts_excludes_self(nested_registry):
|
||||
descs = nested_registry.descendant_mounts("/data")
|
||||
assert all(m.prefix != "/data/" for m in descs)
|
||||
|
||||
|
||||
def test_descendant_mounts_unrelated_path(multi_registry):
|
||||
# A path in /s3/ has no mount nested below it.
|
||||
assert multi_registry.descendant_mounts("/s3/data") == []
|
||||
|
||||
|
||||
# ── resolve ────────────────────────────────────
|
||||
|
||||
|
||||
def test_resolve_strips_prefix(registry):
|
||||
prov, pp, mode = registry.resolve("/data/hello.txt")
|
||||
assert pp == "/hello.txt"
|
||||
assert prov.name == "ram"
|
||||
|
||||
|
||||
def test_resolve_root_path(registry):
|
||||
_, pp, _ = registry.resolve("/data")
|
||||
assert pp == "/"
|
||||
|
||||
|
||||
def test_resolve_trailing_slash(registry):
|
||||
_, pp, _ = registry.resolve("/data/sub/")
|
||||
assert pp.endswith("/")
|
||||
|
||||
|
||||
def test_resolve_mode(registry):
|
||||
_, _, mode = registry.resolve("/data/file.txt")
|
||||
assert mode == MountMode.WRITE
|
||||
|
||||
|
||||
def test_resolve_no_match(registry):
|
||||
with pytest.raises(ValueError):
|
||||
registry.resolve("/unknown/path")
|
||||
|
||||
|
||||
# ── multi mount (s3 + disk + ram) ──────────────
|
||||
|
||||
|
||||
def test_multi_mount_resolves_s3(multi_registry):
|
||||
mount = multi_registry.mount_for("/s3/data/report.csv")
|
||||
assert mount.prefix == "/s3/"
|
||||
assert mount.resource.name == "s3"
|
||||
|
||||
|
||||
def test_multi_mount_resolves_disk(multi_registry):
|
||||
mount = multi_registry.mount_for("/disk/readme.txt")
|
||||
assert mount.prefix == "/disk/"
|
||||
assert mount.resource.name == "disk"
|
||||
|
||||
|
||||
def test_multi_mount_resolves_ram(multi_registry):
|
||||
mount = multi_registry.mount_for("/ram/hello.txt")
|
||||
assert mount.prefix == "/ram/"
|
||||
assert mount.resource.name == "ram"
|
||||
|
||||
|
||||
def test_multi_mount_modes(multi_registry):
|
||||
_, _, s3_mode = multi_registry.resolve("/s3/file.txt")
|
||||
_, _, disk_mode = multi_registry.resolve("/disk/file.txt")
|
||||
_, _, ram_mode = multi_registry.resolve("/ram/file.txt")
|
||||
assert s3_mode == MountMode.READ
|
||||
assert disk_mode == MountMode.WRITE
|
||||
assert ram_mode == MountMode.WRITE
|
||||
|
||||
|
||||
def test_multi_mount_resource_paths(multi_registry):
|
||||
_, pp_s3, _ = multi_registry.resolve("/s3/data/report.csv")
|
||||
_, pp_disk, _ = multi_registry.resolve("/disk/readme.txt")
|
||||
_, pp_ram, _ = multi_registry.resolve("/ram/hello.txt")
|
||||
assert pp_s3 == "/data/report.csv"
|
||||
assert pp_disk == "/readme.txt"
|
||||
assert pp_ram == "/hello.txt"
|
||||
|
||||
|
||||
# ── mount ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_mount_returns_mount_object():
|
||||
reg = MountRegistry()
|
||||
p = RAMResource()
|
||||
m = reg.mount("/test/", p, MountMode.READ)
|
||||
assert m.prefix == "/test/"
|
||||
assert m.resource is p
|
||||
|
||||
|
||||
def test_mount_normalizes_prefix():
|
||||
reg = MountRegistry()
|
||||
m = reg.mount("/test/", RAMResource(), MountMode.READ)
|
||||
assert m.prefix == "/test/"
|
||||
|
||||
|
||||
def test_mount_duplicate_raises(registry):
|
||||
with pytest.raises(ValueError, match="duplicate"):
|
||||
registry.mount("/data/", RAMResource())
|
||||
|
||||
|
||||
# ── mounts listing ─────────────────────────────
|
||||
|
||||
|
||||
def test_mounts_returns_all(multi_registry):
|
||||
prefixes = {m.prefix for m in multi_registry.mounts()}
|
||||
assert "/s3/" in prefixes
|
||||
assert "/disk/" in prefixes
|
||||
assert "/ram/" in prefixes
|
||||
assert "/dev/" in prefixes
|
||||
|
||||
|
||||
def test_mounts_count(multi_registry):
|
||||
assert len(multi_registry.mounts()) == 4
|
||||
|
||||
|
||||
# ── group_by_mount ─────────────────────────────
|
||||
|
||||
|
||||
def test_group_by_mount(multi_registry):
|
||||
groups = multi_registry.group_by_mount(
|
||||
["/s3/a.txt", "/s3/b.txt", "/disk/c.txt"])
|
||||
assert len(groups) == 2
|
||||
s3_group = [g for g in groups if g[0].prefix == "/s3/"][0]
|
||||
assert len(s3_group[1]) == 2
|
||||
disk_group = [g for g in groups if g[0].prefix == "/disk/"][0]
|
||||
assert len(disk_group[1]) == 1
|
||||
|
||||
|
||||
def test_group_by_mount_single(multi_registry):
|
||||
groups = multi_registry.group_by_mount(["/ram/hello.txt"])
|
||||
assert len(groups) == 1
|
||||
|
||||
|
||||
# ── get_resource_type ──────────────────────────
|
||||
|
||||
|
||||
def test_get_resource_type_s3(multi_registry):
|
||||
assert multi_registry.get_resource_type("/s3/file.txt") == "s3"
|
||||
|
||||
|
||||
def test_get_resource_type_disk(multi_registry):
|
||||
assert multi_registry.get_resource_type("/disk/file.txt") == "disk"
|
||||
|
||||
|
||||
def test_get_resource_type_ram(multi_registry):
|
||||
assert multi_registry.get_resource_type("/ram/file.txt") == "ram"
|
||||
|
||||
|
||||
def test_get_resource_type_none(multi_registry):
|
||||
assert multi_registry.get_resource_type(None) is None
|
||||
|
||||
|
||||
def test_get_resource_type_unknown(multi_registry):
|
||||
assert multi_registry.get_resource_type("/unknown/f") is None
|
||||
|
||||
|
||||
# ── find_resource_by_name ──────────────────────
|
||||
|
||||
|
||||
def test_find_resource_s3(multi_registry):
|
||||
prov = multi_registry.find_resource_by_name("s3")
|
||||
assert prov is not None
|
||||
assert prov.name == "s3"
|
||||
|
||||
|
||||
def test_find_resource_disk(multi_registry):
|
||||
prov = multi_registry.find_resource_by_name("disk")
|
||||
assert prov is not None
|
||||
assert prov.name == "disk"
|
||||
|
||||
|
||||
def test_find_resource_ram(multi_registry):
|
||||
prov = multi_registry.find_resource_by_name("ram")
|
||||
assert prov is not None
|
||||
|
||||
|
||||
def test_find_resource_none(multi_registry):
|
||||
assert multi_registry.find_resource_by_name(None) is None
|
||||
|
||||
|
||||
def test_find_resource_missing(multi_registry):
|
||||
assert multi_registry.find_resource_by_name("nonexistent") is None
|
||||
|
||||
|
||||
# ── mount_for_command ──────────────────────────
|
||||
|
||||
|
||||
def test_mount_for_command_cat(registry):
|
||||
mount = registry.mount_for_command("cat")
|
||||
assert mount is not None
|
||||
|
||||
|
||||
def test_mount_for_command_nonexistent(registry):
|
||||
assert registry.mount_for_command("nonexistent_cmd") is None
|
||||
|
||||
|
||||
def test_mount_for_command_grep(multi_registry):
|
||||
mount = multi_registry.mount_for_command("grep")
|
||||
assert mount is not None
|
||||
|
||||
|
||||
# ── resolve_mount cache redirect ───────────────
|
||||
|
||||
|
||||
def _remote_registry_with_cache():
|
||||
reg = MountRegistry()
|
||||
reg.mount("/ssh/", SSHResource(SSHConfig(host="example", root="/srv")),
|
||||
MountMode.WRITE)
|
||||
cache = RAMFileCacheStore()
|
||||
reg.attach_file_cache(cache)
|
||||
return reg, cache
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_mount_keeps_cached_read_on_real_mount():
|
||||
# Warm reads are served in place by with_read_cache, so a cached
|
||||
# read-only command stays on its real mount (keeping its safeguards and
|
||||
# custom handlers) instead of being redirected to the cache mount.
|
||||
reg, cache = _remote_registry_with_cache()
|
||||
await cache.set("/ssh/a.txt", b"hi")
|
||||
scope = PathSpec(resource_path="ssh/a.txt",
|
||||
virtual="/ssh/a.txt",
|
||||
directory="/ssh",
|
||||
resolved=True)
|
||||
mount = await reg.resolve_mount("cat", [scope], "/ssh")
|
||||
assert mount.prefix == "/ssh/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_mount_keeps_cached_write_on_remote():
|
||||
reg, cache = _remote_registry_with_cache()
|
||||
await cache.set("/ssh/a.txt", b"hi")
|
||||
scope = PathSpec(resource_path="ssh/a.txt",
|
||||
virtual="/ssh/a.txt",
|
||||
directory="/ssh",
|
||||
resolved=True)
|
||||
mount = await reg.resolve_mount("rm", [scope], "/ssh")
|
||||
assert mount.prefix == "/ssh/"
|
||||
|
||||
|
||||
class _LimitedResource(BaseResource):
|
||||
name = "limited"
|
||||
|
||||
|
||||
class _FallbackResource(BaseResource):
|
||||
name = "fallback"
|
||||
|
||||
|
||||
@command("fallback-only", resource="fallback", spec=CommandSpec())
|
||||
async def _fallback_only(_store, paths, *texts, **kw):
|
||||
return b"fallback", IOResult()
|
||||
|
||||
|
||||
def _path_bound_registry_with_default():
|
||||
reg = MountRegistry()
|
||||
reg.mount("/limited/", _LimitedResource(), MountMode.WRITE)
|
||||
fallback = _FallbackResource()
|
||||
fallback.register(_fallback_only)
|
||||
reg.mount("/", fallback, MountMode.WRITE)
|
||||
return reg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_mount_rejects_path_bound_unsupported_command():
|
||||
reg = _path_bound_registry_with_default()
|
||||
scope = PathSpec(resource_path="limited/file.txt",
|
||||
virtual="/limited/file.txt",
|
||||
directory="/limited",
|
||||
resolved=True)
|
||||
with pytest.raises(
|
||||
MountCommandUnsupported,
|
||||
match="fallback-only: /limited/file.txt: Operation not supported"):
|
||||
await reg.resolve_mount("fallback-only", [scope], "/limited")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_mount_allows_default_without_path_binding():
|
||||
reg = _path_bound_registry_with_default()
|
||||
mount = await reg.resolve_mount("fallback-only", [], "/limited")
|
||||
assert mount is not None
|
||||
assert mount.prefix == "/"
|
||||
@@ -0,0 +1,39 @@
|
||||
# ========= 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 mirage.resource.ssh import SSHConfig, SSHResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace.mount.registry import MountRegistry
|
||||
|
||||
|
||||
def test_ssh_mount_registration():
|
||||
registry = MountRegistry()
|
||||
cfg = SSHConfig(host="dev", root="/home/ubuntu")
|
||||
resource = SSHResource(cfg)
|
||||
registry.mount("/ssh/", resource, mode=MountMode.WRITE)
|
||||
mount = registry.mount_for("/ssh/some/file.txt")
|
||||
assert mount is not None
|
||||
assert mount.resource.name == "ssh"
|
||||
|
||||
|
||||
def test_ssh_mount_command_resolution():
|
||||
registry = MountRegistry()
|
||||
cfg = SSHConfig(host="dev")
|
||||
resource = SSHResource(cfg)
|
||||
registry.mount("/remote/", resource, mode=MountMode.WRITE)
|
||||
mount = registry.mount_for("/remote/test.py")
|
||||
assert mount is not None
|
||||
cmd = mount.resolve_command("cat", None)
|
||||
assert cmd is not None
|
||||
assert cmd.resource == "ssh"
|
||||
@@ -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. =========
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
# ========= 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 import MountMode, RAMResource, Workspace
|
||||
|
||||
|
||||
async def _workspace() -> Workspace:
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("mkdir -p /data/sub")
|
||||
await ws.execute("echo hi > /data/sub/x.txt")
|
||||
await ws.execute("cd /data")
|
||||
return ws
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_error_respells_relative_operand():
|
||||
# cat of a directory errors on the first lazy pull, past the eager
|
||||
# chokepoint; the drain must still report the operand as typed.
|
||||
ws = await _workspace()
|
||||
io = await ws.execute("cat sub")
|
||||
assert io.exit_code == 1
|
||||
err = (io.stderr or b"").decode()
|
||||
assert err.startswith("cat: sub: ")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_error_keeps_absolute_operand():
|
||||
ws = await _workspace()
|
||||
io = await ws.execute("cat /data/sub")
|
||||
assert io.exit_code == 1
|
||||
err = (io.stderr or b"").decode()
|
||||
assert err.startswith("cat: /data/sub: ")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eager_error_respells_relative_operand():
|
||||
ws = await _workspace()
|
||||
io = await ws.execute("cat sub/missing.txt")
|
||||
assert io.exit_code == 1
|
||||
assert (io.stderr or b"").decode() == (
|
||||
"cat: sub/missing.txt: No such file or directory\n")
|
||||
@@ -0,0 +1,125 @@
|
||||
# ========= 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.io import IOResult
|
||||
from mirage.io.types import materialize
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.shell.job_table import JobTable
|
||||
from mirage.shell.parse import parse
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace.mount import MountRegistry
|
||||
from mirage.workspace.mount.namespace import Namespace
|
||||
from mirage.workspace.node import run_command_tree as _run_command_tree
|
||||
from mirage.workspace.session import Session
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
def run_command_tree(dispatch, registry, *args, **kwargs):
|
||||
return _run_command_tree(dispatch, registry, Namespace(registry), *args,
|
||||
**kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry():
|
||||
"""Minimal registry with a RAM mount at root."""
|
||||
reg = MountRegistry()
|
||||
res = RAMResource()
|
||||
reg.mount("/", res, MountMode.WRITE)
|
||||
return reg
|
||||
|
||||
|
||||
async def _dispatch_noop(op, path, **kwargs):
|
||||
return None, IOResult()
|
||||
|
||||
|
||||
async def _noop_execute(command, **kwargs):
|
||||
return IOResult()
|
||||
|
||||
|
||||
def _session():
|
||||
return Session(session_id="test", cwd="/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_command_tree_materializes_stdout(registry):
|
||||
ast = parse("echo hello")
|
||||
io, exec_node = await run_command_tree(
|
||||
_dispatch_noop,
|
||||
registry,
|
||||
JobTable(),
|
||||
_noop_execute,
|
||||
"agent",
|
||||
ast,
|
||||
_session(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert io.exit_code == 0
|
||||
assert b"hello" in await materialize(io.stdout)
|
||||
assert exec_node is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_command_tree_propagates_exit_code(registry):
|
||||
ast = parse("false")
|
||||
io, _ = await run_command_tree(
|
||||
_dispatch_noop,
|
||||
registry,
|
||||
JobTable(),
|
||||
_noop_execute,
|
||||
"agent",
|
||||
ast,
|
||||
_session(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert io.exit_code != 0
|
||||
|
||||
|
||||
async def _cross_node(cmd: str):
|
||||
# A real two-mount workspace wires dispatch/cache; run_command_tree is the
|
||||
# seam returning the recorded ExecutionNode (Workspace.execute drops it).
|
||||
ws = Workspace({
|
||||
"/a": RAMResource(),
|
||||
"/b": RAMResource()
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("mkdir -p /a/dir")
|
||||
await ws.execute("printf 'x\\n' > /a/f.txt")
|
||||
io, exec_node = await run_command_tree(ws.dispatch, ws._registry,
|
||||
ws.job_table, _noop_execute,
|
||||
"agent", parse(cmd),
|
||||
Session(session_id="t",
|
||||
cwd="/"), None, None)
|
||||
return io, exec_node
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_exec_node_records_stderr():
|
||||
# cp of a directory without -r across mounts: the cross-mount branch builds
|
||||
# the node via _exec_node, so its stderr/exit_code must match io.
|
||||
io, exec_node = await _cross_node("cp /a/dir /b/x")
|
||||
assert io.exit_code == 1
|
||||
assert b"omitting directory" in await materialize(io.stderr)
|
||||
assert exec_node.exit_code == 1
|
||||
assert b"omitting directory" in (exec_node.stderr or b"")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_exec_node_success_has_no_stderr():
|
||||
io, exec_node = await _cross_node("cp /a/f.txt /b/f.txt")
|
||||
assert exec_node.exit_code == 0
|
||||
assert not (exec_node.stderr or b"")
|
||||
@@ -0,0 +1,82 @@
|
||||
# ========= 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.provision import Precision, ProvisionResult
|
||||
from mirage.workspace.provision.control import (handle_for_provision,
|
||||
handle_if_provision,
|
||||
handle_while_provision)
|
||||
|
||||
COSTS = {
|
||||
"cond":
|
||||
ProvisionResult(network_read_low=24,
|
||||
network_read_high=24,
|
||||
read_ops=1,
|
||||
precision=Precision.EXACT),
|
||||
"then":
|
||||
ProvisionResult(network_read_low=6,
|
||||
network_read_high=6,
|
||||
read_ops=1,
|
||||
precision=Precision.EXACT),
|
||||
"else":
|
||||
ProvisionResult(network_read_low=12,
|
||||
network_read_high=12,
|
||||
read_ops=1,
|
||||
precision=Precision.EXACT),
|
||||
"free":
|
||||
ProvisionResult(precision=Precision.EXACT),
|
||||
}
|
||||
|
||||
|
||||
async def _node(node, session) -> ProvisionResult:
|
||||
return COSTS[node]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_if_sums_condition_with_each_branch():
|
||||
result = await handle_if_provision(_node, [("cond", ["then"])], ["else"],
|
||||
session=None)
|
||||
# then-path: 24 + 6 = 30; else-path: 24 + 12 = 36
|
||||
assert result.network_read_low == 30
|
||||
assert result.network_read_high == 36
|
||||
assert result.precision == Precision.RANGE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_if_without_else_pays_the_conditions():
|
||||
result = await handle_if_provision(_node, [("cond", ["then"])],
|
||||
None,
|
||||
session=None)
|
||||
# then-path: 24 + 6 = 30; fall-through still stats the condition: 24
|
||||
assert result.network_read_low == 24
|
||||
assert result.network_read_high == 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_if_elif_ladder_accumulates_conditions():
|
||||
branches = [("cond", ["free"]), ("cond", ["then"])]
|
||||
result = await handle_if_provision(_node, branches, None, session=None)
|
||||
# branch1: 24; branch2: 24 + 24 + 6 = 54; fall-through: 48
|
||||
assert result.network_read_low == 24
|
||||
assert result.network_read_high == 54
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_for_scales_and_while_is_unknown():
|
||||
result = await handle_for_provision(_node, ["then"], 3, session=None)
|
||||
assert result.network_read_low == 18
|
||||
assert result.precision == Precision.EXACT
|
||||
result = await handle_while_provision(_node, ["then"], session=None)
|
||||
assert result.precision == Precision.UNKNOWN
|
||||
@@ -0,0 +1,176 @@
|
||||
# ========= 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 import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.shell.node_kind import NodeKind
|
||||
|
||||
# Drift guard: every statement kind the executor supports must have a
|
||||
# pinned provision expectation here. The map must cover the full enum
|
||||
# (asserted below), so adding a NodeKind forces deciding what the
|
||||
# planner reports for it; a construct can no longer be supported by
|
||||
# the executor and silently mis-planned.
|
||||
#
|
||||
# The seeded file is 24 bytes. Expectations are
|
||||
# (snippet, network_read, network_write, precision).
|
||||
PLANS = {
|
||||
NodeKind.COMMENT: ("# a comment", "0", "0", "exact"),
|
||||
NodeKind.PROGRAM: ("cat /data/a.txt; cat /data/a.txt", "48", "0", "exact"),
|
||||
NodeKind.COMMAND: ("cat /data/a.txt", "24", "0", "exact"),
|
||||
NodeKind.PIPELINE: ("cat /data/a.txt | wc -l", "24", "0", "exact"),
|
||||
NodeKind.LIST: ("cat /data/a.txt && cat /data/a.txt", "48", "0", "exact"),
|
||||
NodeKind.REDIRECT:
|
||||
("cat /data/a.txt > /data/out.txt", "24", "0-24", "range"),
|
||||
NodeKind.SUBSHELL: ("(cat /data/a.txt)", "24", "0", "exact"),
|
||||
NodeKind.COMPOUND: ("{ cat /data/a.txt; }", "24", "0", "exact"),
|
||||
NodeKind.IF: ("if true; then cat /data/a.txt; fi", "0-24", "0", "range"),
|
||||
NodeKind.FOR:
|
||||
("for i in 1 2; do cat /data/a.txt; done", "48", "0", "exact"),
|
||||
NodeKind.SELECT:
|
||||
("select x in a b; do cat /data/a.txt; done", "24", "0", "unknown"),
|
||||
NodeKind.WHILE:
|
||||
("while true; do cat /data/a.txt; done", "24", "0", "unknown"),
|
||||
NodeKind.UNTIL: ("until false; do cat /data/a.txt; done", "24", "0",
|
||||
"unknown"),
|
||||
NodeKind.CASE: ("case x in x) cat /data/a.txt;; esac", "24", "0", "range"),
|
||||
NodeKind.FUNCTION_DEF: ("f() { cat /data/a.txt; }", "0", "0", "exact"),
|
||||
NodeKind.DECLARATION: ("export FOO=1", "0", "0", "exact"),
|
||||
NodeKind.UNSET: ("unset FOO", "0", "0", "exact"),
|
||||
NodeKind.TEST: ("[[ -n x ]]", "0", "0", "exact"),
|
||||
NodeKind.NEGATED: ("! grep zzz /data/a.txt", "24", "0", "exact"),
|
||||
NodeKind.VAR_ASSIGN: ("FOO=1", "0", "0", "exact"),
|
||||
NodeKind.UNSUPPORTED: ("for ((i=0;i<2;i++)); do true; done", "0", "0",
|
||||
"unknown"),
|
||||
}
|
||||
|
||||
|
||||
def test_plans_cover_the_full_enum():
|
||||
assert set(PLANS) == set(NodeKind)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", list(NodeKind))
|
||||
async def test_every_kind_plans(kind):
|
||||
snippet, net, write, precision = PLANS[kind]
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"x" * 24)
|
||||
result = await ws.execute(snippet, provision=True)
|
||||
assert result.network_read == net, kind
|
||||
assert result.network_write == write, kind
|
||||
assert result.precision.value == precision, kind
|
||||
await ws.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_call_and_env_prefix_plan():
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"x" * 24)
|
||||
result = await ws.execute("f() { cat /data/a.txt; }; f", provision=True)
|
||||
assert result.network_read == "24"
|
||||
assert result.precision.value == "exact"
|
||||
result = await ws.execute("f() { f; }; f", provision=True)
|
||||
assert result.precision.value == "unknown"
|
||||
result = await ws.execute("FOO=1 cat /data/a.txt", provision=True)
|
||||
assert result.network_read == "24"
|
||||
assert result.precision.value == "exact"
|
||||
result = await ws.execute("eval 'cat /data/a.txt'", provision=True)
|
||||
assert result.precision.value == "unknown"
|
||||
result = await ws.execute("wc -l < /data/a.txt", provision=True)
|
||||
assert result.network_read == "24"
|
||||
result = await ws.execute("cat /data/a.txt > /dev/null", provision=True)
|
||||
assert result.network_write == "0"
|
||||
assert result.precision.value == "exact"
|
||||
await ws.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_follows_symlinks_and_spans_mounts():
|
||||
ws = Workspace({
|
||||
"/data": RAMResource(),
|
||||
"/data2": RAMResource()
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"x" * 24)
|
||||
await ws.execute("tee /data2/b.txt > /dev/null", stdin=b"y" * 6)
|
||||
await ws.execute("ln -s /data/a.txt /data2/lnk.txt")
|
||||
result = await ws.execute("cat /data2/lnk.txt", provision=True)
|
||||
assert result.network_read == "24"
|
||||
assert result.precision.value == "exact"
|
||||
result = await ws.execute("cat /data/a.txt /data2/b.txt", provision=True)
|
||||
assert result.network_read == "30"
|
||||
assert result.read_ops == 2
|
||||
assert result.precision.value == "exact"
|
||||
result = await ws.execute("cat /data2/b.txt /data/a.txt", provision=True)
|
||||
assert result.network_read == "30"
|
||||
await ws.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_is_dry_and_case_arms_run_fully():
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"x" * 24)
|
||||
# a dry run must not execute command substitutions
|
||||
result = await ws.execute(
|
||||
"cat $(tee /data/leak.txt > /dev/null; echo /data/a.txt)",
|
||||
provision=True)
|
||||
assert result.precision.value == "unknown"
|
||||
listing = await (await ws.execute("ls /data")).stdout_str()
|
||||
assert "leak.txt" not in listing
|
||||
# a case arm runs every statement up to its ;; terminator
|
||||
out = await (
|
||||
await
|
||||
ws.execute("case x in x) echo one; echo two;; esac")).stdout_str()
|
||||
assert out == "one\ntwo\n"
|
||||
result = await ws.execute(
|
||||
"case x in x) cat /data/a.txt; cat /data/a.txt;; esac", provision=True)
|
||||
assert result.network_read == "48"
|
||||
# sed reads its operands; -i degrades to a floor
|
||||
result = await ws.execute("sed s/x/y/ /data/a.txt", provision=True)
|
||||
assert result.network_read == "24"
|
||||
assert result.precision.value == "exact"
|
||||
result = await ws.execute("sed -i s/x/y/ /data/a.txt", provision=True)
|
||||
assert result.precision.value == "unknown"
|
||||
await ws.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdin_driven_and_expanded_estimates():
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"x" * 24)
|
||||
await ws.execute("mkdir /data/tree")
|
||||
await ws.execute("tee /data/tree/b.txt > /dev/null", stdin=b"y" * 10)
|
||||
# heredoc-fed stdin is local bytes: exact zero backend I/O
|
||||
result = await ws.execute("wc -l <<EOF\nabc\nEOF", provision=True)
|
||||
assert result.network_read == "0"
|
||||
assert result.precision.value == "exact"
|
||||
# globs expand during planning
|
||||
result = await ws.execute("cat /data/tree/*.txt", provision=True)
|
||||
assert result.network_read == "10"
|
||||
assert result.precision.value == "exact"
|
||||
# recursive search walks the tree
|
||||
result = await ws.execute("grep -r y /data/tree", provision=True)
|
||||
assert result.network_read == "10"
|
||||
assert result.precision.value == "exact"
|
||||
# a suppressed substitution degrades the loop count to a floor
|
||||
result = await ws.execute("for i in $(echo 1 2); do cat /data/a.txt; done",
|
||||
provision=True)
|
||||
assert result.network_read == "24"
|
||||
assert result.precision.value == "unknown"
|
||||
# a suppressed substitution hides the redirect target
|
||||
result = await ws.execute("cat /data/a.txt > $(echo /data/out.txt)",
|
||||
provision=True)
|
||||
assert result.precision.value == "unknown"
|
||||
await ws.close()
|
||||
@@ -0,0 +1,78 @@
|
||||
# ========= 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 mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.route import SHELL_CONSUMERS, Consumer, route
|
||||
from mirage.workspace.session import Session
|
||||
|
||||
|
||||
def _fixture() -> tuple[Session, Workspace]:
|
||||
ws = Workspace(resources={"/ram": (RAMResource(), MountMode.WRITE)})
|
||||
return Session(session_id="t"), ws
|
||||
|
||||
|
||||
def test_builtins_route_session():
|
||||
session, ws = _fixture()
|
||||
for name in ("cd", "echo", "export", "history", "test", "xargs"):
|
||||
assert route(name, session, ws._registry) is Consumer.SESSION
|
||||
|
||||
|
||||
def test_unsupported_builtins_route_session():
|
||||
session, ws = _fixture()
|
||||
assert route("exec", session, ws._registry) is Consumer.SESSION
|
||||
|
||||
|
||||
def test_namespace_commands():
|
||||
session, ws = _fixture()
|
||||
assert route("ln", session, ws._registry) is Consumer.NAMESPACE
|
||||
assert route("readlink", session, ws._registry) is Consumer.NAMESPACE
|
||||
|
||||
|
||||
def test_function_routes_function():
|
||||
session, ws = _fixture()
|
||||
session.functions["greet"] = []
|
||||
assert route("greet", session, ws._registry) is Consumer.FUNCTION
|
||||
|
||||
|
||||
def test_builtin_shadows_function():
|
||||
session, ws = _fixture()
|
||||
session.functions["echo"] = []
|
||||
assert route("echo", session, ws._registry) is Consumer.SESSION
|
||||
|
||||
|
||||
def test_function_shadows_mount_command():
|
||||
session, ws = _fixture()
|
||||
session.functions["cat"] = []
|
||||
assert route("cat", session, ws._registry) is Consumer.FUNCTION
|
||||
|
||||
|
||||
def test_mount_command_routes_mount():
|
||||
session, ws = _fixture()
|
||||
assert route("cat", session, ws._registry) is Consumer.MOUNT
|
||||
assert route("grep", session, ws._registry) is Consumer.MOUNT
|
||||
|
||||
|
||||
def test_unregistered_name_routes_unknown():
|
||||
session, ws = _fixture()
|
||||
assert route("nosuchcmd", session, ws._registry) is Consumer.UNKNOWN
|
||||
|
||||
|
||||
def test_shell_consumers_resolve_globs():
|
||||
assert Consumer.SESSION in SHELL_CONSUMERS
|
||||
assert Consumer.NAMESPACE in SHELL_CONSUMERS
|
||||
assert Consumer.FUNCTION in SHELL_CONSUMERS
|
||||
assert Consumer.MOUNT not in SHELL_CONSUMERS
|
||||
assert Consumer.UNKNOWN not in SHELL_CONSUMERS
|
||||
@@ -0,0 +1,36 @@
|
||||
# ========= 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 mirage.workspace.route import (SHELL_CONSUMERS, Consumer, WordPolicy,
|
||||
word_policy)
|
||||
|
||||
|
||||
def test_shell_consumers_get_shell_policy():
|
||||
for consumer in SHELL_CONSUMERS:
|
||||
assert word_policy(consumer) is WordPolicy.SHELL
|
||||
|
||||
|
||||
def test_mount_gets_mount_policy():
|
||||
assert word_policy(Consumer.MOUNT) is WordPolicy.MOUNT
|
||||
|
||||
|
||||
def test_unknown_gets_mount_policy():
|
||||
# Unknown names keep patterns intact: nothing resolves their words,
|
||||
# the command fails before any backend I/O.
|
||||
assert word_policy(Consumer.UNKNOWN) is WordPolicy.MOUNT
|
||||
|
||||
|
||||
def test_every_consumer_has_a_policy():
|
||||
for consumer in Consumer:
|
||||
assert isinstance(word_policy(consumer), WordPolicy)
|
||||
@@ -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,136 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.workspace.session import SessionManager
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def test_manager_default_session():
|
||||
mgr = SessionManager("default")
|
||||
s = mgr.get("default")
|
||||
assert s.session_id == "default"
|
||||
|
||||
|
||||
def test_manager_default_cwd():
|
||||
mgr = SessionManager("default")
|
||||
assert mgr.cwd == "/"
|
||||
mgr.cwd = "/data"
|
||||
assert mgr.cwd == "/data"
|
||||
assert mgr.get("default").cwd == "/data"
|
||||
|
||||
|
||||
def test_manager_default_env():
|
||||
mgr = SessionManager("default")
|
||||
assert mgr.env == {}
|
||||
mgr.env = {"A": "1"}
|
||||
assert mgr.env == {"A": "1"}
|
||||
assert mgr.get("default").env == {"A": "1"}
|
||||
|
||||
|
||||
def test_manager_create_session():
|
||||
mgr = SessionManager("default")
|
||||
s = mgr.create("worker-1")
|
||||
assert s.session_id == "worker-1"
|
||||
assert mgr.get("worker-1") is s
|
||||
|
||||
|
||||
def test_manager_create_duplicate_raises():
|
||||
mgr = SessionManager("default")
|
||||
mgr.create("s1")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
mgr.create("s1")
|
||||
|
||||
|
||||
def test_manager_get_missing_raises():
|
||||
mgr = SessionManager("default")
|
||||
with pytest.raises(KeyError):
|
||||
mgr.get("nonexistent")
|
||||
|
||||
|
||||
def test_manager_list_sessions():
|
||||
mgr = SessionManager("default")
|
||||
mgr.create("s1")
|
||||
mgr.create("s2")
|
||||
sessions = mgr.list()
|
||||
ids = {s.session_id for s in sessions}
|
||||
assert ids == {"default", "s1", "s2"}
|
||||
|
||||
|
||||
def test_manager_close_session():
|
||||
mgr = SessionManager("default")
|
||||
mgr.create("temp")
|
||||
_run(mgr.close("temp"))
|
||||
with pytest.raises(KeyError):
|
||||
mgr.get("temp")
|
||||
|
||||
|
||||
def test_manager_close_default_raises():
|
||||
mgr = SessionManager("default")
|
||||
with pytest.raises(ValueError, match="Cannot close"):
|
||||
_run(mgr.close("default"))
|
||||
|
||||
|
||||
def test_manager_close_missing_raises():
|
||||
mgr = SessionManager("default")
|
||||
with pytest.raises(KeyError):
|
||||
_run(mgr.close("nonexistent"))
|
||||
|
||||
|
||||
def test_manager_close_all():
|
||||
mgr = SessionManager("default")
|
||||
mgr.create("s1")
|
||||
mgr.create("s2")
|
||||
_run(mgr.close_all())
|
||||
sessions = mgr.list()
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0].session_id == "default"
|
||||
|
||||
|
||||
def test_manager_sessions_isolated():
|
||||
mgr = SessionManager("default")
|
||||
s1 = mgr.create("s1")
|
||||
s2 = mgr.create("s2")
|
||||
s1.env["X"] = "from-s1"
|
||||
s1.cwd = "/s1"
|
||||
assert "X" not in s2.env
|
||||
assert s2.cwd == "/"
|
||||
|
||||
|
||||
def test_manager_lock_for():
|
||||
mgr = SessionManager("default")
|
||||
lock = mgr.lock_for("default")
|
||||
assert lock is not None
|
||||
|
||||
mgr.create("s1")
|
||||
lock2 = mgr.lock_for("s1")
|
||||
assert lock2 is not lock
|
||||
|
||||
|
||||
def test_manager_create_with_allowed_mounts():
|
||||
mgr = SessionManager("default")
|
||||
s = mgr.create("agent", allowed_mounts=frozenset({"/s3", "/slack"}))
|
||||
assert s.allowed_mounts == frozenset({"/s3", "/slack"})
|
||||
|
||||
|
||||
def test_manager_create_default_unrestricted():
|
||||
mgr = SessionManager("default")
|
||||
s = mgr.create("worker")
|
||||
assert s.allowed_mounts is None
|
||||
@@ -0,0 +1,157 @@
|
||||
# ========= 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 mirage.workspace.session import Session
|
||||
|
||||
|
||||
def test_session_defaults():
|
||||
s = Session(session_id="test")
|
||||
assert s.session_id == "test"
|
||||
assert s.cwd == "/"
|
||||
assert s.env == {}
|
||||
assert s.functions == {}
|
||||
assert s.last_exit_code == 0
|
||||
assert s._stdin_buffer is None
|
||||
|
||||
|
||||
def test_session_custom_cwd():
|
||||
s = Session(session_id="s1", cwd="/data")
|
||||
assert s.cwd == "/data"
|
||||
|
||||
|
||||
def test_session_env():
|
||||
s = Session(session_id="s1", env={"A": "1", "B": "2"})
|
||||
assert s.env["A"] == "1"
|
||||
assert s.env["B"] == "2"
|
||||
|
||||
|
||||
def test_session_env_mutation():
|
||||
s = Session(session_id="s1")
|
||||
s.env["X"] = "hello"
|
||||
assert s.env["X"] == "hello"
|
||||
del s.env["X"]
|
||||
assert "X" not in s.env
|
||||
|
||||
|
||||
def test_session_functions():
|
||||
s = Session(session_id="s1")
|
||||
s.functions["myfunc"] = "body"
|
||||
assert "myfunc" in s.functions
|
||||
|
||||
|
||||
def test_session_exit_code():
|
||||
s = Session(session_id="s1")
|
||||
s.last_exit_code = 42
|
||||
assert s.last_exit_code == 42
|
||||
|
||||
|
||||
def test_session_stdin_buffer():
|
||||
s = Session(session_id="s1")
|
||||
s._stdin_buffer = b"hello\n"
|
||||
assert s._stdin_buffer == b"hello\n"
|
||||
s._stdin_buffer = None
|
||||
assert s._stdin_buffer is None
|
||||
|
||||
|
||||
def test_session_to_dict():
|
||||
s = Session(session_id="s1", cwd="/data", env={"K": "V"})
|
||||
d = s.to_dict()
|
||||
assert d["session_id"] == "s1"
|
||||
assert d["cwd"] == "/data"
|
||||
assert d["env"] == {"K": "V"}
|
||||
assert "created_at" in d
|
||||
|
||||
|
||||
def test_session_from_dict():
|
||||
d = {
|
||||
"session_id": "s2",
|
||||
"cwd": "/tmp",
|
||||
"env": {
|
||||
"A": "1"
|
||||
},
|
||||
"created_at": 123.0
|
||||
}
|
||||
s = Session.from_dict(d)
|
||||
assert s.session_id == "s2"
|
||||
assert s.cwd == "/tmp"
|
||||
assert s.env["A"] == "1"
|
||||
assert s.created_at == 123.0
|
||||
|
||||
|
||||
def test_session_roundtrip():
|
||||
original = Session(session_id="rt", cwd="/x", env={"K": "V"})
|
||||
restored = Session.from_dict(original.to_dict())
|
||||
assert restored.session_id == original.session_id
|
||||
assert restored.cwd == original.cwd
|
||||
assert restored.env == original.env
|
||||
|
||||
|
||||
def test_session_independent_envs():
|
||||
s1 = Session(session_id="a")
|
||||
s2 = Session(session_id="b")
|
||||
s1.env["X"] = "1"
|
||||
assert "X" not in s2.env
|
||||
|
||||
|
||||
def test_session_allowed_mounts_default_none():
|
||||
s = Session(session_id="s")
|
||||
assert s.allowed_mounts is None
|
||||
|
||||
|
||||
def test_session_allowed_mounts_set():
|
||||
s = Session(session_id="s", allowed_mounts=frozenset({"/s3", "/slack"}))
|
||||
assert s.allowed_mounts == frozenset({"/s3", "/slack"})
|
||||
|
||||
|
||||
def test_fork_copies_every_field_including_allowed_mounts():
|
||||
original = Session(
|
||||
session_id="orig",
|
||||
cwd="/disk",
|
||||
env={"FOO": "bar"},
|
||||
functions={"f": object()},
|
||||
last_exit_code=7,
|
||||
shell_options={"errexit": True},
|
||||
readonly_vars={"HOME"},
|
||||
arrays={"ARGV": ["a", "b"]},
|
||||
allowed_mounts=frozenset({"/s3", "/dev", "/"}),
|
||||
)
|
||||
forked = original.fork()
|
||||
assert forked.session_id == "orig"
|
||||
assert forked.cwd == "/disk"
|
||||
assert forked.env == {"FOO": "bar"}
|
||||
assert forked.allowed_mounts == frozenset({"/s3", "/dev", "/"})
|
||||
assert forked.shell_options == {"errexit": True}
|
||||
assert "HOME" in forked.readonly_vars
|
||||
assert forked.arrays == {"ARGV": ["a", "b"]}
|
||||
assert forked.last_exit_code == 7
|
||||
|
||||
|
||||
def test_fork_overrides_apply_without_mutating_original():
|
||||
original = Session(session_id="orig", cwd="/disk", env={"FOO": "bar"})
|
||||
forked = original.fork(cwd="/ram", env={"BAZ": "qux"})
|
||||
assert forked.cwd == "/ram"
|
||||
assert forked.env == {"BAZ": "qux"}
|
||||
assert original.cwd == "/disk"
|
||||
assert original.env == {"FOO": "bar"}
|
||||
|
||||
|
||||
def test_fork_deep_copies_mutable_containers():
|
||||
original = Session(session_id="orig",
|
||||
env={"FOO": "bar"},
|
||||
arrays={"A": ["1"]})
|
||||
forked = original.fork()
|
||||
forked.env["NEW"] = "leaked?"
|
||||
forked.arrays["A"].append("2")
|
||||
assert "NEW" not in original.env
|
||||
assert original.arrays["A"] == ["1"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# ========= 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 mirage.workspace.session.session import Session
|
||||
from mirage.workspace.session.shell_dirs import change_dir, home_dir
|
||||
|
||||
|
||||
def test_home_dir_unset_is_none():
|
||||
session = Session(session_id="s")
|
||||
assert home_dir(session) is None
|
||||
|
||||
|
||||
def test_home_dir_from_env():
|
||||
session = Session(session_id="s", env={"HOME": "/data"})
|
||||
assert home_dir(session) == "/data"
|
||||
|
||||
|
||||
def test_home_dir_empty_env_is_none():
|
||||
session = Session(session_id="s", env={"HOME": ""})
|
||||
assert home_dir(session) is None
|
||||
|
||||
|
||||
def test_change_dir_sets_cwd_and_oldpwd():
|
||||
session = Session(session_id="s", cwd="/data")
|
||||
change_dir(session, "/data/sub")
|
||||
assert session.cwd == "/data/sub"
|
||||
assert session.env["OLDPWD"] == "/data"
|
||||
|
||||
|
||||
def test_change_dir_overwrites_oldpwd():
|
||||
session = Session(session_id="s", cwd="/a")
|
||||
change_dir(session, "/b")
|
||||
change_dir(session, "/c")
|
||||
assert session.cwd == "/c"
|
||||
assert session.env["OLDPWD"] == "/b"
|
||||
@@ -0,0 +1,108 @@
|
||||
# ========= 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 asyncio
|
||||
from collections.abc import Iterator
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
from moto.server import ThreadedMotoServer
|
||||
|
||||
from mirage.resource.s3.s3 import S3Config, S3Resource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
CREDS = dict(aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing",
|
||||
region_name="us-east-1")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def s3_endpoint() -> Iterator[str]:
|
||||
server = ThreadedMotoServer(ip_address="127.0.0.1", port=0, verbose=False)
|
||||
server.start()
|
||||
host, port = server.get_host_and_port()
|
||||
yield f"http://{host}:{port}"
|
||||
server.stop()
|
||||
|
||||
|
||||
def _s3_workspace(endpoint: str, bucket: str) -> Workspace:
|
||||
boto3.client("s3", endpoint_url=endpoint,
|
||||
**CREDS).create_bucket(Bucket=bucket)
|
||||
s3 = S3Resource(
|
||||
S3Config(bucket=bucket,
|
||||
region="us-east-1",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing",
|
||||
path_style=True))
|
||||
return Workspace({"/data": s3}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
async def _exec(ws: Workspace, cmd: str) -> tuple[int, str, str]:
|
||||
result = await ws.execute(cmd)
|
||||
out = await result.stdout_str()
|
||||
err = await result.stderr_str()
|
||||
return result.exit_code, out, err
|
||||
|
||||
|
||||
async def _gzip_roundtrip_interleaved_ls(
|
||||
ws: Workspace) -> tuple[int, str, str]:
|
||||
cmd = ("echo two | tee /data/arch/h.txt > /dev/null"
|
||||
" && gzip /data/arch/h.txt"
|
||||
" && ls /data/arch"
|
||||
" && gunzip /data/arch/h.txt.gz"
|
||||
" && cat /data/arch/h.txt")
|
||||
return await _exec(ws, cmd)
|
||||
|
||||
|
||||
def test_gzip_roundtrip_with_interleaved_ls(s3_endpoint):
|
||||
ws = _s3_workspace(s3_endpoint, "bucket-gzip-ls")
|
||||
code, out, err = asyncio.run(_gzip_roundtrip_interleaved_ls(ws))
|
||||
assert code == 0, f"exit {code}, stderr: {err!r}"
|
||||
assert "two" in out
|
||||
|
||||
|
||||
async def _overwrite_then_ls(ws: Workspace) -> tuple[int, str, str]:
|
||||
setup = await _exec(
|
||||
ws, "echo one | tee /data/arch/a.txt > /dev/null"
|
||||
" && ls /data/arch")
|
||||
assert setup[0] == 0, setup
|
||||
code, out, err = await _exec(
|
||||
ws, "echo two | tee /data/arch/b.txt > /dev/null && ls /data/arch")
|
||||
return code, out, err
|
||||
|
||||
|
||||
def test_ls_sees_file_created_after_listing(s3_endpoint):
|
||||
ws = _s3_workspace(s3_endpoint, "bucket-ls-create")
|
||||
code, out, err = asyncio.run(_overwrite_then_ls(ws))
|
||||
assert code == 0, f"exit {code}, stderr: {err!r}"
|
||||
assert "b.txt" in out
|
||||
|
||||
|
||||
async def _rm_then_stat(ws: Workspace) -> tuple[int, str, str]:
|
||||
setup = await _exec(
|
||||
ws, "echo gone | tee /data/arch/c.txt > /dev/null"
|
||||
" && ls /data/arch")
|
||||
assert setup[0] == 0, setup
|
||||
rm = await _exec(ws, "rm /data/arch/c.txt")
|
||||
assert rm[0] == 0, rm
|
||||
return await _exec(ws, "ls /data/arch")
|
||||
|
||||
|
||||
def test_ls_does_not_show_removed_file(s3_endpoint):
|
||||
ws = _s3_workspace(s3_endpoint, "bucket-rm-stat")
|
||||
code, out, err = asyncio.run(_rm_then_stat(ws))
|
||||
assert code == 0, f"exit {code}, stderr: {err!r}"
|
||||
assert "c.txt" not in out
|
||||
@@ -0,0 +1,97 @@
|
||||
# ========= 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.commands.registry import command
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.core.disk.glob import resolve_glob
|
||||
from mirage.core.disk.read import read_bytes
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import ConsistencyPolicy, MountMode, PathSpec
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@command("stat", resource="disk", spec=SPECS["stat"], filetype=".zzz")
|
||||
async def stat_zzz_disk(
|
||||
accessor,
|
||||
paths: list[PathSpec],
|
||||
*texts: str,
|
||||
stdin=None,
|
||||
index=None,
|
||||
**_extra: object,
|
||||
) -> tuple[bytes | None, IOResult]:
|
||||
paths = await resolve_glob(accessor, paths, index)
|
||||
raw = await read_bytes(accessor, paths[0])
|
||||
return b"CUSTOM DISK STAT %d\n" % len(raw), IOResult(
|
||||
reads={paths[0].mount_path: raw}, cache=[paths[0].mount_path])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_decoupled_from_root_mount():
|
||||
"""The file cache is a hidden store reached via ``registry.file_cache``,
|
||||
not the virtual root mount's resource. When no ``/`` is mounted the root
|
||||
is an ordinary empty RAM mount at ``/`` (a normal entry in ``_mounts``)
|
||||
and never holds the cache."""
|
||||
ws = Workspace({"/data/": RAMResource()}, mode=MountMode.WRITE)
|
||||
assert ws._registry.file_cache is ws.cache
|
||||
assert ws._registry.root_mount.resource is not ws.cache
|
||||
assert ws._registry.root_mount.resource.caches_reads is False
|
||||
assert ws._registry.root_mount.prefix == "/"
|
||||
assert ws._registry.root_mount in ws._registry.mounts()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warm_read_stays_on_real_mount(tmp_path):
|
||||
"""Read-through: the second (cached) read still runs the REAL mount's
|
||||
command. The cache is a hidden store, not a mount, so a warm read serves
|
||||
the cached bytes while the command stays on its real mount and keeps its
|
||||
custom handler."""
|
||||
(tmp_path / "example.zzz").write_bytes(b"payload")
|
||||
disk = DiskResource(root=str(tmp_path))
|
||||
disk.caches_reads = True
|
||||
ws = Workspace({"/": disk}, mode=MountMode.READ)
|
||||
ws.mount("/").register_fns([stat_zzz_disk])
|
||||
|
||||
first = await ws.execute("stat /example.zzz")
|
||||
second = await ws.execute("stat /example.zzz")
|
||||
assert "CUSTOM DISK STAT" in (await first.stdout_str())
|
||||
assert "CUSTOM DISK STAT" in (await second.stdout_str()), (
|
||||
"warm read lost the real mount's custom handler; read-through should "
|
||||
"keep the command on the real mount")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_read_serves_cache(tmp_path):
|
||||
"""A cross-mount read relays each operand through ``execute_op``, and the
|
||||
op-layer read-through serves a warm operand from cache. Proven under LAZY
|
||||
by mutating the file out-of-band: the cross-mount read still returns the
|
||||
cached v1."""
|
||||
(tmp_path / "a.txt").write_bytes(b"v1\n")
|
||||
disk = DiskResource(root=str(tmp_path))
|
||||
disk.caches_reads = True
|
||||
ws = Workspace({
|
||||
"/d/": disk,
|
||||
"/r/": RAMResource()
|
||||
},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=ConsistencyPolicy.LAZY)
|
||||
await ws.execute("echo hi > /r/b.txt")
|
||||
await (await ws.execute("cat /d/a.txt")).stdout_str()
|
||||
(tmp_path / "a.txt").write_bytes(b"v2\n")
|
||||
out = await (await ws.execute("cat /d/a.txt /r/b.txt")).stdout_str()
|
||||
assert "v1" in out and "v2" not in out, (
|
||||
f"cross-mount read did not serve the warm operand from cache: {out!r}")
|
||||
@@ -0,0 +1,89 @@
|
||||
# ========= 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 asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from mirage.commands import COMMANDS as _CMDS
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
ram_cat = _CMDS["cat"]
|
||||
|
||||
|
||||
def _cat_ops():
|
||||
return ram_cat.__wrapped__.args[0]
|
||||
|
||||
|
||||
def _spying_stream(real_stream, pulled: list[str]):
|
||||
|
||||
def factory(accessor, p: PathSpec, index=None) -> AsyncIterator[bytes]:
|
||||
return _spy_iter(real_stream(accessor, p, index), p.virtual, pulled)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
async def _spy_iter(source: AsyncIterator[bytes], name: str,
|
||||
pulled: list[str]) -> AsyncIterator[bytes]:
|
||||
first = True
|
||||
async for chunk in source:
|
||||
if first:
|
||||
pulled.append(name)
|
||||
first = False
|
||||
yield chunk
|
||||
|
||||
|
||||
def _seeded_ws() -> Workspace:
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
async def seed():
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"a1\na2\na3\n")
|
||||
await ws.execute("tee /data/b.txt > /dev/null", stdin=b"b1\nb2\n")
|
||||
|
||||
asyncio.run(seed())
|
||||
return ws
|
||||
|
||||
|
||||
def _spy_cat_reads(ws, command, pulled):
|
||||
ops = _cat_ops()
|
||||
real = ops.read_stream
|
||||
object.__setattr__(ops, "read_stream", _spying_stream(real, pulled))
|
||||
try:
|
||||
return asyncio.run(_run_and_collect(ws, command))
|
||||
finally:
|
||||
object.__setattr__(ops, "read_stream", real)
|
||||
|
||||
|
||||
async def _run_and_collect(ws, command):
|
||||
result = await ws.execute(command)
|
||||
out = await result.stdout_str()
|
||||
await ws.close()
|
||||
return out
|
||||
|
||||
|
||||
def test_multi_cat_head_skips_second_file():
|
||||
ws = _seeded_ws()
|
||||
pulled: list[str] = []
|
||||
out = _spy_cat_reads(ws, "cat /data/a.txt /data/b.txt | head -n 1", pulled)
|
||||
assert out == "a1\n"
|
||||
assert pulled == ["/data/a.txt"]
|
||||
|
||||
|
||||
def test_multi_cat_full_reads_both_files():
|
||||
ws = _seeded_ws()
|
||||
pulled: list[str] = []
|
||||
out = _spy_cat_reads(ws, "cat /data/a.txt /data/b.txt", pulled)
|
||||
assert out == "a1\na2\na3\nb1\nb2\n"
|
||||
assert pulled == ["/data/a.txt", "/data/b.txt"]
|
||||
@@ -0,0 +1,96 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import CommandSafeguard, MountMode, OnExceed
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _build_ws(n_lines: int) -> Workspace:
|
||||
r = RAMResource()
|
||||
r._store.dirs.add("/")
|
||||
body = b"".join(f"line{i}\n".encode() for i in range(n_lines))
|
||||
r._store.files["/big.txt"] = body
|
||||
return Workspace({"/": (r, MountMode.WRITE)})
|
||||
|
||||
|
||||
def _override(ws: Workspace, name: str, safeguard: CommandSafeguard) -> None:
|
||||
mounts = list(ws._registry._mounts)
|
||||
for m in mounts:
|
||||
m.command_safeguards[name] = safeguard
|
||||
|
||||
|
||||
async def _run(ws: Workspace, cmd: str):
|
||||
try:
|
||||
io = await ws.execute(cmd)
|
||||
stdout = await io.stdout_str()
|
||||
stderr = await io.stderr_str()
|
||||
return io.exit_code, stdout, stderr
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
|
||||
def test_cat_truncates_at_default_2000():
|
||||
ws = _build_ws(2500)
|
||||
code, out, err = asyncio.run(_run(ws, "cat /big.txt"))
|
||||
assert code == 0
|
||||
assert out.count("\n") == 2000
|
||||
assert out.startswith("line0\n")
|
||||
assert "line1999\n" in out
|
||||
assert "line2000" not in out
|
||||
assert "truncated" in err
|
||||
|
||||
|
||||
def test_pipe_intermediate_not_capped():
|
||||
ws = _build_ws(2500)
|
||||
code, out, _ = asyncio.run(_run(ws, "cat /big.txt | wc -l"))
|
||||
assert code == 0
|
||||
assert out.strip() == "2500"
|
||||
|
||||
|
||||
def test_pipe_terminal_under_limit_no_notice():
|
||||
ws = _build_ws(2500)
|
||||
code, out, err = asyncio.run(_run(ws, "cat /big.txt | tail -n 3"))
|
||||
assert code == 0
|
||||
assert out == "line2497\nline2498\nline2499\n"
|
||||
assert "truncated" not in err
|
||||
|
||||
|
||||
def test_mount_override_caps_small():
|
||||
ws = _build_ws(5)
|
||||
_override(ws, "cat", CommandSafeguard(max_lines=3))
|
||||
code, out, err = asyncio.run(_run(ws, "cat /big.txt"))
|
||||
assert code == 0
|
||||
assert out == "line0\nline1\nline2\n"
|
||||
assert "truncated" in err
|
||||
|
||||
|
||||
def test_on_exceed_error_mode():
|
||||
ws = _build_ws(5)
|
||||
_override(ws, "cat", CommandSafeguard(max_lines=3,
|
||||
on_exceed=OnExceed.ERROR))
|
||||
code, out, err = asyncio.run(_run(ws, "cat /big.txt"))
|
||||
assert code == 1
|
||||
assert out == ""
|
||||
assert "truncated" in err
|
||||
|
||||
|
||||
def test_below_limit_untouched():
|
||||
ws = _build_ws(5)
|
||||
code, out, err = asyncio.run(_run(ws, "cat /big.txt"))
|
||||
assert code == 0
|
||||
assert out == "line0\nline1\nline2\nline3\nline4\n"
|
||||
assert "truncated" not in err
|
||||
@@ -0,0 +1,331 @@
|
||||
# ========= 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 asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.commands import safeguard as sg
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import CommandSafeguard, OnExceed
|
||||
|
||||
|
||||
async def _slow_provision(*args, **kwargs):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restore_defaults():
|
||||
snapshot = dict(sg.DEFAULT_COMMAND_SAFEGUARDS)
|
||||
yield
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS.clear()
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS.update(snapshot)
|
||||
|
||||
|
||||
def _ws(safeguards: dict | None = None) -> Workspace:
|
||||
if safeguards:
|
||||
return Workspace(
|
||||
{"/data": (RAMResource(), MountMode.WRITE, safeguards)},
|
||||
mode=MountMode.WRITE)
|
||||
return Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_builtin_under_default_does_not_fire():
|
||||
ws = _ws()
|
||||
r = await ws.execute("echo hi")
|
||||
assert r.exit_code == 0
|
||||
assert (await r.stdout_str()) == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builtin_default_safeguard_fires(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.1)
|
||||
ws = _ws()
|
||||
r = await ws.execute("sleep 2")
|
||||
assert r.exit_code == 124
|
||||
assert "sleep: timed out after 0.1s" in (await r.stderr_str())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_safeguard_applies_to_unknown_command(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.05)
|
||||
ws = _ws()
|
||||
r = await ws.execute("sleep 1")
|
||||
assert r.exit_code == 124
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_first_stage_to_trip_wins(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.1)
|
||||
ws = _ws()
|
||||
r = await ws.execute("sleep 2 | echo done")
|
||||
assert r.exit_code == 124
|
||||
assert "sleep: timed out" in (await r.stderr_str())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_zero_disables(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0)
|
||||
ws = _ws()
|
||||
r = await ws.execute("sleep 0.1")
|
||||
assert r.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mount_override_threaded_via_constructor():
|
||||
overrides = {"cat": CommandSafeguard(timeout_seconds=42.0, max_lines=99)}
|
||||
ws = _ws(safeguards=overrides)
|
||||
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
|
||||
assert mount.command_safeguards["cat"].timeout_seconds == 42.0
|
||||
assert mount.command_safeguards["cat"].max_lines == 99
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mount_override_beats_command_default():
|
||||
overrides = {"cat": CommandSafeguard(timeout_seconds=999.0)}
|
||||
ws = _ws(safeguards=overrides)
|
||||
mount = next(m for m in ws._registry._mounts if m.prefix == "/data/")
|
||||
from mirage.commands.safeguard import resolve_safeguard
|
||||
resolved = resolve_safeguard("cat", None,
|
||||
mount.command_safeguards.get("cat"))
|
||||
assert resolved.timeout_seconds == 999.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_sets_shared_cancel_event(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.05)
|
||||
ws = _ws()
|
||||
cancel = asyncio.Event()
|
||||
r = await ws.execute("sleep 1", cancel=cancel)
|
||||
assert r.exit_code == 124
|
||||
assert cancel.is_set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_cat_honors_command_default_safeguard(
|
||||
restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["cat"] = CommandSafeguard(max_lines=4)
|
||||
a = RAMResource()
|
||||
b = RAMResource()
|
||||
a._store.dirs.add("/")
|
||||
b._store.dirs.add("/")
|
||||
a._store.files["/x.txt"] = b"a\n" * 20
|
||||
b._store.files["/y.txt"] = b"b\n" * 20
|
||||
ws = Workspace({"/a/": a, "/b/": b}, mode=MountMode.WRITE)
|
||||
r = await ws.execute("cat /a/x.txt /b/y.txt")
|
||||
out = await r.stdout_str()
|
||||
err = await r.stderr_str()
|
||||
assert out.count("\n") == 4
|
||||
assert "output truncated" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fan_out_find_has_safeguard_set():
|
||||
a = RAMResource()
|
||||
a._store.dirs.add("/")
|
||||
a._store.files["/x.txt"] = b"hi\n"
|
||||
ws = Workspace({"/a/": a}, mode=MountMode.WRITE)
|
||||
r = await ws.execute("find /")
|
||||
assert r.safeguard is not None
|
||||
assert r.safeguard.timeout_seconds is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_honors_per_mount_timeout_override():
|
||||
a = RAMResource()
|
||||
b = RAMResource()
|
||||
a._store.dirs.add("/")
|
||||
b._store.dirs.add("/")
|
||||
a._store.files["/x.txt"] = b"hi\n"
|
||||
b._store.files["/y.txt"] = b"yo\n"
|
||||
ws = Workspace(
|
||||
{
|
||||
"/a/": (a, MountMode.WRITE, {
|
||||
"cat": CommandSafeguard(timeout_seconds=3.0)
|
||||
}),
|
||||
"/b/":
|
||||
b,
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
r = await ws.execute("cat /a/x.txt /b/y.txt")
|
||||
assert r.safeguard is not None
|
||||
assert r.safeguard.timeout_seconds == 3.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fan_out_uses_tightest_timeout_among_mounts():
|
||||
parent = RAMResource()
|
||||
child = RAMResource()
|
||||
parent._store.dirs.add("/")
|
||||
parent._store.files["/a.txt"] = b"hi\n"
|
||||
child._store.dirs.add("/")
|
||||
child._store.files["/b.txt"] = b"yo\n"
|
||||
ws = Workspace(
|
||||
{
|
||||
"/p/":
|
||||
parent,
|
||||
"/p/sub/": (child, MountMode.WRITE, {
|
||||
"find": CommandSafeguard(timeout_seconds=2.0)
|
||||
}),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
r = await ws.execute("find /p")
|
||||
assert r.safeguard is not None
|
||||
assert r.safeguard.timeout_seconds == 2.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fan_out_tightest_when_parent_is_tighter():
|
||||
parent = RAMResource()
|
||||
child = RAMResource()
|
||||
parent._store.dirs.add("/")
|
||||
parent._store.files["/a.txt"] = b"hi\n"
|
||||
child._store.dirs.add("/")
|
||||
child._store.files["/b.txt"] = b"yo\n"
|
||||
ws = Workspace(
|
||||
{
|
||||
"/p/": (parent, MountMode.WRITE, {
|
||||
"find": CommandSafeguard(timeout_seconds=2.0)
|
||||
}),
|
||||
"/p/sub/": (child, MountMode.WRITE, {
|
||||
"find": CommandSafeguard(timeout_seconds=9.0)
|
||||
}),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
r = await ws.execute("find /p")
|
||||
assert r.safeguard is not None
|
||||
assert r.safeguard.timeout_seconds == 2.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_job_propagates_timeout(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.05)
|
||||
ws = _ws()
|
||||
r1 = await ws.execute("sleep 2 &")
|
||||
assert r1.exit_code == 0
|
||||
r2 = await ws.execute("wait %1")
|
||||
assert r2.exit_code == 124
|
||||
err = await r2.stderr_str()
|
||||
assert "sleep: timed out" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stderr_redirect_does_not_swallow_timeout_exit(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.05)
|
||||
ws = _ws()
|
||||
r = await ws.execute("sleep 2 2>&1")
|
||||
assert r.exit_code == 124
|
||||
r = await ws.execute("sleep 2 2>&1 | cat")
|
||||
assert r.exit_code == 124
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_job_table_reports_completed_bg_without_wait(restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.05)
|
||||
ws = _ws()
|
||||
await ws.execute("sleep 5 &")
|
||||
await asyncio.sleep(0.2)
|
||||
jobs = ws.job_table.list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0].status.value == "completed"
|
||||
assert jobs[0].exit_code == 124
|
||||
assert b"sleep: timed out" in jobs[0].stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_wrap_preserves_lazy_nonzero_exit_on_no_match():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hello > /data/f.txt")
|
||||
r = await ws.execute("grep zzz /data/f.txt")
|
||||
assert r.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_wrap_preserves_lazy_zero_exit_on_match():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hello > /data/f.txt")
|
||||
r = await ws.execute("grep hello /data/f.txt")
|
||||
assert r.exit_code == 0
|
||||
assert (await r.stdout_str()) == "hello\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncation_keeps_lazy_exit_zero_on_match():
|
||||
ws = _ws({"grep": CommandSafeguard(max_lines=2, timeout_seconds=600)})
|
||||
await ws.execute("printf 'a\\na\\na\\na\\n' > /data/f.txt")
|
||||
r = await ws.execute("grep a /data/f.txt")
|
||||
assert r.exit_code == 0
|
||||
assert (await r.stdout_str()) == "a\na\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provision_dry_run_honors_timeout(monkeypatch, restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["cat"] = CommandSafeguard(
|
||||
timeout_seconds=0.1)
|
||||
monkeypatch.setattr("mirage.workspace.workspace.provision_node",
|
||||
_slow_provision)
|
||||
ws = _ws()
|
||||
r = await ws.execute("cat /data/f.txt", provision=True)
|
||||
assert r.exit_code == 124
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_preserves_partial_records_and_logs(
|
||||
caplog, restore_defaults):
|
||||
sg.DEFAULT_COMMAND_SAFEGUARDS["sleep"] = CommandSafeguard(
|
||||
timeout_seconds=0.1)
|
||||
ws = _ws()
|
||||
await ws.execute("echo hello > /data/f.txt")
|
||||
before = len(ws._ops.records)
|
||||
with caplog.at_level(logging.DEBUG, logger="mirage.workspace.workspace"):
|
||||
r = await ws.execute("cat /data/f.txt; sleep 5")
|
||||
assert r.exit_code == 124
|
||||
assert len(ws._ops.records) > before
|
||||
assert any("timed out" in m for m in caplog.messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_cat_aggregates_tightest_safeguard():
|
||||
a = RAMResource()
|
||||
b = RAMResource()
|
||||
a._store.dirs.add("/")
|
||||
b._store.dirs.add("/")
|
||||
a._store.files["/x.txt"] = b"1\n2\n3\n"
|
||||
b._store.files["/y.txt"] = b"4\n5\n6\n"
|
||||
ws = Workspace(
|
||||
{
|
||||
"/a/": (a, MountMode.WRITE, {
|
||||
"cat":
|
||||
CommandSafeguard(max_lines=100, on_exceed=OnExceed.TRUNCATE)
|
||||
}),
|
||||
"/b/":
|
||||
(b, MountMode.WRITE, {
|
||||
"cat": CommandSafeguard(max_lines=1, on_exceed=OnExceed.ERROR)
|
||||
}),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
r = await ws.execute("cat /a/x.txt /b/y.txt")
|
||||
assert r.safeguard.max_lines == 1
|
||||
assert r.safeguard.on_exceed is OnExceed.ERROR
|
||||
@@ -0,0 +1,130 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _make_ws():
|
||||
ram1 = RAMResource()
|
||||
ram2 = RAMResource()
|
||||
ram1._store.files["/file.txt"] = b"line1\nline2\nline3\nline4\nline5\n"
|
||||
ram2._store.files["/file.txt"] = b"aaa\nbbb\nccc\n"
|
||||
return Workspace(
|
||||
{
|
||||
"/a/": (ram1, MountMode.WRITE),
|
||||
"/b/": (ram2, MountMode.WRITE)
|
||||
}, )
|
||||
|
||||
|
||||
def _run(ws, cmd):
|
||||
|
||||
async def _inner():
|
||||
io = await ws.execute(cmd)
|
||||
return await io.stdout_str(), await io.stderr_str(), io.exit_code
|
||||
|
||||
return asyncio.run(_inner())
|
||||
|
||||
|
||||
def test_cross_mount_head_invalid_n():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "head -n abc /a/file.txt /b/file.txt")
|
||||
assert code == 1
|
||||
assert "abc" in err
|
||||
|
||||
|
||||
def test_cross_mount_tail_invalid_n():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "tail -n abc /a/file.txt /b/file.txt")
|
||||
assert code == 1
|
||||
assert "abc" in err
|
||||
|
||||
|
||||
def test_cross_mount_head_valid_n():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "head -n 2 /a/file.txt /b/file.txt")
|
||||
assert code == 0
|
||||
assert "line1" in out
|
||||
assert "line2" in out
|
||||
assert "aaa" in out
|
||||
assert "bbb" in out
|
||||
|
||||
|
||||
def test_cross_mount_tail_valid_n():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "tail -n 1 /a/file.txt /b/file.txt")
|
||||
assert code == 0
|
||||
assert "line5" in out
|
||||
assert "ccc" in out
|
||||
|
||||
|
||||
def test_cross_mount_head_default_n():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "head /a/file.txt /b/file.txt")
|
||||
assert code == 0
|
||||
assert "line1" in out
|
||||
assert "aaa" in out
|
||||
|
||||
|
||||
def test_cross_mount_head_byte_mode():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "head -c 3 /a/file.txt /b/file.txt")
|
||||
assert code == 0
|
||||
assert "/a/file.txt" in out
|
||||
assert "lin" in out
|
||||
assert "line1" not in out
|
||||
assert "bbb" not in out
|
||||
|
||||
|
||||
def test_cross_mount_grep_invert():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "grep -v line1 /a/file.txt /b/file.txt")
|
||||
assert code == 0
|
||||
assert "/a/file.txt:line2" in out
|
||||
assert "aaa" in out
|
||||
assert "line1" not in out
|
||||
|
||||
|
||||
def test_cross_mount_wc_total():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "wc -l /a/file.txt /b/file.txt")
|
||||
assert code == 0
|
||||
assert "total" in out
|
||||
assert "5" in out
|
||||
assert "3" in out
|
||||
assert "8" in out
|
||||
|
||||
|
||||
def test_cross_mount_cat_missing_has_strerror():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "cat /a/file.txt /b/missing.txt")
|
||||
assert code == 1
|
||||
assert err == "cat: /b/missing.txt: No such file or directory\n"
|
||||
|
||||
|
||||
def test_cross_mount_diff_missing_has_strerror():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "diff /a/missing.txt /b/file.txt")
|
||||
assert code == 1
|
||||
assert err == "diff: /a/missing.txt: No such file or directory\n"
|
||||
|
||||
|
||||
def test_cross_mount_cmp_missing_has_strerror():
|
||||
ws = _make_ws()
|
||||
out, err, code = _run(ws, "cmp /a/file.txt /b/missing.txt")
|
||||
assert code == 1
|
||||
assert err == "cmp: /b/missing.txt: No such file or directory\n"
|
||||
@@ -0,0 +1,93 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _make_ws():
|
||||
src = RAMResource()
|
||||
dst = RAMResource()
|
||||
src._store.dirs.update({"/dir", "/dir/sub", "/dir/empty"})
|
||||
src._store.files["/dir/a.txt"] = b"aaa\n"
|
||||
src._store.files["/dir/sub/b.txt"] = b"bbb\n"
|
||||
ws = Workspace(
|
||||
{
|
||||
"/a": (src, MountMode.WRITE),
|
||||
"/b": (dst, MountMode.WRITE),
|
||||
}, )
|
||||
return ws, src, dst
|
||||
|
||||
|
||||
def _run(ws, cmd):
|
||||
|
||||
async def _inner():
|
||||
io = await ws.execute(cmd)
|
||||
return await io.stdout_str(), await io.stderr_str(), io.exit_code
|
||||
|
||||
return asyncio.run(_inner())
|
||||
|
||||
|
||||
def test_cp_recursive_copies_tree():
|
||||
ws, _src, dst = _make_ws()
|
||||
_out, _err, code = _run(ws, "cp -r /a/dir /b/copied")
|
||||
assert code == 0
|
||||
assert dst._store.files["/copied/a.txt"] == b"aaa\n"
|
||||
assert dst._store.files["/copied/sub/b.txt"] == b"bbb\n"
|
||||
assert "/copied/sub" in dst._store.dirs
|
||||
assert "/copied/empty" in dst._store.dirs
|
||||
|
||||
|
||||
def test_cp_recursive_into_existing_dir():
|
||||
ws, _src, dst = _make_ws()
|
||||
dst._store.dirs.add("/into")
|
||||
_out, _err, code = _run(ws, "cp -r /a/dir /b/into")
|
||||
assert code == 0
|
||||
assert dst._store.files["/into/dir/a.txt"] == b"aaa\n"
|
||||
assert dst._store.files["/into/dir/sub/b.txt"] == b"bbb\n"
|
||||
|
||||
|
||||
def test_cp_directory_without_recursive_fails():
|
||||
ws, _src, dst = _make_ws()
|
||||
_out, err, code = _run(ws, "cp /a/dir /b/copied")
|
||||
assert code == 1
|
||||
assert "omitting directory" in err
|
||||
assert "/copied" not in dst._store.dirs
|
||||
assert not any(k.startswith("/copied") for k in dst._store.files)
|
||||
|
||||
|
||||
def test_mv_recursive_moves_tree_and_removes_source():
|
||||
ws, src, dst = _make_ws()
|
||||
_out, _err, code = _run(ws, "mv /a/dir /b/moved")
|
||||
assert code == 0
|
||||
assert dst._store.files["/moved/a.txt"] == b"aaa\n"
|
||||
assert dst._store.files["/moved/sub/b.txt"] == b"bbb\n"
|
||||
assert "/dir" not in src._store.dirs
|
||||
assert "/dir/a.txt" not in src._store.files
|
||||
assert "/dir/sub/b.txt" not in src._store.files
|
||||
|
||||
|
||||
def test_cp_recursive_no_clobber_skips_existing():
|
||||
# /b/into exists, so `cp -r /a/dir /b/into` maps to /b/into/dir. Pre-seed
|
||||
# that mapped tree with an existing a.txt so -n must skip it.
|
||||
ws, _src, dst = _make_ws()
|
||||
dst._store.dirs.update({"/into", "/into/dir"})
|
||||
dst._store.files["/into/dir/a.txt"] = b"keep\n"
|
||||
_out, _err, code = _run(ws, "cp -rn /a/dir /b/into")
|
||||
assert code == 0
|
||||
assert dst._store.files["/into/dir/a.txt"] == b"keep\n"
|
||||
assert dst._store.files["/into/dir/sub/b.txt"] == b"bbb\n"
|
||||
@@ -0,0 +1,345 @@
|
||||
# ========= 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 import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
def _make_ws():
|
||||
resource = RAMResource()
|
||||
store = resource._store
|
||||
store.dirs.add("/")
|
||||
store.dirs.add("/subdir")
|
||||
store.dirs.add("/subdir/nested")
|
||||
store.files["/subdir/file.txt"] = b"hello"
|
||||
store.modified["/subdir/file.txt"] = "2024-01-01"
|
||||
store.files["/subdir/nested/deep.txt"] = b"deep"
|
||||
store.modified["/subdir/nested/deep.txt"] = "2024-01-01"
|
||||
return Workspace({"/ram/": resource}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def _make_ws_special_chars():
|
||||
resource = RAMResource()
|
||||
store = resource._store
|
||||
store.dirs.add("/")
|
||||
store.dirs.add("/Zecheng's Server")
|
||||
store.files["/Zecheng's Server/image.png"] = b"PNG"
|
||||
store.modified["/Zecheng's Server/image.png"] = "2024-01-01"
|
||||
return Workspace({"/ram/": resource}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pwd_default():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("pwd")
|
||||
assert (await r.stdout_str()).strip() != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_and_pwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_subdir_and_pwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_dotdot_and_pwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && cd .. && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_slash_and_pwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && cd / && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_tilde_unset_home_errors():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && cd ~")
|
||||
assert r.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_no_args_unset_home_errors():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && cd")
|
||||
assert r.exit_code == 1
|
||||
assert "HOME not set" in await r.stderr_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_no_args_with_home():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("export HOME=/ram/subdir && cd /ram && cd && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_relative_and_pwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && cd subdir && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_no_args_uses_cwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && ls")
|
||||
assert "file.txt" in await r.stdout_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_no_args_root():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && ls")
|
||||
assert "subdir" in await r.stdout_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_relative_nested():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && cd nested && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir/nested"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_dotdot_twice():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute('cd /ram/subdir/nested && cd ../.. && pwd')
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_backslash_escaped():
|
||||
ws = _make_ws_special_chars()
|
||||
r = await ws.execute(r"ls /ram/Zecheng\'s\ Server/")
|
||||
assert "image.png" in await r.stdout_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_quoted():
|
||||
ws = _make_ws_special_chars()
|
||||
r = await ws.execute('ls "/ram/Zecheng\'s Server/"')
|
||||
assert "image.png" in await r.stdout_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_backslash_escaped_and_ls():
|
||||
ws = _make_ws_special_chars()
|
||||
r = await ws.execute(r"cd /ram/Zecheng\'s\ Server && ls")
|
||||
assert "image.png" in await r.stdout_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_quoted_and_ls():
|
||||
ws = _make_ws_special_chars()
|
||||
r = await ws.execute('cd "/ram/Zecheng\'s Server" && ls')
|
||||
assert "image.png" in await r.stdout_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_backslash_escaped_and_pwd():
|
||||
ws = _make_ws_special_chars()
|
||||
r = await ws.execute(r"cd /ram/Zecheng\'s\ Server && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/Zecheng's Server"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pwd_default_root():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("pwd")
|
||||
assert (await r.stdout_str()).strip() == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_echo_pwd_tracks_cwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram/subdir && echo $PWD")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_echo_home_unset_is_empty():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute('echo "[$HOME]"')
|
||||
assert (await r.stdout_str()).strip() == "[]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_updates_oldpwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && cd /ram/subdir && echo $OLDPWD")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_dash_returns_and_prints():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && cd /ram/subdir && cd -")
|
||||
out = await r.stdout_str()
|
||||
assert out.strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_dash_swaps_cwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && cd /ram/subdir && cd - > /dev/null && pwd"
|
||||
)
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_dash_without_oldpwd_errors():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd -")
|
||||
assert r.exit_code == 1
|
||||
assert "OLDPWD not set" in await r.stderr_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_home_cd_tilde():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("export HOME=/ram/subdir && cd ~ && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_home_echo():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("export HOME=/ram/subdir && echo $HOME")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tilde_expands_for_commands():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("export HOME=/ram/subdir && cat ~/file.txt")
|
||||
assert (await r.stdout_str()) == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quoted_tilde_not_expanded():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute('export HOME=/ram/subdir && cat "~/file.txt"')
|
||||
assert r.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subshell_does_not_leak_oldpwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && (cd /ram/subdir) && echo $OLDPWD")
|
||||
assert (await r.stdout_str()).strip() == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subshell_does_not_leak_cwd():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && (cd /ram/subdir) && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_double_slash_collapses():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd //ram && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_triple_slash_collapses():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd ///ram/subdir && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_physical_flag():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd -P /ram/subdir && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_logical_flag():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd -L /ram && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_clustered_flags():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd -LP /ram && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_double_dash_terminates_options():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd -- /ram && pwd")
|
||||
assert (await r.stdout_str()).strip() == "/ram"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_invalid_option_exit2():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd -x /ram")
|
||||
assert r.exit_code == 2
|
||||
assert "invalid option" in await r.stderr_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_too_many_arguments():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram /ram/subdir")
|
||||
assert r.exit_code == 1
|
||||
assert "too many arguments" in await r.stderr_str()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_quoted_tilde_is_literal():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && cd '~'")
|
||||
assert r.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_cdpath_search_and_print():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("export CDPATH=/ram && cd subdir && pwd")
|
||||
lines = (await r.stdout_str()).splitlines()
|
||||
assert lines[-1] == "/ram/subdir"
|
||||
assert "/ram/subdir" in lines[:-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_cdpath_empty_entry_is_cwd_no_print():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("cd /ram && export CDPATH=:/ram && cd subdir && pwd")
|
||||
lines = (await r.stdout_str()).splitlines()
|
||||
assert lines[-1] == "/ram/subdir"
|
||||
assert lines == ["/ram/subdir"]
|
||||
@@ -0,0 +1,39 @@
|
||||
# ========= 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.resource.ram import RAMResource
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_hit_does_not_double_store():
|
||||
ram = RAMResource()
|
||||
await ram.write("/big.bin", b"x" * 4096)
|
||||
ws = Workspace(resources={"/r": ram})
|
||||
try:
|
||||
await ws.execute("cat /r/big.bin > /dev/null")
|
||||
size_after_first = ws._cache.cache_size
|
||||
keys_first = sorted(ws._cache._entries.keys())
|
||||
|
||||
await ws.execute("cat /r/big.bin > /dev/null")
|
||||
size_after_second = ws._cache.cache_size
|
||||
keys_second = sorted(ws._cache._entries.keys())
|
||||
|
||||
assert size_after_second == size_after_first
|
||||
assert keys_second == keys_first
|
||||
assert all(k.startswith("/r/") for k in keys_second)
|
||||
finally:
|
||||
await ws.close()
|
||||
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.core.dify import tree
|
||||
from mirage.resource.dify import DifyConfig, DifyResource
|
||||
|
||||
|
||||
async def list_documents(config):
|
||||
return [{
|
||||
"id": "doc-1",
|
||||
"name": "Quickstart",
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": "guides/quickstart"
|
||||
}],
|
||||
"enabled": True,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
"tokens": 5,
|
||||
"data_source_type": "upload_file",
|
||||
"data_source_detail_dict": {
|
||||
"upload_file": {
|
||||
"size": 321
|
||||
}
|
||||
},
|
||||
"created_at": 1716282000,
|
||||
}]
|
||||
|
||||
|
||||
def resource() -> DifyResource:
|
||||
return DifyResource(
|
||||
DifyConfig(
|
||||
api_key="secret",
|
||||
base_url="https://dify.example/v1",
|
||||
dataset_id="dataset-1",
|
||||
))
|
||||
|
||||
|
||||
def test_workspace_mount_registers_dify_commands_and_ops():
|
||||
workspace = Workspace({"/knowledge": resource()}, mode=MountMode.READ)
|
||||
mount = workspace.mount("/knowledge/")
|
||||
|
||||
assert "awk" in mount.commands()
|
||||
assert "ls" in mount.commands()
|
||||
assert "cat" in mount.commands()
|
||||
assert "cut" in mount.commands()
|
||||
assert "rg" in mount.commands()
|
||||
assert "sed" in mount.commands()
|
||||
assert "sort" in mount.commands()
|
||||
assert "stat" in mount.commands()
|
||||
assert "tree" in mount.commands()
|
||||
assert "uniq" in mount.commands()
|
||||
assert "du" in mount.commands()
|
||||
assert "read" in mount.registered_ops()
|
||||
assert "stat" in mount.registered_ops()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_executes_dify_ls(monkeypatch):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
workspace = Workspace({"/knowledge": resource()}, mode=MountMode.READ)
|
||||
|
||||
result = await workspace.execute("ls /knowledge")
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert await result.stdout_str() == "guides\n"
|
||||
@@ -0,0 +1,55 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _make_ws() -> tuple[Workspace, RAMResource]:
|
||||
resource = RAMResource()
|
||||
resource._store.files["/file.txt"] = b"OLD"
|
||||
ws = Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
return ws, resource
|
||||
|
||||
|
||||
def test_redirect_write_overrides_cached_read():
|
||||
ws, resource = _make_ws()
|
||||
|
||||
async def run() -> None:
|
||||
await ws.execute("cat /data/file.txt")
|
||||
await ws.execute('echo -n "NEW" > /data/file.txt')
|
||||
|
||||
asyncio.run(run())
|
||||
assert resource._store.files["/file.txt"] == b"NEW", (
|
||||
"redirect-write should reach the backend even when the path was "
|
||||
"previously cached by a read")
|
||||
|
||||
|
||||
def test_redirect_append_after_cached_read():
|
||||
ws, resource = _make_ws()
|
||||
|
||||
async def run() -> None:
|
||||
await ws.execute("cat /data/file.txt")
|
||||
await ws.execute('echo -n "MORE" >> /data/file.txt')
|
||||
|
||||
asyncio.run(run())
|
||||
assert resource._store.files["/file.txt"] == b"OLDMORE", (
|
||||
"redirect-append should reach the backend even when the path was "
|
||||
"previously cached by a read")
|
||||
@@ -0,0 +1,296 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import DEFAULT_SESSION_ID
|
||||
|
||||
|
||||
def _make_ws():
|
||||
resource = RAMResource()
|
||||
store = resource._store
|
||||
store.dirs.add("/")
|
||||
store.dirs.add("/subdir")
|
||||
store.dirs.add("/other")
|
||||
store.files["/subdir/file.txt"] = b"hello"
|
||||
store.modified["/subdir/file.txt"] = "2024-01-01"
|
||||
return Workspace({"/ram/": resource}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
# ── cwd tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cwd_runs_in_override():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("pwd", cwd="/ram/subdir")
|
||||
assert (await r.stdout_str()).strip() == "/ram/subdir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cwd_does_not_mutate_session():
|
||||
ws = _make_ws()
|
||||
before = ws.get_session(DEFAULT_SESSION_ID).cwd
|
||||
await ws.execute("pwd", cwd="/ram/subdir")
|
||||
after = ws.get_session(DEFAULT_SESSION_ID).cwd
|
||||
assert before == after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cwd_cd_does_not_leak():
|
||||
ws = _make_ws()
|
||||
before = ws.get_session(DEFAULT_SESSION_ID).cwd
|
||||
await ws.execute("cd /ram/subdir", cwd="/ram")
|
||||
after = ws.get_session(DEFAULT_SESSION_ID).cwd
|
||||
assert before == after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cwd_parallel_isolation():
|
||||
ws = _make_ws()
|
||||
r1, r2 = await asyncio.gather(
|
||||
ws.execute("pwd", cwd="/ram/subdir"),
|
||||
ws.execute("pwd", cwd="/ram/other"),
|
||||
)
|
||||
assert (await r1.stdout_str()).strip() == "/ram/subdir"
|
||||
assert (await r2.stdout_str()).strip() == "/ram/other"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_persists_overrides_inherit():
|
||||
ws = _make_ws()
|
||||
await ws.execute("export DEBUG=1")
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).env.get("DEBUG") == "1"
|
||||
before_cwd = ws.get_session(DEFAULT_SESSION_ID).cwd
|
||||
r = await ws.execute("printenv DEBUG", cwd="/ram/subdir")
|
||||
assert (await r.stdout_str()).strip() == "1"
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).cwd == before_cwd
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).env.get("DEBUG") == "1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_function_definitions_do_not_leak():
|
||||
ws = _make_ws()
|
||||
await ws.execute("greet() { echo hi; }", cwd="/ram/subdir")
|
||||
session = ws.get_session(DEFAULT_SESSION_ID)
|
||||
assert "greet" not in session.functions
|
||||
|
||||
|
||||
# ── env tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_exposes_override():
|
||||
ws = _make_ws()
|
||||
r = await ws.execute("printenv FOO", env={"FOO": "bar"})
|
||||
assert r.exit_code == 0
|
||||
assert (await r.stdout_str()).strip() == "bar"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_does_not_mutate_session():
|
||||
ws = _make_ws()
|
||||
before = dict(ws.get_session(DEFAULT_SESSION_ID).env)
|
||||
await ws.execute("printenv FOO", env={"FOO": "bar"})
|
||||
after = dict(ws.get_session(DEFAULT_SESSION_ID).env)
|
||||
assert before == after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_export_does_not_leak():
|
||||
ws = _make_ws()
|
||||
await ws.execute("export LEAKED=yes", env={"FOO": "bar"})
|
||||
assert "LEAKED" not in ws.get_session(DEFAULT_SESSION_ID).env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_layers_onto_session():
|
||||
ws = _make_ws()
|
||||
await ws.execute("export BASE=keep")
|
||||
assert ws.get_session(DEFAULT_SESSION_ID).env.get("BASE") == "keep"
|
||||
r_base = await ws.execute("printenv BASE", env={"FOO": "bar"})
|
||||
assert (await r_base.stdout_str()).strip() == "keep"
|
||||
r_foo = await ws.execute("printenv FOO", env={"FOO": "bar"})
|
||||
assert (await r_foo.stdout_str()).strip() == "bar"
|
||||
session_env = ws.get_session(DEFAULT_SESSION_ID).env
|
||||
assert session_env.get("BASE") == "keep"
|
||||
assert "FOO" not in session_env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_parallel_isolation():
|
||||
ws = _make_ws()
|
||||
r1, r2 = await asyncio.gather(
|
||||
ws.execute("printenv FOO", env={"FOO": "one"}),
|
||||
ws.execute("printenv FOO", env={"FOO": "two"}),
|
||||
)
|
||||
assert (await r1.stdout_str()).strip() == "one"
|
||||
assert (await r2.stdout_str()).strip() == "two"
|
||||
|
||||
|
||||
# ── mid-flight cancel ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_pre_set_raises_immediately():
|
||||
ws = _make_ws()
|
||||
cancel = asyncio.Event()
|
||||
cancel.set()
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await ws.execute("echo hi", cancel=cancel)
|
||||
assert "abort" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_aborts_sleep_within_timeout():
|
||||
ws = _make_ws()
|
||||
cancel = asyncio.Event()
|
||||
|
||||
async def trigger() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
cancel.set()
|
||||
|
||||
asyncio.create_task(trigger())
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
with pytest.raises(Exception):
|
||||
await ws.execute("sleep 5", cancel=cancel)
|
||||
assert asyncio.get_event_loop().time() - t0 < 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_inside_for_loop():
|
||||
ws = _make_ws()
|
||||
cancel = asyncio.Event()
|
||||
|
||||
async def trigger() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
cancel.set()
|
||||
|
||||
asyncio.create_task(trigger())
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
with pytest.raises(Exception):
|
||||
await ws.execute(
|
||||
"for i in 1 2 3 4 5 6 7 8 9 10; do sleep 1; done",
|
||||
cancel=cancel,
|
||||
)
|
||||
assert asyncio.get_event_loop().time() - t0 < 1.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_between_list_stages():
|
||||
ws = _make_ws()
|
||||
cancel = asyncio.Event()
|
||||
|
||||
async def trigger() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
cancel.set()
|
||||
|
||||
asyncio.create_task(trigger())
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
with pytest.raises(Exception):
|
||||
await ws.execute(
|
||||
"sleep 1 && sleep 1 && sleep 1 && echo done",
|
||||
cancel=cancel,
|
||||
)
|
||||
assert asyncio.get_event_loop().time() - t0 < 2.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_inside_command_substitution():
|
||||
ws = _make_ws()
|
||||
cancel = asyncio.Event()
|
||||
|
||||
async def trigger() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
cancel.set()
|
||||
|
||||
asyncio.create_task(trigger())
|
||||
t0 = asyncio.get_event_loop().time()
|
||||
with pytest.raises(Exception):
|
||||
await ws.execute('echo "$(sleep 5)"', cancel=cancel)
|
||||
assert asyncio.get_event_loop().time() - t0 < 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_workspace_remains_usable():
|
||||
ws = _make_ws()
|
||||
cancel = asyncio.Event()
|
||||
|
||||
async def trigger() -> None:
|
||||
await asyncio.sleep(0.05)
|
||||
cancel.set()
|
||||
|
||||
asyncio.create_task(trigger())
|
||||
with pytest.raises(Exception):
|
||||
await ws.execute("sleep 5", cancel=cancel)
|
||||
r = await ws.execute("echo recovered")
|
||||
assert r.exit_code == 0
|
||||
assert r.stdout.decode().strip() == "recovered"
|
||||
|
||||
|
||||
# ── agent harness pattern ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def _tool_call(ws, cmd, cwd_v, env_v, timeout):
|
||||
cancel = asyncio.Event()
|
||||
asyncio.get_event_loop().call_later(timeout, cancel.set)
|
||||
return await ws.execute(cmd, cwd=cwd_v, env=env_v, cancel=cancel)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_pattern_parallel_tool_calls_each_with_own_options():
|
||||
ws = _make_ws()
|
||||
a, b = await asyncio.gather(
|
||||
_tool_call(ws, "pwd; printenv DEBUG", "/ram/subdir", {"DEBUG": "one"},
|
||||
5.0),
|
||||
_tool_call(ws, "pwd; printenv DEBUG", "/ram", {"DEBUG": "two"}, 5.0),
|
||||
)
|
||||
assert "/ram/subdir" in a.stdout.decode()
|
||||
assert "one" in a.stdout.decode()
|
||||
assert "/ram" in b.stdout.decode()
|
||||
assert "two" in b.stdout.decode()
|
||||
session = ws.get_session(DEFAULT_SESSION_ID)
|
||||
assert session.cwd != "/ram/subdir"
|
||||
assert "DEBUG" not in session.env
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_uses_custom_default_session_id():
|
||||
resource = RAMResource()
|
||||
resource._store.dirs.add("/")
|
||||
ws = Workspace({"/ram/": resource},
|
||||
mode=MountMode.WRITE,
|
||||
session_id="mysess")
|
||||
r = await ws.execute("echo hi > /ram/f.txt")
|
||||
assert r.exit_code == 0
|
||||
r2 = await ws.execute("cat /ram/f.txt")
|
||||
assert (await r2.stdout_str()).strip() == "hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_pattern_one_aborts_while_siblings_complete():
|
||||
ws = _make_ws()
|
||||
settled = await asyncio.gather(
|
||||
_tool_call(ws, "sleep 5", "/ram/subdir", {"DEBUG": "one"}, 0.1),
|
||||
_tool_call(ws, "echo ok", "/ram", {"DEBUG": "two"}, 5.0),
|
||||
return_exceptions=True,
|
||||
)
|
||||
assert isinstance(settled[0], Exception)
|
||||
assert "abort" in str(settled[0]).lower()
|
||||
assert not isinstance(settled[1], Exception)
|
||||
assert settled[1].stdout.decode().strip() == "ok"
|
||||
@@ -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. =========
|
||||
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
def test_execution_node_leaf():
|
||||
node = ExecutionNode(command="cat /data/file.txt", stderr=b"", exit_code=0)
|
||||
assert node.command == "cat /data/file.txt"
|
||||
assert node.op is None
|
||||
assert node.children == []
|
||||
assert node.exit_code == 0
|
||||
|
||||
|
||||
def test_execution_node_pipe():
|
||||
node = ExecutionNode(
|
||||
op="|",
|
||||
stderr=b"",
|
||||
exit_code=0,
|
||||
children=[
|
||||
ExecutionNode(command="grep foo file",
|
||||
stderr=b"warning",
|
||||
exit_code=0),
|
||||
ExecutionNode(command="sort", stderr=b"", exit_code=0),
|
||||
],
|
||||
)
|
||||
assert node.op == "|"
|
||||
assert node.command is None
|
||||
assert len(node.children) == 2
|
||||
assert node.children[0].stderr == b"warning"
|
||||
|
||||
|
||||
def test_execution_node_nested_tree():
|
||||
tree = ExecutionNode(
|
||||
op=";",
|
||||
stderr=b"",
|
||||
exit_code=0,
|
||||
children=[
|
||||
ExecutionNode(
|
||||
op="|",
|
||||
stderr=b"",
|
||||
exit_code=0,
|
||||
children=[
|
||||
ExecutionNode(command="grep foo file",
|
||||
stderr=b"",
|
||||
exit_code=1),
|
||||
ExecutionNode(command="sort", stderr=b"", exit_code=0),
|
||||
],
|
||||
),
|
||||
ExecutionNode(command="echo done", stderr=b"", exit_code=0),
|
||||
],
|
||||
)
|
||||
assert tree.children[0].children[0].exit_code == 1
|
||||
assert tree.children[1].command == "echo done"
|
||||
|
||||
|
||||
def test_execution_node_to_dict():
|
||||
node = ExecutionNode(
|
||||
op="|",
|
||||
stderr=b"warn",
|
||||
exit_code=0,
|
||||
children=[
|
||||
ExecutionNode(command="grep foo", stderr=b"", exit_code=1),
|
||||
ExecutionNode(command="sort", stderr=b"", exit_code=0),
|
||||
],
|
||||
)
|
||||
d = node.to_dict()
|
||||
assert d["op"] == "|"
|
||||
assert d["exit_code"] == 0
|
||||
assert len(d["children"]) == 2
|
||||
assert d["children"][0]["command"] == "grep foo"
|
||||
assert d["children"][0]["stderr"] == ""
|
||||
assert d["children"][0]["exit_code"] == 1
|
||||
@@ -0,0 +1,544 @@
|
||||
# ========= 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 asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from mirage.io import IOResult
|
||||
from mirage.shell import parse
|
||||
from mirage.shell.call_stack import CallStack
|
||||
from mirage.shell.helpers import get_parts
|
||||
from mirage.types import PathSpec
|
||||
from mirage.workspace.expand import (classify_parts, classify_word,
|
||||
expand_and_classify, expand_node,
|
||||
expand_parts)
|
||||
from mirage.workspace.session import Session
|
||||
|
||||
|
||||
def _session(env=None, cwd="/"):
|
||||
return Session(session_id="test", cwd=cwd, env=env or {})
|
||||
|
||||
|
||||
def _execute_fn():
|
||||
return AsyncMock(return_value=IOResult())
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _cmd_parts(cmd: str):
|
||||
"""Parse a command and return its parts nodes."""
|
||||
root = parse(cmd)
|
||||
return get_parts(root.named_children[0])
|
||||
|
||||
|
||||
def _first_arg(cmd: str):
|
||||
"""Parse and return the second child (first argument)."""
|
||||
return _cmd_parts(cmd)[1]
|
||||
|
||||
|
||||
def _mock_registry(prefixes=None):
|
||||
"""Create a mock registry that matches given prefixes.
|
||||
|
||||
Args:
|
||||
prefixes: list of mount prefixes, e.g. ["/data/", "/s3/"].
|
||||
If None, matches everything.
|
||||
"""
|
||||
reg = MagicMock()
|
||||
|
||||
def _mount_for(path):
|
||||
if prefixes is None:
|
||||
root = MagicMock()
|
||||
root.prefix = "/"
|
||||
return root
|
||||
norm = "/" + path.strip("/")
|
||||
for p in sorted(prefixes, key=len, reverse=True):
|
||||
norm_p = p.rstrip("/")
|
||||
if norm == norm_p or norm.startswith(p):
|
||||
m = MagicMock()
|
||||
m.prefix = p
|
||||
return m
|
||||
raise ValueError(f"no mount matches: {path}")
|
||||
|
||||
reg.mount_for = MagicMock(side_effect=_mount_for)
|
||||
return reg
|
||||
|
||||
|
||||
# ── word ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_expand_word():
|
||||
node = _cmd_parts("echo hello")[1]
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == "hello"
|
||||
|
||||
|
||||
def test_expand_command_name():
|
||||
node = _cmd_parts("echo hello")[0]
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == "echo"
|
||||
|
||||
|
||||
# ── simple expansion $VAR ───────────────────────
|
||||
|
||||
|
||||
def test_expand_simple_var():
|
||||
node = _first_arg("echo $FOO")
|
||||
session = _session(env={"FOO": "bar"})
|
||||
result = _run(expand_node(node, session, _execute_fn()))
|
||||
assert result == "bar"
|
||||
|
||||
|
||||
def test_expand_simple_var_missing():
|
||||
node = _first_arg("echo $MISSING")
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_expand_special_question():
|
||||
node = _first_arg("echo $?")
|
||||
session = _session()
|
||||
session.last_exit_code = 42
|
||||
result = _run(expand_node(node, session, _execute_fn()))
|
||||
assert result == "42"
|
||||
|
||||
|
||||
def test_expand_special_at():
|
||||
node = _first_arg("echo $@")
|
||||
cs = CallStack()
|
||||
cs.push(["a", "b", "c"])
|
||||
result = _run(expand_node(node, _session(), _execute_fn(), call_stack=cs))
|
||||
assert result == "a b c"
|
||||
|
||||
|
||||
def test_expand_positional():
|
||||
node = _first_arg("echo $1")
|
||||
cs = CallStack()
|
||||
cs.push(["first", "second"])
|
||||
result = _run(expand_node(node, _session(), _execute_fn(), call_stack=cs))
|
||||
assert result == "first"
|
||||
|
||||
|
||||
def test_expand_dollar_zero():
|
||||
node = _first_arg("echo $0")
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == "mirage"
|
||||
|
||||
|
||||
# ── brace expansion ${VAR} ─────────────────────
|
||||
|
||||
|
||||
def test_expand_braces():
|
||||
node = _first_arg("echo ${FOO}")
|
||||
session = _session(env={"FOO": "hello"})
|
||||
result = _run(expand_node(node, session, _execute_fn()))
|
||||
assert result == "hello"
|
||||
|
||||
|
||||
def test_expand_braces_default():
|
||||
node = _first_arg("echo ${FOO:-default_val}")
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == "default_val"
|
||||
|
||||
|
||||
def test_expand_braces_default_not_used():
|
||||
node = _first_arg("echo ${FOO:-default_val}")
|
||||
session = _session(env={"FOO": "exists"})
|
||||
result = _run(expand_node(node, session, _execute_fn()))
|
||||
assert result == "exists"
|
||||
|
||||
|
||||
# ── command substitution $(cmd) ─────────────────
|
||||
|
||||
|
||||
def test_expand_command_sub():
|
||||
node = _first_arg("echo $(whoami)")
|
||||
execute_fn = AsyncMock()
|
||||
io = IOResult()
|
||||
io.stdout = b"testuser\n"
|
||||
execute_fn.return_value = io
|
||||
result = _run(expand_node(node, _session(), execute_fn))
|
||||
assert result == "testuser"
|
||||
execute_fn.assert_called_once()
|
||||
|
||||
|
||||
# ── arithmetic expansion $((expr)) ──────────────
|
||||
|
||||
|
||||
def test_expand_arithmetic():
|
||||
node = _first_arg("echo $((1 + 2))")
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == "3"
|
||||
|
||||
|
||||
def test_expand_arithmetic_multiply():
|
||||
node = _first_arg("echo $((3 * 4))")
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == "12"
|
||||
|
||||
|
||||
# ── concatenation $VAR/file.txt ─────────────────
|
||||
|
||||
|
||||
def test_expand_concatenation():
|
||||
node = _first_arg("echo $DIR/file.txt")
|
||||
session = _session(env={"DIR": "/data"})
|
||||
result = _run(expand_node(node, session, _execute_fn()))
|
||||
assert result == "/data/file.txt"
|
||||
|
||||
|
||||
# ── string "hello $VAR" ────────────────────────
|
||||
|
||||
|
||||
def test_expand_double_quoted():
|
||||
node = _first_arg('echo "hello $NAME"')
|
||||
session = _session(env={"NAME": "world"})
|
||||
result = _run(expand_node(node, session, _execute_fn()))
|
||||
assert result == "hello world"
|
||||
|
||||
|
||||
def test_expand_double_quoted_no_var():
|
||||
node = _first_arg('echo "plain text"')
|
||||
result = _run(expand_node(node, _session(), _execute_fn()))
|
||||
assert result == "plain text"
|
||||
|
||||
|
||||
# ── raw string 'no expansion' ──────────────────
|
||||
|
||||
|
||||
def test_expand_raw_string():
|
||||
node = _first_arg("echo 'no $expansion'")
|
||||
session = _session(env={"expansion": "SHOULD_NOT_SEE"})
|
||||
result = _run(expand_node(node, session, _execute_fn()))
|
||||
assert result == "no $expansion"
|
||||
|
||||
|
||||
# ── expand_parts ───────────────────────────────
|
||||
|
||||
|
||||
def test_expand_parts_basic():
|
||||
parts = _cmd_parts("echo hello world")
|
||||
session = _session()
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo", "hello", "world"]
|
||||
|
||||
|
||||
def test_expand_parts_with_var():
|
||||
parts = _cmd_parts("echo $A $B")
|
||||
session = _session(env={"A": "foo", "B": "bar"})
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo", "foo", "bar"]
|
||||
|
||||
|
||||
def test_expand_parts_cmd_sub_splits():
|
||||
parts = _cmd_parts("echo $(ls)")
|
||||
execute_fn = AsyncMock()
|
||||
io = IOResult()
|
||||
io.stdout = b"file1\nfile2\nfile3\n"
|
||||
execute_fn.return_value = io
|
||||
session = _session()
|
||||
result = _run(expand_parts(parts, session, execute_fn))
|
||||
assert "file1" in result
|
||||
assert "file2" in result
|
||||
assert "file3" in result
|
||||
|
||||
|
||||
def test_expand_parts_empty_var_skipped():
|
||||
parts = _cmd_parts("echo $EMPTY")
|
||||
session = _session()
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo"]
|
||||
|
||||
|
||||
def test_expand_parts_splits_unquoted_var():
|
||||
parts = _cmd_parts("echo $VAR")
|
||||
session = _session(env={"VAR": "a b c"})
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo", "a", "b", "c"]
|
||||
|
||||
|
||||
def test_expand_parts_no_split_quoted_var():
|
||||
parts = _cmd_parts('echo "$VAR"')
|
||||
session = _session(env={"VAR": "a b c"})
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo", "a b c"]
|
||||
|
||||
|
||||
def test_expand_parts_splits_dollar_at():
|
||||
parts = _cmd_parts("echo $@")
|
||||
cs = CallStack()
|
||||
cs.push(["a", "b", "c"])
|
||||
session = _session()
|
||||
result = _run(expand_parts(parts, session, _execute_fn(), call_stack=cs))
|
||||
assert result == ["echo", "a", "b", "c"]
|
||||
|
||||
|
||||
def test_expand_parts_no_split_empty_var():
|
||||
parts = _cmd_parts("echo $EMPTY")
|
||||
session = _session(env={"EMPTY": ""})
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo"]
|
||||
|
||||
|
||||
def test_expand_parts_splits_expansion_braces():
|
||||
parts = _cmd_parts("echo ${VAR}")
|
||||
session = _session(env={"VAR": "x y z"})
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo", "x", "y", "z"]
|
||||
|
||||
|
||||
def test_expand_parts_keeps_empty_raw_string():
|
||||
"""A quoted empty literal '' is a real (empty) argument, not dropped."""
|
||||
parts = _cmd_parts("echo a '' b")
|
||||
result = _run(expand_parts(parts, _session(), _execute_fn()))
|
||||
assert result == ["echo", "a", "", "b"]
|
||||
|
||||
|
||||
def test_expand_parts_keeps_empty_double_quote():
|
||||
"""A quoted empty literal "" is a real (empty) argument, not dropped."""
|
||||
parts = _cmd_parts('echo a "" b')
|
||||
result = _run(expand_parts(parts, _session(), _execute_fn()))
|
||||
assert result == ["echo", "a", "", "b"]
|
||||
|
||||
|
||||
def test_expand_parts_keeps_empty_quoted_var():
|
||||
"""A quoted expansion that yields empty stays a word (bash keeps "$x")."""
|
||||
parts = _cmd_parts('echo a "$EMPTY" b')
|
||||
session = _session(env={"EMPTY": ""})
|
||||
result = _run(expand_parts(parts, session, _execute_fn()))
|
||||
assert result == ["echo", "a", "", "b"]
|
||||
|
||||
|
||||
def test_expand_parts_drops_empty_quoted_dollar_at():
|
||||
"""Quoted "$@" with no positional args yields zero words, not one empty."""
|
||||
parts = _cmd_parts('echo a "$@" b')
|
||||
result = _run(expand_parts(parts, _session(), _execute_fn()))
|
||||
assert result == ["echo", "a", "b"]
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# classify_word
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
|
||||
def test_classify_relative_text():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("hello", reg, "/data")
|
||||
assert result == "hello"
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_classify_absolute_file():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("/data/file.txt", reg, "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/data/file.txt"
|
||||
assert result.directory == "/data/"
|
||||
assert result.pattern is None
|
||||
assert result.resolved is True
|
||||
|
||||
|
||||
def test_classify_absolute_directory():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("/data/subdir/", reg, "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.resolved is False
|
||||
|
||||
|
||||
def test_classify_absolute_glob():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("/data/*.csv", reg, "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/data/*.csv"
|
||||
assert result.directory == "/data/"
|
||||
assert result.pattern == "*.csv"
|
||||
assert result.resolved is False
|
||||
|
||||
|
||||
def test_classify_absolute_glob_question():
|
||||
reg = _mock_registry(["/s3/"])
|
||||
result = classify_word("/s3/file?.txt", reg, "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.pattern == "file?.txt"
|
||||
|
||||
|
||||
def test_classify_absolute_glob_bracket():
|
||||
reg = _mock_registry(["/s3/"])
|
||||
result = classify_word("/s3/file[0-9].txt", reg, "/")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.pattern == "file[0-9].txt"
|
||||
|
||||
|
||||
def test_classify_relative_glob():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("*.txt", reg, "/data")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/data/*.txt"
|
||||
assert result.directory == "/data/"
|
||||
assert result.pattern == "*.txt"
|
||||
|
||||
|
||||
def test_classify_relative_dotdot_glob():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("../*.txt", reg, "/data/sub")
|
||||
assert isinstance(result, PathSpec)
|
||||
assert result.virtual == "/data/*.txt"
|
||||
assert result.directory == "/data/"
|
||||
assert result.pattern == "*.txt"
|
||||
|
||||
|
||||
def test_classify_no_mount_returns_text():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("/unknown/file.txt", reg, "/")
|
||||
assert result == "/unknown/file.txt"
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_classify_no_mount_glob_returns_text():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("/unknown/*.txt", reg, "/")
|
||||
assert result == "/unknown/*.txt"
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_classify_relative_no_glob_always_text():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("error", reg, "/data")
|
||||
assert result == "error"
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
def test_classify_relative_glob_no_mount_returns_text():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_word("*.txt", reg, "/nomount")
|
||||
assert result == "*.txt"
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# classify_parts
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
|
||||
def test_classify_parts_command_name_stays_str():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_parts(["cat", "/data/file.txt"], reg, "/")
|
||||
assert result[0] == "cat"
|
||||
assert isinstance(result[0], str)
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].virtual == "/data/file.txt"
|
||||
|
||||
|
||||
def test_classify_parts_mixed():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_parts(["grep", "-n", "pattern", "/data/file.txt"], reg,
|
||||
"/")
|
||||
assert result[0] == "grep"
|
||||
assert result[1] == "-n"
|
||||
assert result[2] == "pattern"
|
||||
assert isinstance(result[3], PathSpec)
|
||||
|
||||
|
||||
def test_classify_parts_multiple_paths():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_parts(["diff", "/data/a.txt", "/data/b.txt"], reg, "/")
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert isinstance(result[2], PathSpec)
|
||||
assert result[1].virtual == "/data/a.txt"
|
||||
assert result[2].virtual == "/data/b.txt"
|
||||
|
||||
|
||||
def test_classify_parts_glob_in_args():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_parts(["cat", "/data/*.csv"], reg, "/")
|
||||
assert isinstance(result[1], PathSpec)
|
||||
assert result[1].pattern == "*.csv"
|
||||
|
||||
|
||||
def test_classify_parts_no_paths():
|
||||
reg = _mock_registry(["/data/"])
|
||||
result = classify_parts(["echo", "hello", "world"], reg, "/")
|
||||
assert all(isinstance(r, str) for r in result)
|
||||
|
||||
|
||||
def test_classify_parts_empty():
|
||||
reg = _mock_registry()
|
||||
result = classify_parts([], reg, "/")
|
||||
assert result == []
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════
|
||||
# expand_and_classify
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
|
||||
def test_expand_and_classify_text():
|
||||
parts = _cmd_parts("echo hello world")[1:]
|
||||
reg = _mock_registry(["/data/"])
|
||||
session = _session()
|
||||
result = _run(expand_and_classify(parts, session, _execute_fn(), reg, "/"))
|
||||
assert result == ["hello", "world"]
|
||||
|
||||
|
||||
def test_expand_and_classify_glob():
|
||||
parts = _cmd_parts("echo *.txt")[1:]
|
||||
reg = _mock_registry(["/data/"])
|
||||
session = _session(cwd="/data")
|
||||
result = _run(
|
||||
expand_and_classify(parts, session, _execute_fn(), reg, "/data"))
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], PathSpec)
|
||||
assert result[0].pattern == "*.txt"
|
||||
|
||||
|
||||
def test_expand_and_classify_absolute_path():
|
||||
parts = _cmd_parts("echo /data/file.txt")[1:]
|
||||
reg = _mock_registry(["/data/"])
|
||||
session = _session()
|
||||
result = _run(expand_and_classify(parts, session, _execute_fn(), reg, "/"))
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], PathSpec)
|
||||
assert result[0].virtual == "/data/file.txt"
|
||||
assert result[0].resolved is True
|
||||
|
||||
|
||||
def test_expand_and_classify_var_to_glob():
|
||||
parts = _cmd_parts("echo $PATTERN")[1:]
|
||||
reg = _mock_registry(["/s3/"])
|
||||
session = _session(env={"PATTERN": "/s3/*.csv"})
|
||||
result = _run(expand_and_classify(parts, session, _execute_fn(), reg, "/"))
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], PathSpec)
|
||||
assert result[0].pattern == "*.csv"
|
||||
|
||||
|
||||
def test_expand_and_classify_var_to_path():
|
||||
parts = _cmd_parts("echo $FILE")[1:]
|
||||
reg = _mock_registry(["/data/"])
|
||||
session = _session(env={"FILE": "/data/report.csv"})
|
||||
result = _run(expand_and_classify(parts, session, _execute_fn(), reg, "/"))
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], PathSpec)
|
||||
assert result[0].virtual == "/data/report.csv"
|
||||
assert result[0].resolved is True
|
||||
|
||||
|
||||
def test_expand_and_classify_no_mount_stays_text():
|
||||
parts = _cmd_parts("echo /unknown/file.txt")[1:]
|
||||
reg = _mock_registry(["/data/"])
|
||||
session = _session()
|
||||
result = _run(expand_and_classify(parts, session, _execute_fn(), reg, "/"))
|
||||
assert result == ["/unknown/file.txt"]
|
||||
assert isinstance(result[0], str)
|
||||
@@ -0,0 +1,64 @@
|
||||
# ========= 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 mirage.resource.gdocs import GDocsConfig, GDocsResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.slack import SlackConfig, SlackResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def test_file_prompt_includes_mounted_resources():
|
||||
ram = RAMResource()
|
||||
ws = Workspace(
|
||||
{"/": (ram, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
prompt = ws.file_prompt
|
||||
assert "/" in prompt
|
||||
assert "In-memory" in prompt
|
||||
|
||||
|
||||
def test_file_prompt_shows_write_commands_for_writable_mounts():
|
||||
slack = SlackResource(config=SlackConfig(token="xoxb-fake"))
|
||||
ws = Workspace(
|
||||
{"/slack": (slack, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
prompt = ws.file_prompt
|
||||
assert "/slack" in prompt
|
||||
assert "slack-post-message" in prompt
|
||||
|
||||
|
||||
def test_file_prompt_hides_write_commands_for_readonly():
|
||||
slack = SlackResource(config=SlackConfig(token="xoxb-fake"))
|
||||
ws = Workspace(
|
||||
{"/slack": (slack, MountMode.READ)},
|
||||
mode=MountMode.READ,
|
||||
)
|
||||
prompt = ws.file_prompt
|
||||
assert "/slack" in prompt
|
||||
assert "slack-post-message" not in prompt
|
||||
|
||||
|
||||
def test_file_prompt_substitutes_prefix_in_write_prompt():
|
||||
cfg = GDocsConfig(client_id="x", client_secret="y", refresh_token="z")
|
||||
gdocs = GDocsResource(config=cfg)
|
||||
ws = Workspace(
|
||||
{"/home/zecheng/gdocs": (gdocs, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
prompt = ws.file_prompt
|
||||
assert "/home/zecheng/gdocs/owned/<file>.gdoc.json" in prompt
|
||||
assert "{prefix}" not in prompt
|
||||
@@ -0,0 +1,201 @@
|
||||
# ========= 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. =========
|
||||
"""Tests for find's action flags (-delete, -print0, -ls).
|
||||
|
||||
Per-resource find handlers only emit matched paths. The dispatcher
|
||||
(`mirage/workspace/executor/find_action_dispatch.py:_apply_find_actions`)
|
||||
reads the parsed action flags and applies the corresponding side
|
||||
effect or output reformat.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _ws() -> Workspace:
|
||||
return Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def _ws_two_mounts() -> Workspace:
|
||||
return Workspace({
|
||||
"/a": (RAMResource(), MountMode.WRITE),
|
||||
"/b": (RAMResource(), MountMode.WRITE),
|
||||
})
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
async def _setup_html_files(ws: Workspace) -> None:
|
||||
ws.create_session("s")
|
||||
await ws.execute("mkdir -p /a/b", session_id="s")
|
||||
await ws.execute("touch /foo.html /bar.htm /a/b/baz.html", session_id="s")
|
||||
|
||||
|
||||
# ── -delete ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_delete_removes_matched_files() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup_html_files(ws)
|
||||
r = await ws.execute("find / -name '*.html' -delete", session_id="s")
|
||||
assert r.exit_code == 0
|
||||
assert await r.stdout_str() == ""
|
||||
assert await r.stderr_str() == ""
|
||||
# html files gone
|
||||
check = await ws.execute("find / -name '*.html'", session_id="s")
|
||||
assert await check.stdout_str() == ""
|
||||
# htm preserved
|
||||
htm = await ws.execute("find / -name '*.htm'", session_id="s")
|
||||
assert "/bar.htm" in await htm.stdout_str()
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_delete_silent_unless_print() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup_html_files(ws)
|
||||
r = await ws.execute("find / -name '*.html' -delete", session_id="s")
|
||||
assert await r.stdout_str() == ""
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_delete_with_print_emits_matches() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup_html_files(ws)
|
||||
r = await ws.execute("find / -name '*.html' -print -delete",
|
||||
session_id="s")
|
||||
out = await r.stdout_str()
|
||||
assert "/foo.html" in out
|
||||
assert "/a/b/baz.html" in out
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_delete_skips_mount_roots() -> None:
|
||||
# A mount root in the match set must not be unlinked: mounts
|
||||
# are structural metadata.
|
||||
async def _go():
|
||||
ws = _ws_two_mounts()
|
||||
ws.create_session("s")
|
||||
await ws.execute("touch /a/x.html /b/y.html", session_id="s")
|
||||
# Force mount roots into the match set via -type d, then
|
||||
# -delete must skip them while still listing them in find.
|
||||
# Without a -name pattern the synthetic /a and /b appear.
|
||||
await ws.execute("find / -type d -delete", session_id="s")
|
||||
# Mount roots survive (delete may report errors for other
|
||||
# dir entries, that's fine).
|
||||
ls = await ws.execute("ls /", session_id="s")
|
||||
out = await ls.stdout_str()
|
||||
assert "a" in out
|
||||
assert "b" in out
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_delete_deepest_first() -> None:
|
||||
# Children deleted before parents so non-empty-dir errors
|
||||
# don't fire.
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
ws.create_session("s")
|
||||
await ws.execute("mkdir -p /tmp/a/b", session_id="s")
|
||||
await ws.execute("touch /tmp/a/b/file.txt", session_id="s")
|
||||
r = await ws.execute("find /tmp -name '*.txt' -delete", session_id="s")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
# ── -print0 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_print0_separates_with_nul() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup_html_files(ws)
|
||||
r = await ws.execute("find / -name '*.html' -print0", session_id="s")
|
||||
out = await r.stdout_str()
|
||||
assert "\x00" in out
|
||||
assert "\n" not in out.replace("\x00", "")
|
||||
assert out.endswith("\x00")
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
# ── -ls ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ls_emits_long_format_per_match() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup_html_files(ws)
|
||||
r = await ws.execute("find / -name '*.html' -ls", session_id="s")
|
||||
out = await r.stdout_str()
|
||||
# ls -ld output per match: starts with permission bits.
|
||||
lines = [ln for ln in out.split("\n") if ln]
|
||||
assert len(lines) >= 2
|
||||
for line in lines:
|
||||
assert line.startswith(("-", "d", "l"))
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
# ── default behavior unchanged ─────────────────────────────────
|
||||
|
||||
|
||||
def test_no_action_flag_unchanged() -> None:
|
||||
# find without action flags must behave as before.
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup_html_files(ws)
|
||||
r = await ws.execute("find / -name '*.html'", session_id="s")
|
||||
out = await r.stdout_str()
|
||||
assert "/foo.html" in out
|
||||
assert "/a/b/baz.html" in out
|
||||
assert "\x00" not in out
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
# ── synthetic mount entries honor -name ───────────────────────
|
||||
|
||||
|
||||
def test_mount_entries_filtered_by_name() -> None:
|
||||
# Without -type filter, mount roots are synthesized as dir
|
||||
# entries. -name must still apply to those entries so user
|
||||
# intent ("find files matching X") isn't overridden by
|
||||
# spurious mount listings.
|
||||
async def _go():
|
||||
ws = _ws_two_mounts()
|
||||
ws.create_session("s")
|
||||
# /a and /b are mounts; -name 'a' should match only /a.
|
||||
r = await ws.execute("find / -name 'a' -type d", session_id="s")
|
||||
lines = (await r.stdout_str()).strip().split("\n")
|
||||
assert "/a" in lines
|
||||
assert "/b" not in lines
|
||||
|
||||
_run(_go())
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
async def _setup() -> Workspace:
|
||||
ws = Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
ws.create_session("s")
|
||||
await ws.execute("mkdir -p /data/sub /data/emptydir", session_id="s")
|
||||
await ws.execute("touch /data/empty.txt /data/sub/nested.txt",
|
||||
session_id="s")
|
||||
await ws.execute("printf x > /data/sub/full.txt", session_id="s")
|
||||
return ws
|
||||
|
||||
|
||||
def test_empty_matches_empty_files_and_dirs() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = await _setup()
|
||||
r = await ws.execute("find /data -empty", session_id="s")
|
||||
out = sorted((await r.stdout_str()).split())
|
||||
assert out == [
|
||||
"/data/empty.txt", "/data/emptydir", "/data/sub/nested.txt"
|
||||
]
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_empty_with_type_d() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = await _setup()
|
||||
r = await ws.execute("find /data -type d -empty", session_id="s")
|
||||
assert sorted((await r.stdout_str()).split()) == ["/data/emptydir"]
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_empty_with_type_f() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = await _setup()
|
||||
r = await ws.execute("find /data -type f -empty", session_id="s")
|
||||
assert sorted((await r.stdout_str()).split()) == [
|
||||
"/data/empty.txt", "/data/sub/nested.txt"
|
||||
]
|
||||
|
||||
_run(_go())
|
||||
@@ -0,0 +1,56 @@
|
||||
import asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _ws() -> Workspace:
|
||||
return Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
async def _setup(ws: Workspace) -> None:
|
||||
ws.create_session("s")
|
||||
await ws.execute("mkdir -p /data/sub", session_id="s")
|
||||
await ws.execute("touch /data/a.txt /data/sub/nested.txt", session_id="s")
|
||||
|
||||
|
||||
def test_unknown_predicate_exits_1_and_prints_nothing() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup(ws)
|
||||
r = await ws.execute("find /data -boguspredicate", session_id="s")
|
||||
assert r.exit_code == 1
|
||||
assert await r.stdout_str() == ""
|
||||
assert "-boguspredicate" in await r.stderr_str()
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_unsupported_regex_exits_1() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup(ws)
|
||||
r = await ws.execute("find /data -regex '.*'", session_id="s")
|
||||
assert r.exit_code == 1
|
||||
|
||||
_run(_go())
|
||||
|
||||
|
||||
def test_supported_name_still_exits_0() -> None:
|
||||
|
||||
async def _go():
|
||||
ws = _ws()
|
||||
await _setup(ws)
|
||||
r = await ws.execute("find /data -name '*.txt'", session_id="s")
|
||||
assert r.exit_code == 0
|
||||
out = await r.stdout_str()
|
||||
assert "/data/a.txt" in out
|
||||
|
||||
_run(_go())
|
||||
@@ -0,0 +1,132 @@
|
||||
# ========= 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 asyncio
|
||||
import time
|
||||
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import ConsistencyPolicy, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from tests.integration.s3_mock import MultiBucketSession, patch_s3_session
|
||||
|
||||
|
||||
def test_disk_always_refetches_after_external_mutation(tmp_path):
|
||||
root = tmp_path / "disk"
|
||||
root.mkdir()
|
||||
(root / "file.txt").write_bytes(b"v1")
|
||||
|
||||
resource = DiskResource(root=str(root))
|
||||
ws = Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=ConsistencyPolicy.ALWAYS,
|
||||
)
|
||||
|
||||
async def run() -> tuple[bytes, bytes]:
|
||||
io1 = await ws.execute("cat /data/file.txt")
|
||||
first = await io1.materialize_stdout()
|
||||
time.sleep(1.1)
|
||||
(root / "file.txt").write_bytes(b"v2")
|
||||
io2 = await ws.execute("cat /data/file.txt")
|
||||
second = await io2.materialize_stdout()
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(run())
|
||||
assert first == b"v1"
|
||||
assert second == b"v2", (
|
||||
"ALWAYS must refetch from disk after mtime changed; got stale cache")
|
||||
|
||||
|
||||
def test_disk_lazy_keeps_stale_cache_after_external_mutation(tmp_path):
|
||||
root = tmp_path / "disk"
|
||||
root.mkdir()
|
||||
(root / "file.txt").write_bytes(b"v1")
|
||||
|
||||
resource = DiskResource(root=str(root))
|
||||
ws = Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=ConsistencyPolicy.LAZY,
|
||||
)
|
||||
|
||||
async def run() -> tuple[bytes, bytes]:
|
||||
io1 = await ws.execute("cat /data/file.txt")
|
||||
first = await io1.materialize_stdout()
|
||||
time.sleep(1.1)
|
||||
(root / "file.txt").write_bytes(b"v2")
|
||||
io2 = await ws.execute("cat /data/file.txt")
|
||||
second = await io2.materialize_stdout()
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(run())
|
||||
assert first == b"v1"
|
||||
assert second in (b"v1", b"v2"), (
|
||||
"LAZY allowed to serve cached bytes; this test just confirms no crash")
|
||||
|
||||
|
||||
def test_s3_always_warm_read_serves_cache_for_non_md5_fingerprint():
|
||||
"""Multipart-style ETags are not the MD5 of the content. The cold
|
||||
read must stamp the cache entry with the backend ETag so a warm read
|
||||
under ALWAYS passes the freshness check and serves from cache
|
||||
instead of evicting and refetching on every read."""
|
||||
store = {"data.txt": b"name,age\nalice,30\n"}
|
||||
session = MultiBucketSession({"test-bucket": store}, etag_suffix="-2")
|
||||
client = session._client
|
||||
with patch_s3_session(session):
|
||||
config = S3Config(
|
||||
bucket="test-bucket",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="fake",
|
||||
aws_secret_access_key="fake",
|
||||
)
|
||||
ws = Workspace(
|
||||
{"/s3": (S3Resource(config), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=ConsistencyPolicy.ALWAYS,
|
||||
)
|
||||
|
||||
async def run() -> tuple[bytes, bytes]:
|
||||
io1 = await ws.execute("cat /s3/data.txt | wc -c")
|
||||
first = await io1.materialize_stdout()
|
||||
io2 = await ws.execute("cat /s3/data.txt | wc -c")
|
||||
second = await io2.materialize_stdout()
|
||||
return first, second
|
||||
|
||||
first, second = asyncio.run(run())
|
||||
assert first == second == b"18\n"
|
||||
assert client.calls["head_object"] >= 1, (
|
||||
"ALWAYS must consult the remote fingerprint on the warm read")
|
||||
assert client.calls["get_object"] == 1, (
|
||||
"warm read with an unchanged remote fingerprint must serve from "
|
||||
"cache; a second get_object means the entry was evicted")
|
||||
|
||||
|
||||
def test_ram_falls_back_to_lazy_when_fingerprint_absent():
|
||||
resource = RAMResource()
|
||||
resource._store.files["/file.txt"] = b"v1"
|
||||
ws = Workspace(
|
||||
{"/data": (resource, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
consistency=ConsistencyPolicy.ALWAYS,
|
||||
)
|
||||
|
||||
async def run() -> bytes:
|
||||
io1 = await ws.execute("cat /data/file.txt")
|
||||
return await io1.materialize_stdout()
|
||||
|
||||
data = asyncio.run(run())
|
||||
assert data == b"v1", (
|
||||
"RAM read under ALWAYS must succeed (no fingerprint → LAZY fallback)")
|
||||
@@ -0,0 +1,70 @@
|
||||
# ========= 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 subprocess
|
||||
import tempfile
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace.fuse import FuseManager
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
def _fake_mount(monkeypatch):
|
||||
monkeypatch.setattr("mirage.workspace.fuse.mount_background",
|
||||
lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(subprocess, "run", lambda *_args, **_kwargs: None)
|
||||
|
||||
|
||||
class TestFuseManager:
|
||||
|
||||
def test_initial_state(self):
|
||||
fm = FuseManager()
|
||||
assert fm.mountpoint is None
|
||||
|
||||
def test_close_without_mountpoint_does_nothing(self):
|
||||
fm = FuseManager()
|
||||
fm.close()
|
||||
assert fm.mountpoint is None
|
||||
|
||||
def test_close_keeps_caller_owned_mountpoint(self, monkeypatch, tmp_path):
|
||||
# Regression: explicit mountpoints are caller-owned deployment paths.
|
||||
# close() should unmount FUSE, not remove the directory given by the
|
||||
# caller.
|
||||
_fake_mount(monkeypatch)
|
||||
|
||||
ws = Workspace({"/a/": RAMResource()}, mode=MountMode.WRITE)
|
||||
fm = FuseManager()
|
||||
fm.setup(ws._ops, prefix="/a/", mountpoint=str(tmp_path))
|
||||
fm.close()
|
||||
|
||||
assert tmp_path.exists()
|
||||
assert fm.mountpoint is None
|
||||
|
||||
def test_close_removes_generated_mountpoint(self, monkeypatch, tmp_path):
|
||||
# Generated temp mountpoints are Mirage-owned, so close() removes the
|
||||
# directory it created with an empty-directory rmdir.
|
||||
generated = tmp_path / "mirage-generated"
|
||||
generated.mkdir()
|
||||
_fake_mount(monkeypatch)
|
||||
monkeypatch.setattr(tempfile, "mkdtemp",
|
||||
lambda *_args, **_kwargs: str(generated))
|
||||
|
||||
ws = Workspace({"/a/": RAMResource()}, mode=MountMode.WRITE)
|
||||
fm = FuseManager()
|
||||
fm.setup(ws._ops, prefix="/a/")
|
||||
fm.close()
|
||||
|
||||
assert not generated.exists()
|
||||
assert fm.mountpoint is None
|
||||
@@ -0,0 +1,155 @@
|
||||
# ========= 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 subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import FuseManager, Mount, MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
class _FakeThread:
|
||||
|
||||
def __init__(self):
|
||||
self.alive = True
|
||||
|
||||
|
||||
def _fake_mount(monkeypatch):
|
||||
monkeypatch.setattr("mirage.workspace.fuse.mount_background",
|
||||
lambda ops, mountpoint, root_prefix="": _FakeThread())
|
||||
monkeypatch.setattr(subprocess, "run", lambda *_args, **_kwargs: None)
|
||||
|
||||
|
||||
def test_add_fuse_mount_registers_and_returns_mountpoint(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace({"/a/": RAMResource()}, mode=MountMode.WRITE)
|
||||
mp = ws.add_fuse_mount("/a/", "/tmp/forced-a")
|
||||
assert mp == "/tmp/forced-a"
|
||||
assert ws.fuse_mountpoints == {"/a/": "/tmp/forced-a"}
|
||||
ws.remove_fuse_mount("/a/")
|
||||
assert ws.fuse_mountpoints == {}
|
||||
|
||||
|
||||
def test_workspace_close_unmounts_managers(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
with Workspace({"/a/": RAMResource()}, mode=MountMode.WRITE) as ws:
|
||||
ws.add_fuse_mount("/a/", "/tmp/tracked-a")
|
||||
assert ws.fuse_mountpoints == {"/a/": "/tmp/tracked-a"}
|
||||
assert ws.fuse_mountpoints == {}
|
||||
|
||||
|
||||
def test_multiple_fuse_mounts_are_independent(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace({
|
||||
"/a/": RAMResource(),
|
||||
"/b/": RAMResource()
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
ws.add_fuse_mount("/a/", "/tmp/mp-a")
|
||||
ws.add_fuse_mount("/b/", "/tmp/mp-b")
|
||||
assert ws.fuse_mountpoints == {"/a/": "/tmp/mp-a", "/b/": "/tmp/mp-b"}
|
||||
assert set(ws._fuse_managers) == {"/a/", "/b/"}
|
||||
ws.remove_fuse_mount("/a/")
|
||||
assert ws.fuse_mountpoints == {"/b/": "/tmp/mp-b"}
|
||||
assert set(ws._fuse_managers) == {"/b/"}
|
||||
|
||||
|
||||
def test_collision_rejected_before_mount(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr("mirage.workspace.fuse.mount_background",
|
||||
lambda ops, mountpoint, root_prefix="":
|
||||
(calls.append(mountpoint) or _FakeThread()))
|
||||
monkeypatch.setattr(subprocess, "run", lambda *_args, **_kwargs: None)
|
||||
ws = Workspace({
|
||||
"/a/": RAMResource(),
|
||||
"/b/": RAMResource()
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
ws.add_fuse_mount("/a/", "/tmp/dup-mp")
|
||||
with pytest.raises(ValueError):
|
||||
ws.add_fuse_mount("/b/", "/tmp/dup-mp")
|
||||
assert ws.fuse_mountpoints == {"/a/": "/tmp/dup-mp"}
|
||||
assert calls == ["/tmp/dup-mp"]
|
||||
|
||||
|
||||
def test_double_unmount_is_idempotent(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace({"/a/": RAMResource()}, mode=MountMode.WRITE)
|
||||
fm = FuseManager()
|
||||
fm.setup(ws._ops, "/a/", mountpoint="/tmp/idem-a")
|
||||
fm.unmount()
|
||||
fm.unmount() # must not raise
|
||||
assert fm.mountpoint is None
|
||||
|
||||
|
||||
def test_mount_spec_fuse_true_single(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace({"/gdocs/": Mount(RAMResource(), fuse=True)},
|
||||
mode=MountMode.WRITE)
|
||||
mps = ws.fuse_mountpoints
|
||||
assert set(mps) == {"/gdocs/"}
|
||||
assert mps["/gdocs/"]
|
||||
assert ws.fuse_mountpoint == mps["/gdocs/"]
|
||||
|
||||
|
||||
def test_mount_spec_fuse_pinned_path(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace({"/whatever/": Mount(RAMResource(), fuse="/tmp/pinned-x")},
|
||||
mode=MountMode.WRITE)
|
||||
assert ws.fuse_mountpoints["/whatever/"] == "/tmp/pinned-x"
|
||||
|
||||
|
||||
def test_mount_spec_fuse_each_of_multiple(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace(
|
||||
{
|
||||
"/a/": Mount(RAMResource(), fuse=True),
|
||||
"/b/": Mount(RAMResource(), fuse=True)
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
assert set(ws.fuse_mountpoints) == {"/a/", "/b/"}
|
||||
with pytest.raises(RuntimeError):
|
||||
ws.fuse_mountpoint
|
||||
|
||||
|
||||
def test_mount_spec_mode_inherits_and_override(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace(
|
||||
{
|
||||
"/inherit/": Mount(RAMResource()),
|
||||
"/override/": Mount(RAMResource(), mode=MountMode.READ),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
assert ws.mount("/inherit/").mode == MountMode.WRITE
|
||||
assert ws.mount("/override/").mode == MountMode.READ
|
||||
|
||||
|
||||
def test_no_fuse_when_bare_or_tuple(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
ws = Workspace(
|
||||
{
|
||||
"/bare/": RAMResource(),
|
||||
"/tup/": (RAMResource(), MountMode.WRITE),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
assert ws.fuse_mountpoints == {}
|
||||
|
||||
|
||||
def test_mount_spec_fuse_unmounts_on_close(monkeypatch):
|
||||
_fake_mount(monkeypatch)
|
||||
with Workspace({"/gdocs/": Mount(RAMResource(), fuse=True)},
|
||||
mode=MountMode.WRITE) as ws:
|
||||
assert set(ws.fuse_mountpoints) == {"/gdocs/"}
|
||||
assert ws.fuse_mountpoints == {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
def _ws():
|
||||
return Workspace({
|
||||
"/a/": RAMResource(),
|
||||
"/b/": RAMResource()
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def test_no_fuse_mounts_returns_empty_and_none():
|
||||
with _ws() as ws:
|
||||
assert ws.fuse_mountpoints == {}
|
||||
assert ws.fuse_mountpoint is None
|
||||
|
||||
|
||||
def test_register_one_mount_exposes_singular():
|
||||
with _ws() as ws:
|
||||
ws._register_fuse("/a/", "/tmp/mp-a")
|
||||
assert ws.fuse_mountpoints == {"/a/": "/tmp/mp-a"}
|
||||
assert ws.fuse_mountpoint == "/tmp/mp-a"
|
||||
|
||||
|
||||
def test_register_two_distinct_paths_singular_raises():
|
||||
with _ws() as ws:
|
||||
ws._register_fuse("/a/", "/tmp/mp-a")
|
||||
ws._register_fuse("/b/", "/tmp/mp-b")
|
||||
assert set(ws.fuse_mountpoints) == {"/a/", "/b/"}
|
||||
with pytest.raises(RuntimeError):
|
||||
_ = ws.fuse_mountpoint
|
||||
|
||||
|
||||
def test_register_colliding_path_raises():
|
||||
with _ws() as ws:
|
||||
ws._register_fuse("/a/", "/tmp/same")
|
||||
with pytest.raises(ValueError):
|
||||
ws._register_fuse("/b/", "/tmp/same")
|
||||
|
||||
|
||||
def test_deregister_removes_entry():
|
||||
with _ws() as ws:
|
||||
ws._register_fuse("/a/", "/tmp/mp-a")
|
||||
ws._deregister_fuse("/a/")
|
||||
assert ws.fuse_mountpoints == {}
|
||||
assert ws.fuse_mountpoint is None
|
||||
@@ -0,0 +1,77 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _build_ws() -> Workspace:
|
||||
r = RAMResource()
|
||||
r._store.dirs.add("/")
|
||||
r._store.dirs.add("/src")
|
||||
r._store.files["/src/a.js"] = b'legacyFetch("/api");\n'
|
||||
return Workspace({"/": (r, MountMode.WRITE)})
|
||||
|
||||
|
||||
async def _run(cmd: str):
|
||||
ws = _build_ws()
|
||||
try:
|
||||
io = await ws.execute(cmd)
|
||||
stdout = await io.stdout_str()
|
||||
return io.exit_code, stdout
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
|
||||
def test_grep_r_src_returns_exit_0():
|
||||
code, out = asyncio.run(_run('grep -rEn "legacyFetch" /src'))
|
||||
assert code == 0
|
||||
assert "legacyFetch" in out
|
||||
|
||||
|
||||
def test_grep_r_root_returns_exit_0_when_match_found():
|
||||
code, out = asyncio.run(_run('grep -rEn "legacyFetch" /'))
|
||||
assert code == 0
|
||||
assert "legacyFetch" in out
|
||||
|
||||
|
||||
def test_grep_r_cwd_returns_exit_0_when_match_found():
|
||||
code, out = asyncio.run(_run('grep -rEn "legacyFetch" .'))
|
||||
assert code == 0
|
||||
assert "legacyFetch" in out
|
||||
|
||||
|
||||
def test_grep_r_root_no_match_returns_exit_1():
|
||||
code, _ = asyncio.run(_run('grep -rEn "doesNotExistAnywhere" /'))
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_grep_r_root_in_if_then():
|
||||
code, out = asyncio.run(
|
||||
_run('if grep -rEn "legacyFetch" /; then echo FOUND; fi'))
|
||||
assert "FOUND" in out
|
||||
|
||||
|
||||
def test_grep_r_root_with_and():
|
||||
_, out = asyncio.run(_run('grep -rEn "legacyFetch" / && echo OK'))
|
||||
assert "OK" in out
|
||||
|
||||
|
||||
def test_grep_r_root_with_or_does_not_run_right_arm():
|
||||
_, out = asyncio.run(
|
||||
_run('grep -rEn "legacyFetch" / || echo SHOULD_NOT_PRINT'))
|
||||
assert "SHOULD_NOT_PRINT" not in out
|
||||
@@ -0,0 +1,92 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _ws():
|
||||
ram = RAMResource()
|
||||
ram._store.files["/hello.txt"] = b"hi\n"
|
||||
return Workspace(resources={"/ram/": (ram, MountMode.EXEC)}, )
|
||||
|
||||
|
||||
def _exec(ws, cmd):
|
||||
return _run(ws.execute(cmd))
|
||||
|
||||
|
||||
def test_help_flag_renders_help_through_executor():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "cat --help")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode() if isinstance(io.stdout, bytes) else _run(
|
||||
_materialize(io.stdout))
|
||||
assert "Usage: cat" in out
|
||||
assert "--help" in out
|
||||
|
||||
|
||||
def test_man_renders_help_for_known_command():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "man cat")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode() if isinstance(io.stdout, bytes) else _run(
|
||||
_materialize(io.stdout))
|
||||
assert "cat" in out
|
||||
|
||||
|
||||
def test_man_no_args_lists_commands_grouped_by_resource():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "man")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode() if isinstance(io.stdout, bytes) else _run(
|
||||
_materialize(io.stdout))
|
||||
assert "RAM" in out
|
||||
assert "- cat" in out
|
||||
assert "- ls" in out
|
||||
assert "# general" in out
|
||||
|
||||
|
||||
def test_man_unknown_command_exits_1():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "man definitely-not-a-real-command")
|
||||
assert io.exit_code == 1
|
||||
err = io.stderr.decode() if isinstance(io.stderr, bytes) else _run(
|
||||
_materialize(io.stderr))
|
||||
assert "no entry for" in err
|
||||
|
||||
|
||||
def test_workspace_file_prompt_mentions_help_and_man():
|
||||
ws = _ws()
|
||||
prompt = ws.file_prompt
|
||||
assert "--help" in prompt
|
||||
assert "man <cmd>" in prompt
|
||||
assert "`man`" in prompt
|
||||
|
||||
|
||||
async def _materialize(source):
|
||||
if source is None:
|
||||
return ""
|
||||
if isinstance(source, bytes):
|
||||
return source.decode()
|
||||
chunks = []
|
||||
async for chunk in source:
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks).decode()
|
||||
@@ -0,0 +1,399 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.base import Accessor, NOOPAccessor
|
||||
from mirage.commands.registry import command
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.io.types import ByteSource, IOResult
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.workspace.snapshot import apply_state_dict, read_tar
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
@command("history", resource="ram", spec=SPECS["history"])
|
||||
async def fake_history(
|
||||
accessor: Accessor = NOOPAccessor(),
|
||||
paths: list[PathSpec] | None = None,
|
||||
*texts: str,
|
||||
**_extra: object,
|
||||
) -> tuple[ByteSource | None, IOResult]:
|
||||
return b"FAKE\n", IOResult()
|
||||
|
||||
|
||||
def _ws() -> Workspace:
|
||||
return Workspace({"/data": (RAMResource(), MountMode.WRITE)})
|
||||
|
||||
|
||||
def _exec(ws: Workspace, cmd: str, **kw) -> IOResult:
|
||||
return asyncio.run(ws.execute(cmd, **kw))
|
||||
|
||||
|
||||
def _stdout(io: IOResult) -> str:
|
||||
if io.stdout is None:
|
||||
return ""
|
||||
if isinstance(io.stdout, memoryview):
|
||||
return bytes(io.stdout).decode()
|
||||
if isinstance(io.stdout, (bytes, bytearray)):
|
||||
return bytes(io.stdout).decode()
|
||||
return ""
|
||||
|
||||
|
||||
def test_history_command_per_session():
|
||||
ws = _ws()
|
||||
ws.create_session("s2")
|
||||
_exec(ws, "ls /data")
|
||||
_exec(ws, "pwd")
|
||||
_exec(ws, "ls /", session_id="s2")
|
||||
mine = _stdout(_exec(ws, "history"))
|
||||
other = _stdout(_exec(ws, "history", session_id="s2"))
|
||||
assert "ls /data" in mine
|
||||
assert "pwd" in mine
|
||||
assert "ls /data" not in other
|
||||
assert "ls /" in other
|
||||
|
||||
|
||||
def test_history_builtin_wins_over_mount_command():
|
||||
res = RAMResource()
|
||||
res.register(fake_history)
|
||||
ws = Workspace({"/data": (res, MountMode.WRITE)})
|
||||
_exec(ws, "ls /data")
|
||||
out = _stdout(_exec(ws, "history", cwd="/data"))
|
||||
assert "FAKE" not in out
|
||||
assert "ls /data" in out
|
||||
|
||||
|
||||
def test_cat_bash_history_all_sessions():
|
||||
ws = _ws()
|
||||
ws.create_session("s2")
|
||||
_exec(ws, "ls /data")
|
||||
_exec(ws, "pwd", session_id="s2")
|
||||
io = _exec(ws, "cat /.bash_history")
|
||||
assert io.exit_code == 0
|
||||
out = _stdout(io)
|
||||
assert "ls /data" in out
|
||||
assert "pwd" in out
|
||||
assert out.count("#") >= 2
|
||||
|
||||
|
||||
def test_grep_and_tail_bash_history():
|
||||
ws = _ws()
|
||||
_exec(ws, "ls /data")
|
||||
_exec(ws, "pwd")
|
||||
grep_io = _exec(ws, "grep pwd /.bash_history")
|
||||
assert grep_io.exit_code == 0
|
||||
grep_out = _stdout(grep_io)
|
||||
assert "pwd" in grep_out
|
||||
assert "ls /data" not in grep_out
|
||||
tail_io = _exec(ws, "tail -n 2 /.bash_history")
|
||||
assert tail_io.exit_code == 0
|
||||
assert "pwd" in _stdout(tail_io)
|
||||
|
||||
|
||||
def test_ls_root_hides_dotfile_ls_a_shows():
|
||||
ws = _ws()
|
||||
plain = _stdout(_exec(ws, "ls /"))
|
||||
dotted = _stdout(_exec(ws, "ls -a /"))
|
||||
assert ".bash_history" not in plain
|
||||
assert ".bash_history" in dotted
|
||||
|
||||
|
||||
def test_history_c_clears_only_my_session_file_unchanged():
|
||||
ws = _ws()
|
||||
ws.create_session("s2")
|
||||
_exec(ws, "ls /data")
|
||||
_exec(ws, "pwd", session_id="s2")
|
||||
clear_io = _exec(ws, "history -c")
|
||||
assert clear_io.exit_code == 0
|
||||
mine = _stdout(_exec(ws, "history"))
|
||||
other = _stdout(_exec(ws, "history", session_id="s2"))
|
||||
file_out = _stdout(_exec(ws, "cat /.bash_history"))
|
||||
assert "ls /data" not in mine
|
||||
assert "pwd" in other
|
||||
assert "ls /data" in file_out
|
||||
|
||||
|
||||
def test_append_to_bash_history_rejected():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "echo hacked >> /.bash_history")
|
||||
assert io.exit_code != 0
|
||||
|
||||
|
||||
def test_sessions_path_no_longer_resolves():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "ls /.sessions")
|
||||
assert io.exit_code != 0
|
||||
|
||||
|
||||
def test_unmount_history_view_rejected():
|
||||
ws = _ws()
|
||||
with pytest.raises(ValueError, match="history view"):
|
||||
asyncio.run(ws.unmount("/.bash_history"))
|
||||
|
||||
|
||||
def test_snapshot_roundtrip_preserves_tombstones(tmp_path):
|
||||
ws = _ws()
|
||||
_exec(ws, "ls /data")
|
||||
_exec(ws, "history -c")
|
||||
_exec(ws, "pwd")
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(ws.snapshot(snap))
|
||||
dst = asyncio.run(Workspace.load(snap))
|
||||
mine = _stdout(_exec(dst, "history"))
|
||||
file_out = _stdout(_exec(dst, "cat /.bash_history"))
|
||||
assert "ls /data" not in mine
|
||||
assert "pwd" in mine
|
||||
assert "ls /data" in file_out
|
||||
|
||||
|
||||
def test_workspace_history_method():
|
||||
ws = _ws()
|
||||
_exec(ws, "ls /data")
|
||||
_exec(ws, "pwd")
|
||||
events = asyncio.run(ws.history())
|
||||
assert [e["command"] for e in events] == ["ls /data", "pwd"]
|
||||
assert all(e["type"] == "command" for e in events)
|
||||
|
||||
|
||||
def test_history_s_appends_without_executing():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
io = _exec(ws, "history -s rm -rf /data")
|
||||
assert io.exit_code == 0
|
||||
out = _stdout(_exec(ws, "history"))
|
||||
assert "rm -rf /data" in out
|
||||
assert _exec(ws, "ls /data/x").exit_code != 0
|
||||
|
||||
|
||||
def test_history_d_deletes_and_renumbers():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
_exec(ws, "echo keep")
|
||||
io = _exec(ws, "history -d 1")
|
||||
assert io.exit_code == 0
|
||||
out = _stdout(_exec(ws, "history"))
|
||||
assert "pwd" not in out.split("history -d 1")[0]
|
||||
assert out.startswith("1 echo keep")
|
||||
|
||||
|
||||
def test_history_d_attached_offset_deletes():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
_exec(ws, "echo keep")
|
||||
io = _exec(ws, "history -d1")
|
||||
assert io.exit_code == 0
|
||||
out = _stdout(_exec(ws, "history"))
|
||||
assert out.startswith("1 echo keep")
|
||||
|
||||
|
||||
def test_history_d_out_of_range():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
io = _exec(ws, "history -d 99")
|
||||
assert io.exit_code == 1
|
||||
assert b"99: history position out of range" in io.stderr
|
||||
|
||||
|
||||
def test_history_d_non_numeric():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "history -d abc")
|
||||
assert io.exit_code == 1
|
||||
assert b"abc: history position out of range" in io.stderr
|
||||
|
||||
|
||||
def test_history_d_trailing_garbage_rejected():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
io = _exec(ws, "history -d 1abc")
|
||||
assert io.exit_code == 1
|
||||
assert b"1abc: history position out of range" in io.stderr
|
||||
|
||||
|
||||
def test_history_count_trailing_garbage_rejected():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
io = _exec(ws, "history 3abc")
|
||||
assert io.exit_code == 1
|
||||
assert b"3abc: numeric argument required" in io.stderr
|
||||
|
||||
|
||||
def test_history_d_negative_offset_deletes_last():
|
||||
ws = _ws()
|
||||
_exec(ws, "echo first")
|
||||
_exec(ws, "echo last")
|
||||
io = _exec(ws, "history -d -1")
|
||||
assert io.exit_code == 0
|
||||
out = _stdout(_exec(ws, "history"))
|
||||
assert "echo first" in out
|
||||
lines = [ln for ln in out.splitlines() if "echo last" in ln]
|
||||
assert lines == []
|
||||
|
||||
|
||||
def test_history_d_requires_argument():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "history -d")
|
||||
assert io.exit_code == 2
|
||||
assert b"-d: option requires an argument" in io.stderr
|
||||
assert b"history: usage:" in io.stderr
|
||||
|
||||
|
||||
def test_history_invalid_option_usage_exit_2():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "history -z")
|
||||
assert io.exit_code == 2
|
||||
assert b"-z: invalid option" in io.stderr
|
||||
assert b"history: usage:" in io.stderr
|
||||
|
||||
|
||||
def test_history_p_prints_without_storing_args():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "history -p hello world")
|
||||
assert io.exit_code == 0
|
||||
assert _stdout(io) == "hello\nworld\n"
|
||||
out = _stdout(_exec(ws, "history"))
|
||||
assert "1 history -p hello world" in out
|
||||
|
||||
|
||||
def test_history_ps_suppresses_print():
|
||||
ws = _ws()
|
||||
io = _exec(ws, "history -ps echo hi")
|
||||
assert io.exit_code == 0
|
||||
assert _stdout(io) == ""
|
||||
out = _stdout(_exec(ws, "history"))
|
||||
assert "echo hi" in out
|
||||
|
||||
|
||||
def test_history_zero_lists_nothing():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
_exec(ws, "echo two")
|
||||
io = _exec(ws, "history 0")
|
||||
assert io.exit_code == 0
|
||||
assert _stdout(io) == ""
|
||||
|
||||
|
||||
def test_history_sync_flags_are_noops():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
for flag in ("-a", "-r", "-w", "-n"):
|
||||
io = _exec(ws, f"history {flag}")
|
||||
assert io.exit_code == 0
|
||||
assert _stdout(io) == ""
|
||||
|
||||
|
||||
def test_history_cluster_cw():
|
||||
ws = _ws()
|
||||
_exec(ws, "pwd")
|
||||
io = _exec(ws, "history -cw")
|
||||
assert io.exit_code == 0
|
||||
out = _stdout(_exec(ws, "history 1"))
|
||||
assert "pwd" not in out
|
||||
|
||||
|
||||
def test_find_view_via_generic():
|
||||
ws = _ws()
|
||||
out = _stdout(_exec(ws, "find /.bash_history"))
|
||||
assert out == "/.bash_history\n"
|
||||
out = _stdout(_exec(ws, "find /.bash_history -name '*.bash*'"))
|
||||
assert out == "/.bash_history\n"
|
||||
out = _stdout(_exec(ws, "find /.bash_history -type d"))
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_substitution_records_only_outer_line():
|
||||
ws = _ws()
|
||||
_exec(ws, "echo hi > /data/f.txt")
|
||||
_exec(ws, "wc -l $(find /data -name '*.txt')")
|
||||
commands = [e["command"] for e in asyncio.run(ws.history())]
|
||||
assert commands == [
|
||||
"echo hi > /data/f.txt",
|
||||
"wc -l $(find /data -name '*.txt')",
|
||||
]
|
||||
|
||||
|
||||
def test_substitution_keeps_inner_ops_in_audit():
|
||||
ws = _ws()
|
||||
_exec(ws, "echo hi > /data/f.txt")
|
||||
_exec(ws, "echo $(cat /data/f.txt)")
|
||||
events = asyncio.run(ws.observer.events())
|
||||
reads = [e for e in events if e["type"] == "op" and e["op"] == "read"]
|
||||
assert any(e["path"] == "/data/f.txt" for e in reads)
|
||||
|
||||
|
||||
def test_outer_ops_after_substitution_not_lost():
|
||||
ws = _ws()
|
||||
_exec(ws, "echo hi > /data/f.txt")
|
||||
_exec(ws, "wc -l $(find /data -name '*.txt')")
|
||||
events = asyncio.run(ws.observer.events())
|
||||
reads = [(e["op"], e["path"]) for e in events if e["type"] == "op"]
|
||||
assert ("read", "/data/f.txt") in reads
|
||||
|
||||
|
||||
def test_eval_xargs_source_record_single_entry():
|
||||
ws = _ws()
|
||||
_exec(ws, "echo hi > /data/f.txt")
|
||||
_exec(ws, "eval cat /data/f.txt")
|
||||
_exec(ws, "echo /data/f.txt | xargs cat")
|
||||
_exec(ws, "echo 'cat /data/f.txt' > /data/s.sh")
|
||||
_exec(ws, "source /data/s.sh")
|
||||
commands = [e["command"] for e in asyncio.run(ws.history())]
|
||||
assert commands == [
|
||||
"echo hi > /data/f.txt",
|
||||
"eval cat /data/f.txt",
|
||||
"echo /data/f.txt | xargs cat",
|
||||
"echo 'cat /data/f.txt' > /data/s.sh",
|
||||
"source /data/s.sh",
|
||||
]
|
||||
|
||||
|
||||
def test_unrecorded_execute_skips_history_keeps_caller_ops():
|
||||
ws = _ws()
|
||||
_exec(ws, "echo hi > /data/f.txt")
|
||||
_exec(ws, "cat /data/f.txt", record=False)
|
||||
commands = [e["command"] for e in asyncio.run(ws.history())]
|
||||
assert commands == ["echo hi > /data/f.txt"]
|
||||
|
||||
|
||||
async def _raise_induced(io):
|
||||
raise RuntimeError("induced")
|
||||
|
||||
|
||||
def test_in_place_restore_rewinds_history(tmp_path):
|
||||
src = _ws()
|
||||
_exec(src, "echo from-snapshot")
|
||||
snap = tmp_path / "s.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
dst = _ws()
|
||||
_exec(dst, "echo pre-restore")
|
||||
asyncio.run(apply_state_dict(dst, read_tar(snap)))
|
||||
cmds = [e["command"] for e in asyncio.run(dst.history())]
|
||||
assert cmds == ["echo from-snapshot"]
|
||||
|
||||
|
||||
def test_failed_line_ops_still_in_audit(monkeypatch):
|
||||
ws = _ws()
|
||||
_exec(ws, "echo hi > /data/f.txt")
|
||||
monkeypatch.setattr(ws, "apply_io", _raise_induced)
|
||||
io = _exec(ws, "cat /data/f.txt")
|
||||
assert io.exit_code == 1
|
||||
events = asyncio.run(ws.observer.events())
|
||||
reads = [e for e in events if e["type"] == "op" and e["op"] == "read"]
|
||||
assert any(e["path"] == "/data/f.txt" for e in reads)
|
||||
last_cmd = [e for e in events if e["type"] == "command"][-1]
|
||||
assert last_cmd["command"] == "cat /data/f.txt"
|
||||
assert last_cmd["exit_code"] == 1
|
||||
@@ -0,0 +1,46 @@
|
||||
# ========= 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 mirage import Workspace
|
||||
from mirage.cache.index import (RAMIndexCacheStore, RedisIndexCacheStore,
|
||||
RedisIndexConfig)
|
||||
from mirage.config import MountBlock, RedisIndexBlock, WorkspaceConfig
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
def test_redis_index_config_default_key_prefix():
|
||||
assert RedisIndexConfig().key_prefix == "mirage:index:"
|
||||
|
||||
|
||||
def test_workspace_index_param_applies_to_mounts():
|
||||
r = RAMResource()
|
||||
Workspace({"/m": r},
|
||||
index=RedisIndexConfig(url="redis://localhost:6379/0"))
|
||||
assert isinstance(r.index, RedisIndexCacheStore)
|
||||
|
||||
|
||||
def test_workspace_default_index_is_ram():
|
||||
r = RAMResource()
|
||||
Workspace({"/m": r})
|
||||
assert isinstance(r.index, RAMIndexCacheStore)
|
||||
|
||||
|
||||
def test_config_index_redis_block_builds_redis_config():
|
||||
cfg = WorkspaceConfig(
|
||||
mounts={"/m": MountBlock(resource="ram")},
|
||||
index=RedisIndexBlock(type="redis"),
|
||||
)
|
||||
kwargs = cfg.to_workspace_kwargs()
|
||||
assert isinstance(kwargs["index"], RedisIndexConfig)
|
||||
assert kwargs["index"].key_prefix == "mirage:index:"
|
||||
@@ -0,0 +1,165 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import boto3
|
||||
from moto import mock_aws
|
||||
|
||||
from mirage.cache.index import LookupStatus
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3.s3 import S3Config, S3Resource
|
||||
from mirage.types import DEFAULT_SESSION_ID, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _stdout(io):
|
||||
if io.stdout is None:
|
||||
return b""
|
||||
if isinstance(io.stdout, bytes):
|
||||
return io.stdout
|
||||
if isinstance(io.stdout, memoryview):
|
||||
return bytes(io.stdout)
|
||||
return b""
|
||||
|
||||
|
||||
# ── resource has index ─────────────────────────
|
||||
|
||||
|
||||
def test_ram_resource_has_index():
|
||||
p = RAMResource()
|
||||
assert p.index is not None
|
||||
assert p.index_ttl == 0
|
||||
|
||||
|
||||
def test_s3_resource_has_index():
|
||||
with mock_aws():
|
||||
boto3.client("s3",
|
||||
region_name="us-east-1").create_bucket(Bucket="test-idx")
|
||||
p = S3Resource(S3Config(bucket="test-idx", region="us-east-1"))
|
||||
assert p.index is not None
|
||||
assert p.index_ttl == 600
|
||||
|
||||
|
||||
# ── RAM index integration ─────────────────────
|
||||
|
||||
|
||||
def _ram_ws():
|
||||
p = RAMResource()
|
||||
p._store.dirs.add("/sub")
|
||||
p._store.files["/sub/a.txt"] = b"aaa\n"
|
||||
p._store.files["/sub/b.txt"] = b"bbb\n"
|
||||
p._store.files["/sub/c.csv"] = b"col\n"
|
||||
ws = Workspace(resources={"/data/": (p, MountMode.WRITE)}, )
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/data"
|
||||
return ws, p
|
||||
|
||||
|
||||
def test_ram_glob_populates_index():
|
||||
"""RAM ttl=0 → index populated but expires immediately."""
|
||||
ws, prov = _ram_ws()
|
||||
io = _run(ws.execute("cat /data/sub/*.txt"))
|
||||
assert io.exit_code == 0
|
||||
# ttl=0: entries were set but expired by the time we check
|
||||
# Index now stores virtual paths (with mount prefix)
|
||||
listing = _run(prov.index.list_dir("/data/sub"))
|
||||
assert listing.status == LookupStatus.EXPIRED
|
||||
|
||||
|
||||
def test_ram_glob_second_call_uses_index():
|
||||
ws, prov = _ram_ws()
|
||||
_run(ws.execute("cat /data/sub/a.txt"))
|
||||
_run(ws.execute("cat /data/sub/a.txt"))
|
||||
|
||||
|
||||
def test_ram_glob_pattern_works():
|
||||
ws, prov = _ram_ws()
|
||||
io = _run(ws.execute("cat /data/sub/*.txt"))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
# ── S3 index integration ──────────────────────
|
||||
|
||||
|
||||
def test_s3_resource_index_ttl():
|
||||
"""S3 resource gets index with TTL=600."""
|
||||
with mock_aws():
|
||||
boto3.client("s3",
|
||||
region_name="us-east-1").create_bucket(Bucket="test-ttl")
|
||||
prov = S3Resource(S3Config(bucket="test-ttl", region="us-east-1"))
|
||||
assert prov.index is not None
|
||||
assert prov.index_ttl == 600
|
||||
|
||||
|
||||
def test_s3_index_can_store_entries():
|
||||
"""S3 index can store and retrieve entries."""
|
||||
from mirage.cache.index import IndexEntry
|
||||
with mock_aws():
|
||||
boto3.client(
|
||||
"s3", region_name="us-east-1").create_bucket(Bucket="test-store")
|
||||
prov = S3Resource(S3Config(bucket="test-store", region="us-east-1"))
|
||||
_run(
|
||||
prov.index.set_dir("/data", [
|
||||
("a.txt", IndexEntry(
|
||||
id="a", name="a.txt", resource_type="file")),
|
||||
("b.txt", IndexEntry(
|
||||
id="b", name="b.txt", resource_type="file")),
|
||||
]))
|
||||
listing = _run(prov.index.list_dir("/data"))
|
||||
assert listing.entries is not None
|
||||
assert len(listing.entries) == 2
|
||||
|
||||
|
||||
def test_s3_index_separate_from_ram():
|
||||
"""S3 and RAM have independent indexes."""
|
||||
ram = RAMResource()
|
||||
with mock_aws():
|
||||
boto3.client("s3",
|
||||
region_name="us-east-1").create_bucket(Bucket="test-sep")
|
||||
s3 = S3Resource(S3Config(bucket="test-sep", region="us-east-1"))
|
||||
assert ram.index is not s3.index
|
||||
|
||||
|
||||
# ── index per resource ─────────────────────────
|
||||
|
||||
|
||||
def test_index_per_resource():
|
||||
p1 = RAMResource()
|
||||
p2 = RAMResource()
|
||||
assert p1.index is not p2.index
|
||||
|
||||
|
||||
# ── TTL behavior ───────────────────────────────
|
||||
|
||||
|
||||
def test_ram_index_ttl_zero():
|
||||
p = RAMResource()
|
||||
assert p.index_ttl == 0
|
||||
|
||||
|
||||
def test_index_expired_refetches():
|
||||
p = RAMResource()
|
||||
p._store.dirs.add("/sub")
|
||||
p._store.files["/sub/a.txt"] = b"aaa\n"
|
||||
ws = Workspace(resources={"/data/": (p, MountMode.WRITE)}, )
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/data"
|
||||
_run(ws.execute("cat /data/sub/*.txt"))
|
||||
listing = _run(p.index.list_dir("/data/sub"))
|
||||
# RAM ttl=0 → expired immediately after set
|
||||
expired = listing.status == LookupStatus.EXPIRED
|
||||
assert expired or listing.entries is not None
|
||||
@@ -0,0 +1,79 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
class _FakeRemote(RAMResource):
|
||||
caches_reads = True
|
||||
index_ttl = 600
|
||||
|
||||
|
||||
def _seed_remote() -> _FakeRemote:
|
||||
remote = _FakeRemote()
|
||||
remote._store.dirs.add("/data")
|
||||
remote._store.files["/data/a.txt"] = b"a"
|
||||
return remote
|
||||
|
||||
|
||||
def test_dispatch_write_invalidates_parent_dir_index():
|
||||
remote = _seed_remote()
|
||||
ws = Workspace({"/r": (remote, MountMode.WRITE)}, mode=MountMode.WRITE)
|
||||
|
||||
async def run():
|
||||
before = await ws.readdir("/r/data")
|
||||
scope = PathSpec(
|
||||
resource_path=mount_key("/r/data/b.txt", "/r"),
|
||||
virtual="/r/data/b.txt",
|
||||
directory="/r/data",
|
||||
resolved=True,
|
||||
)
|
||||
await ws.dispatch("write", scope, data=b"b")
|
||||
after = await ws.readdir("/r/data")
|
||||
return before, after
|
||||
|
||||
before, after = asyncio.run(run())
|
||||
assert "/r/data/a.txt" in before
|
||||
assert "/r/data/b.txt" in after, (
|
||||
"after dispatch write, readdir should reflect b.txt; "
|
||||
f"got {after!r}")
|
||||
|
||||
|
||||
def test_dispatch_unlink_invalidates_parent_dir_index():
|
||||
remote = _seed_remote()
|
||||
remote._store.files["/data/c.txt"] = b"c"
|
||||
ws = Workspace({"/r": (remote, MountMode.WRITE)}, mode=MountMode.WRITE)
|
||||
|
||||
async def run():
|
||||
before = await ws.readdir("/r/data")
|
||||
scope = PathSpec(
|
||||
resource_path=mount_key("/r/data/c.txt", "/r"),
|
||||
virtual="/r/data/c.txt",
|
||||
directory="/r/data",
|
||||
resolved=True,
|
||||
)
|
||||
await ws.dispatch("unlink", scope)
|
||||
after = await ws.readdir("/r/data")
|
||||
return before, after
|
||||
|
||||
before, after = asyncio.run(run())
|
||||
assert "/r/data/c.txt" in before
|
||||
assert "/r/data/c.txt" not in after, (
|
||||
"after dispatch unlink, readdir should drop c.txt; "
|
||||
f"got {after!r}")
|
||||
@@ -0,0 +1,192 @@
|
||||
# ========= 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 asyncio
|
||||
import io
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
from moto.server import ThreadedMotoServer
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3.s3 import S3Config, S3Resource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
CREDS = dict(aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing",
|
||||
region_name="us-east-1")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def s3_endpoint() -> Iterator[str]:
|
||||
server = ThreadedMotoServer(ip_address="127.0.0.1", port=0, verbose=False)
|
||||
server.start()
|
||||
host, port = server.get_host_and_port()
|
||||
yield f"http://{host}:{port}"
|
||||
server.stop()
|
||||
|
||||
|
||||
def _s3_workspace(endpoint: str, bucket: str) -> Workspace:
|
||||
boto3.client("s3", endpoint_url=endpoint,
|
||||
**CREDS).create_bucket(Bucket=bucket)
|
||||
s3 = S3Resource(
|
||||
S3Config(bucket=bucket,
|
||||
region="us-east-1",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id="testing",
|
||||
aws_secret_access_key="testing",
|
||||
path_style=True))
|
||||
return Workspace({"/data": s3}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def _zip_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("inner/z.txt", "zip content\n")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _capture_io(ws: Workspace) -> list:
|
||||
captured: list = []
|
||||
orig = ws._dispatcher.apply_io
|
||||
|
||||
async def recording(result, records=None):
|
||||
captured.append(result)
|
||||
return await orig(result, records=records)
|
||||
|
||||
ws._dispatcher.apply_io = recording
|
||||
return captured
|
||||
|
||||
|
||||
def _assert_single_prefix(captured: list) -> None:
|
||||
for result in captured:
|
||||
keys = (list(result.writes) + list(result.reads) + list(result.cache))
|
||||
for key in keys:
|
||||
if key.startswith("/dev/"):
|
||||
continue
|
||||
assert key.startswith("/data/"), key
|
||||
assert not key.startswith("/data/data/"), key
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd,stdin", [
|
||||
("tee /data/t.txt > /dev/null", b"x\ny\n"),
|
||||
("csplit -f /data/cs_ /data/seed.txt 2", None),
|
||||
("unzip /data/a.zip -d /data/exout", None),
|
||||
("cp /data/seed.txt /data/copy.txt", None),
|
||||
("grep x /data/seed.txt > /data/red.txt", None),
|
||||
("cat /data/seed.txt >> /data/app.txt", None),
|
||||
("cat /data/seed.txt | tee /data/piped.txt > /dev/null", None),
|
||||
("sed s/x/z/ /data/seed.txt > /data/s1.txt && cat /data/s1.txt"
|
||||
" > /data/s2.txt", None),
|
||||
])
|
||||
def test_ram_io_keys_single_prefixed(cmd, stdin):
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
async def run():
|
||||
await ws.execute("tee /data/seed.txt > /dev/null", stdin=b"x\ny\n")
|
||||
await ws.execute("tee /data/a.zip > /dev/null", stdin=_zip_bytes())
|
||||
captured = _capture_io(ws)
|
||||
result = await ws.execute(cmd, stdin=stdin)
|
||||
assert result.exit_code == 0, await result.stderr_str()
|
||||
_assert_single_prefix(captured)
|
||||
await ws.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_ram_stderr_redirect_records_mount_relative_key():
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
async def run():
|
||||
captured = _capture_io(ws)
|
||||
result = await ws.execute("cat /data/missing.txt 2> /data/err.txt")
|
||||
assert result.exit_code != 0
|
||||
_assert_single_prefix(captured)
|
||||
back = await ws.execute("cat /data/err.txt")
|
||||
assert back.exit_code == 0
|
||||
assert "missing.txt" in await back.stdout_str()
|
||||
await ws.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_ram_csplit_writes_parts_inside_mount():
|
||||
ws = Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
async def run():
|
||||
await ws.execute("tee /data/seed.txt > /dev/null", stdin=b"x\ny\n")
|
||||
result = await ws.execute("csplit -f /data/cs_ /data/seed.txt 2")
|
||||
assert result.exit_code == 0, await result.stderr_str()
|
||||
part = await ws.execute("cat /data/cs_00")
|
||||
assert part.exit_code == 0
|
||||
assert await part.stdout_str() == "x\n"
|
||||
await ws.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_s3_io_keys_single_prefixed(s3_endpoint):
|
||||
ws = _s3_workspace(s3_endpoint, "key-prefix-test")
|
||||
|
||||
async def run():
|
||||
captured = _capture_io(ws)
|
||||
await ws.execute("tee /data/t.txt > /dev/null", stdin=b"x\ny\n")
|
||||
for cmd in (
|
||||
"touch /data/new.txt",
|
||||
"mkdir -p /data/newdir",
|
||||
"csplit -f /data/cs_ /data/t.txt 2",
|
||||
):
|
||||
result = await ws.execute(cmd)
|
||||
assert result.exit_code == 0, await result.stderr_str()
|
||||
_assert_single_prefix(captured)
|
||||
await ws.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_s3_redirect_write_invalidates_listed_dir(s3_endpoint):
|
||||
ws = _s3_workspace(s3_endpoint, "key-redirect-test")
|
||||
|
||||
async def run():
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"x\ny\n")
|
||||
await ws.execute("ls -1 /data/")
|
||||
await ws.execute("grep x /data/a.txt > /data/red.txt")
|
||||
await ws.execute("cat /data/a.txt | tee /data/piped.txt > /dev/null")
|
||||
listing = await (await ws.execute("ls -1 /data/")).stdout_str()
|
||||
assert "red.txt" in listing
|
||||
assert "piped.txt" in listing
|
||||
back = await ws.execute("cat /data/red.txt")
|
||||
assert await back.stdout_str() == "x\n"
|
||||
await ws.close()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_s3_touch_invalidates_listed_dir(s3_endpoint):
|
||||
ws = _s3_workspace(s3_endpoint, "key-invalidate-test")
|
||||
|
||||
async def run():
|
||||
await ws.execute("tee /data/a.txt > /dev/null", stdin=b"a\n")
|
||||
await ws.execute("ls -1 /data/")
|
||||
await ws.execute("touch /data/late.txt")
|
||||
result = await ws.execute("rm /data/late.txt")
|
||||
assert result.exit_code == 0, await result.stderr_str()
|
||||
gone = await ws.execute("cat /data/late.txt")
|
||||
assert gone.exit_code != 0
|
||||
await ws.close()
|
||||
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,475 @@
|
||||
# ========= 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. =========
|
||||
"""Mount-root protection tests.
|
||||
|
||||
Covers two related behaviors enforced by the command dispatcher
|
||||
(`mirage/workspace/executor/command.py`):
|
||||
|
||||
1. **Write rule** — destructive/conflicting commands targeting a mount
|
||||
root (`rm /r2`, `mv /r2 /x`, `mkdir /r2`, `touch /r2`, `ln s /r2`)
|
||||
are refused with Unix-style error messages, instead of silently
|
||||
modifying the underlying resource.
|
||||
|
||||
2. **Read fan-out** — traversal commands (`find`, `tree`, `du`,
|
||||
`grep -r`, `ls -R`) on a path at or above mount roots run across
|
||||
each affected mount and concatenate output, so users see
|
||||
contents from every mount instead of only the parent's resource.
|
||||
|
||||
Together these make mount roots behave like first-class directories
|
||||
that the user can navigate but not accidentally destroy.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
# ── fixtures ──────────────────────────────────────
|
||||
|
||||
|
||||
def _ws_two_mounts() -> Workspace:
|
||||
return Workspace({
|
||||
"/r2": (RAMResource(), MountMode.WRITE),
|
||||
"/ram": (RAMResource(), MountMode.WRITE),
|
||||
})
|
||||
|
||||
|
||||
def _ws_nested() -> Workspace:
|
||||
return Workspace({
|
||||
"/data": (RAMResource(), MountMode.WRITE),
|
||||
"/data/inner": (RAMResource(), MountMode.WRITE),
|
||||
})
|
||||
|
||||
|
||||
async def _exec(ws: Workspace, cmd: str):
|
||||
return await ws.execute(cmd)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Write rule
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
# ── rm ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_rm_refuses_mount_root():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "rm /r2")
|
||||
assert r.exit_code == 1
|
||||
assert b"Device or resource busy" in (r.stderr or b"")
|
||||
assert b"/r2" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rmdir_refuses_mount_root():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "rmdir /r2")
|
||||
assert r.exit_code == 1
|
||||
assert b"Device or resource busy" in (r.stderr or b"")
|
||||
assert b"/r2" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rm_rf_refuses_mount_root():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "rm -rf /r2")
|
||||
assert r.exit_code == 1
|
||||
assert b"Device or resource busy" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rm_rf_refuses_mount_root_with_trailing_slash():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "rm -rf /r2/")
|
||||
assert r.exit_code == 1
|
||||
assert b"Device or resource busy" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rm_inside_mount_still_works():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "touch /r2/file")
|
||||
r = await _exec(ws, "rm /r2/file")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rm_rf_inside_mount_still_works():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "mkdir /r2/sub")
|
||||
await _exec(ws, "touch /r2/sub/x")
|
||||
r = await _exec(ws, "rm -rf /r2/sub")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rm_does_not_remove_mount_after_refusal():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "touch /r2/keep")
|
||||
r = await _exec(ws, "rm -rf /r2")
|
||||
assert r.exit_code == 1
|
||||
# Mount is still mounted and contents preserved.
|
||||
ls = await _exec(ws, "ls /r2")
|
||||
assert b"keep" in (ls.stdout or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
# ── mv ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mv_refuses_mount_root_as_source():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "mv /r2 /elsewhere")
|
||||
assert r.exit_code == 1
|
||||
assert b"Device or resource busy" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_mv_into_mount_root_is_allowed():
|
||||
# `mv /src /r2` means "move /src INTO /r2" when /r2 is a directory.
|
||||
# The guard only fires on the SOURCE being a mount root.
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "touch /ram/payload")
|
||||
r = await _exec(ws, "mv /ram/payload /r2/payload")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
# ── mkdir ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_mkdir_refuses_existing_mount_root():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "mkdir /r2")
|
||||
assert r.exit_code == 1
|
||||
assert b"File exists" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_mkdir_dash_p_on_mount_root_is_idempotent():
|
||||
# GNU mkdir -p does not error if the directory already exists.
|
||||
# Mount roots already exist, so -p must succeed silently.
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "mkdir -p /r2")
|
||||
assert r.exit_code == 0
|
||||
assert (r.stderr or b"") == b""
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_mkdir_inside_mount_is_allowed():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "mkdir /r2/newdir")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
# ── touch ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_touch_refuses_mount_root():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "touch /r2")
|
||||
assert r.exit_code == 1
|
||||
assert b"Is a directory" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_touch_inside_mount_is_allowed():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "touch /r2/newfile")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
# ── ln ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_ln_refuses_mount_root_as_link_name():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "touch /ram/source")
|
||||
r = await _exec(ws, "ln /ram/source /r2")
|
||||
assert r.exit_code == 1
|
||||
assert b"File exists" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_ln_s_refuses_mount_root_as_link_name():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "ln -s /ram/source /r2")
|
||||
assert r.exit_code == 1
|
||||
assert b"File exists" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_ln_inside_a_single_mount_is_not_blocked_by_guard():
|
||||
# ln within one mount should not be refused by the mount-root guard.
|
||||
# (Whether the underlying resource supports ln is a separate concern;
|
||||
# the guard's job is only to reject mount-root targets.)
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "touch /r2/source")
|
||||
r = await _exec(ws, "ln -s /r2/source /r2/link")
|
||||
# Either ln succeeds, or the resource doesn't support it. The
|
||||
# guard's "File exists" message must NOT appear because /r2/link
|
||||
# is not a mount root.
|
||||
assert b"File exists" not in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
# ── nested mount as a target ──────────────────────
|
||||
|
||||
|
||||
def test_rm_refuses_nested_mount_root():
|
||||
|
||||
async def go():
|
||||
ws = _ws_nested()
|
||||
r = await _exec(ws, "rm -rf /data/inner")
|
||||
assert r.exit_code == 1
|
||||
assert b"Device or resource busy" in (r.stderr or b"")
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rm_inside_nested_mount_still_works():
|
||||
|
||||
async def go():
|
||||
ws = _ws_nested()
|
||||
await _exec(ws, "touch /data/inner/x")
|
||||
r = await _exec(ws, "rm /data/inner/x")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_rm_inside_outer_mount_still_works():
|
||||
|
||||
async def go():
|
||||
ws = _ws_nested()
|
||||
await _exec(ws, "touch /data/outer-file")
|
||||
r = await _exec(ws, "rm /data/outer-file")
|
||||
assert r.exit_code == 0
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
# Read fan-out
|
||||
# ════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def test_find_root_lists_mounts_at_depth_one():
|
||||
# The original user-reported bug: `find / -maxdepth 1 -mindepth 1
|
||||
# -type d` should list mount prefixes as directories.
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "find / -maxdepth 1 -mindepth 1 -type d")
|
||||
assert r.exit_code == 0
|
||||
out = (r.stdout or b"").decode()
|
||||
assert "/r2" in out
|
||||
assert "/ram" in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_find_root_descends_into_each_mount():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "touch /r2/a")
|
||||
await _exec(ws, "touch /ram/b")
|
||||
r = await _exec(ws, "find /")
|
||||
out = (r.stdout or b"").decode()
|
||||
assert "/r2/a" in out
|
||||
assert "/ram/b" in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_find_inside_one_mount_is_unchanged():
|
||||
# When the path is inside a single mount with no descendants,
|
||||
# fan-out does NOT trigger and the command behaves normally.
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "touch /r2/only-a")
|
||||
r = await _exec(ws, "find /r2")
|
||||
out = (r.stdout or b"").decode()
|
||||
assert "/r2/only-a" in out
|
||||
# Must not contain entries from /ram
|
||||
assert "/ram" not in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_find_with_no_descendants_does_not_fan_out():
|
||||
# Single mount; no descendants under /. Fan-out path must not run.
|
||||
async def go():
|
||||
ws = Workspace({"/r2": (RAMResource(), MountMode.WRITE)})
|
||||
await _exec(ws, "touch /r2/file")
|
||||
r = await _exec(ws, "find /r2")
|
||||
assert r.exit_code == 0
|
||||
out = (r.stdout or b"").decode()
|
||||
assert "/r2/file" in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_find_root_with_nested_mounts():
|
||||
# Both /data and /data/inner are mounts. find / must surface
|
||||
# files from both.
|
||||
async def go():
|
||||
ws = _ws_nested()
|
||||
await _exec(ws, "touch /data/outer-file")
|
||||
await _exec(ws, "touch /data/inner/inner-file")
|
||||
r = await _exec(ws, "find /")
|
||||
out = (r.stdout or b"").decode()
|
||||
assert "/data/outer-file" in out
|
||||
assert "/data/inner/inner-file" in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_find_filters_parent_paths_under_descendant_mount():
|
||||
# If the parent mount has a key whose path overlaps a descendant
|
||||
# mount's prefix, the descendant mount's content is authoritative.
|
||||
# The parent's shadowed content must not duplicate into the
|
||||
# output under the descendant prefix.
|
||||
async def go():
|
||||
ws = _ws_nested()
|
||||
# Put a key in the parent /data resource that lives at the
|
||||
# SAME path as the /data/inner mount root.
|
||||
await _exec(ws, "mkdir /data/inner"
|
||||
) # blocked: /data/inner is a mount root → File exists
|
||||
# Instead, write under the inner mount and verify no parent leak.
|
||||
await _exec(ws, "touch /data/inner/from-inner")
|
||||
r = await _exec(ws, "find /data")
|
||||
out = (r.stdout or b"").decode()
|
||||
# /data/inner/from-inner exists from the inner mount.
|
||||
assert "/data/inner/from-inner" in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_find_maxdepth_skips_too_deep_mount():
|
||||
# /data/inner is at depth 2 from /. With -maxdepth 1, the inner
|
||||
# mount must NOT be included.
|
||||
async def go():
|
||||
ws = _ws_nested()
|
||||
await _exec(ws, "touch /data/inner/x")
|
||||
await _exec(ws, "touch /data/outer-file")
|
||||
r = await _exec(ws, "find / -maxdepth 1")
|
||||
out = (r.stdout or b"").decode()
|
||||
# /data should appear (depth 1)
|
||||
assert "/data" in out
|
||||
# /data/inner/x and /data/outer-file are too deep
|
||||
assert "/data/inner/x" not in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_grep_recursive_root_fans_out():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "sh -c 'echo needle > /r2/a.txt'")
|
||||
await _exec(ws, "sh -c 'echo other > /ram/b.txt'")
|
||||
await _exec(ws, "sh -c 'echo needle > /ram/c.txt'")
|
||||
r = await _exec(ws, "grep -r needle /")
|
||||
out = (r.stdout or b"").decode()
|
||||
assert "/r2/a.txt" in out
|
||||
assert "/ram/c.txt" in out
|
||||
assert "/ram/b.txt" not in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_du_root_fans_out():
|
||||
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
await _exec(ws, "sh -c 'echo content > /r2/file'")
|
||||
await _exec(ws, "sh -c 'echo other > /ram/file'")
|
||||
r = await _exec(ws, "du /")
|
||||
out = (r.stdout or b"").decode()
|
||||
# Each mount contributes some line containing its prefix.
|
||||
assert "/r2" in out
|
||||
assert "/ram" in out
|
||||
|
||||
_run(go())
|
||||
|
||||
|
||||
# ── ls / unchanged ────────────────────────────────
|
||||
|
||||
|
||||
def test_ls_root_still_lists_mounts():
|
||||
# The pre-existing ls injection path must still work — mounts
|
||||
# are visible as folder entries in `ls /`.
|
||||
async def go():
|
||||
ws = _ws_two_mounts()
|
||||
r = await _exec(ws, "ls /")
|
||||
out = (r.stdout or b"").decode()
|
||||
assert "r2" in out
|
||||
assert "ram" in out
|
||||
|
||||
_run(go())
|
||||
@@ -0,0 +1,38 @@
|
||||
# ========= 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 importlib
|
||||
|
||||
import mirage.workspace.types as t
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def test_workspace_has_no_sync_attribute():
|
||||
assert not hasattr(Workspace, "sync"), (
|
||||
"Workspace.sync() was a no-op (DirtyTracker always empty); "
|
||||
"should be removed in Phase 1 cleanup.")
|
||||
|
||||
|
||||
def test_sync_policy_enum_removed():
|
||||
assert not hasattr(t, "SyncPolicy"), "SyncPolicy removed in Phase 1."
|
||||
assert not hasattr(t, "SyncResult"), "SyncResult removed in Phase 1."
|
||||
assert not hasattr(t, "Inode"), "Inode removed in Phase 1."
|
||||
|
||||
|
||||
def test_dirty_tracker_module_removed():
|
||||
try:
|
||||
importlib.import_module("mirage.workspace.tracker")
|
||||
except ModuleNotFoundError:
|
||||
return
|
||||
raise AssertionError("mirage.workspace.tracker should be deleted.")
|
||||
@@ -0,0 +1,362 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _make_ws():
|
||||
ram1 = RAMResource()
|
||||
ram2 = RAMResource()
|
||||
ram1._store.files["/f.txt"] = b"aaa\n"
|
||||
return Workspace(
|
||||
{
|
||||
"/a/": (ram1, MountMode.WRITE),
|
||||
"/b/": (ram2, MountMode.WRITE)
|
||||
}, )
|
||||
|
||||
|
||||
def _make_numbered_ws():
|
||||
ram = RAMResource()
|
||||
ram._store.files["/f.txt"] = b"1\n2\n"
|
||||
ram._store.files["/g.txt"] = b"3\n4\n"
|
||||
ram._store.files["/h.txt"] = b"hello\n"
|
||||
return Workspace({"/a/": (ram, MountMode.WRITE)}, )
|
||||
|
||||
|
||||
def _run(ws, cmd):
|
||||
|
||||
async def _inner():
|
||||
io = await ws.execute(cmd)
|
||||
return await io.stdout_str(), await io.stderr_str(), io.exit_code
|
||||
|
||||
return asyncio.run(_inner())
|
||||
|
||||
|
||||
# ── single-mount: good + missing keeps partial output, GNU-style ──
|
||||
|
||||
|
||||
def test_cat_good_then_missing():
|
||||
out, err, code = _run(_make_ws(), "cat /a/f.txt /a/missing.txt")
|
||||
assert out == "aaa\n"
|
||||
assert err == "cat: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cat_missing_then_good():
|
||||
out, err, code = _run(_make_ws(), "cat /a/missing.txt /a/f.txt")
|
||||
assert out == "aaa\n"
|
||||
assert err == "cat: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cat_all_missing_reports_each():
|
||||
out, err, code = _run(_make_ws(), "cat /a/m1.txt /a/m2.txt")
|
||||
assert out == ""
|
||||
assert err == ("cat: /a/m1.txt: No such file or directory\n"
|
||||
"cat: /a/m2.txt: No such file or directory\n")
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_wc_good_then_missing_keeps_total():
|
||||
out, err, code = _run(_make_ws(), "wc -l /a/f.txt /a/missing.txt")
|
||||
assert out == "1 /a/f.txt\n1 total\n"
|
||||
assert err == "wc: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_wc_all_missing_zero_total():
|
||||
out, err, code = _run(_make_ws(), "wc -l /a/m1.txt /a/m2.txt")
|
||||
assert out == "0 total\n"
|
||||
assert err == ("wc: /a/m1.txt: No such file or directory\n"
|
||||
"wc: /a/m2.txt: No such file or directory\n")
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_head_good_then_missing_keeps_banner():
|
||||
out, err, code = _run(_make_ws(), "head -n 1 /a/f.txt /a/missing.txt")
|
||||
assert out == "==> /a/f.txt <==\naaa\n"
|
||||
assert err == "head: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_head_missing_first_no_leading_blank():
|
||||
out, err, code = _run(_make_ws(), "head -n 1 /a/missing.txt /a/f.txt")
|
||||
assert out == "==> /a/f.txt <==\naaa\n"
|
||||
assert err == "head: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_tail_good_then_missing_keeps_banner():
|
||||
out, err, code = _run(_make_ws(), "tail -n 1 /a/f.txt /a/missing.txt")
|
||||
assert out == "==> /a/f.txt <==\naaa\n"
|
||||
assert err == "tail: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_single_missing_operand_unchanged():
|
||||
out, err, code = _run(_make_ws(), "cat /a/missing.txt")
|
||||
assert out == ""
|
||||
assert err == "cat: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
# ── cross-mount: same bytes as single-mount ──
|
||||
|
||||
|
||||
def test_cross_cat_good_then_missing():
|
||||
out, err, code = _run(_make_ws(), "cat /a/f.txt /b/missing.txt")
|
||||
assert out == "aaa\n"
|
||||
assert err == "cat: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cross_wc_good_then_missing_keeps_total():
|
||||
out, err, code = _run(_make_ws(), "wc -l /a/f.txt /b/missing.txt")
|
||||
assert out == "1 /a/f.txt\n1 total\n"
|
||||
assert err == "wc: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cross_head_good_then_missing_keeps_banner():
|
||||
out, err, code = _run(_make_ws(), "head -n 1 /a/f.txt /b/missing.txt")
|
||||
assert out == "==> /a/f.txt <==\naaa\n"
|
||||
assert err == "head: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cross_tail_good_then_missing_keeps_banner():
|
||||
out, err, code = _run(_make_ws(), "tail -n 1 /a/f.txt /b/missing.txt")
|
||||
assert out == "==> /a/f.txt <==\naaa\n"
|
||||
assert err == "tail: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
# ── every operand is processed, not just the first (paths[0] bugs) ──
|
||||
|
||||
|
||||
def test_cut_processes_all_operands():
|
||||
out, err, code = _run(_make_numbered_ws(), "cut -c1 /a/f.txt /a/g.txt")
|
||||
assert out == "1\n2\n3\n4\n"
|
||||
assert err == ""
|
||||
assert code == 0
|
||||
|
||||
|
||||
def test_tac_reverses_each_operand():
|
||||
out, err, code = _run(_make_numbered_ws(), "tac /a/f.txt /a/g.txt")
|
||||
assert out == "2\n1\n4\n3\n"
|
||||
assert err == ""
|
||||
assert code == 0
|
||||
|
||||
|
||||
def test_nl_numbering_continues_across_operands():
|
||||
out, err, code = _run(_make_numbered_ws(), "nl /a/f.txt /a/g.txt")
|
||||
assert out == " 1\t1\n 2\t2\n 3\t3\n 4\t4\n"
|
||||
assert err == ""
|
||||
assert code == 0
|
||||
|
||||
|
||||
def test_strings_scans_all_operands():
|
||||
ws = _make_numbered_ws()
|
||||
_run(ws, "printf 'worlds\\n' > /a/h2.txt")
|
||||
out, err, code = _run(ws, "strings /a/h.txt /a/h2.txt")
|
||||
assert out == "hello\nworlds\n"
|
||||
assert err == ""
|
||||
assert code == 0
|
||||
|
||||
|
||||
def test_zcat_concatenates_all_operands():
|
||||
ws = _make_numbered_ws()
|
||||
_run(
|
||||
ws, "printf 'z\\n' > /a/z1.txt && printf 'y\\n' > /a/z2.txt"
|
||||
" && gzip /a/z1.txt /a/z2.txt")
|
||||
out, err, code = _run(ws, "zcat /a/z1.txt.gz /a/z2.txt.gz")
|
||||
assert out == "z\ny\n"
|
||||
assert err == ""
|
||||
assert code == 0
|
||||
|
||||
|
||||
# ── the rest of the read family keeps partial output past missing ──
|
||||
|
||||
|
||||
def test_nl_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(), "nl /a/f.txt /a/missing.txt")
|
||||
assert out == " 1\t1\n 2\t2\n"
|
||||
assert err == "nl: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_nl_all_missing_reports_each():
|
||||
out, err, code = _run(_make_numbered_ws(), "nl /a/m1.txt /a/m2.txt")
|
||||
assert out == ""
|
||||
assert err == ("nl: /a/m1.txt: No such file or directory\n"
|
||||
"nl: /a/m2.txt: No such file or directory\n")
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_md5_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(), "md5 /a/f.txt /a/missing.txt")
|
||||
assert out == "6ddb4095eb719e2a9f0a3f95677d24e0 /a/f.txt\n"
|
||||
assert err == "md5: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_sha256sum_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(),
|
||||
"sha256sum /a/f.txt /a/missing.txt")
|
||||
assert out == ("a6e2b7a040683432de03a18fd8a1939a2fdf8258"
|
||||
"5b364bfc874bdd4095c4cae1 /a/f.txt\n")
|
||||
assert err == "sha256sum: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_tac_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(), "tac /a/f.txt /a/missing.txt")
|
||||
assert out == "2\n1\n"
|
||||
assert err == "tac: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_rev_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(), "rev /a/f.txt /a/missing.txt")
|
||||
assert out == "1\n2\n"
|
||||
assert err == "rev: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cut_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(),
|
||||
"cut -c1 /a/f.txt /a/missing.txt")
|
||||
assert out == "1\n2\n"
|
||||
assert err == "cut: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_expand_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(),
|
||||
"expand /a/f.txt /a/missing.txt")
|
||||
assert out == "1\n2\n"
|
||||
assert err == "expand: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_unexpand_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(),
|
||||
"unexpand /a/f.txt /a/missing.txt")
|
||||
assert out == "1\n2\n"
|
||||
assert err == "unexpand: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_fold_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(), "fold /a/f.txt /a/missing.txt")
|
||||
assert out == "1\n2\n"
|
||||
assert err == "fold: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_fmt_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(), "fmt /a/f.txt /a/missing.txt")
|
||||
assert out == "1 2\n"
|
||||
assert err == "fmt: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_strings_good_then_missing():
|
||||
out, err, code = _run(_make_numbered_ws(),
|
||||
"strings /a/h.txt /a/missing.txt")
|
||||
assert out == "hello\n"
|
||||
assert err == "strings: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_zcat_good_then_missing():
|
||||
ws = _make_numbered_ws()
|
||||
_run(ws, "printf 'z\\n' > /a/z1.txt && gzip /a/z1.txt")
|
||||
out, err, code = _run(ws, "zcat /a/z1.txt.gz /a/missing.gz")
|
||||
assert out == "z\n"
|
||||
assert err == "zcat: /a/missing.gz: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_sort_still_aborts_on_missing():
|
||||
# GNU sort needs all input before emitting anything, so no partial
|
||||
# output; the repo reports the operand and exits 1 (GNU exits 2).
|
||||
out, err, code = _run(_make_numbered_ws(), "sort /a/f.txt /a/missing.txt")
|
||||
assert out == ""
|
||||
assert err == "sort: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def _make_cross_numbered_ws():
|
||||
ram1 = RAMResource()
|
||||
ram2 = RAMResource()
|
||||
ram1._store.files["/f.txt"] = b"1\n2\n"
|
||||
return Workspace(
|
||||
{
|
||||
"/a/": (ram1, MountMode.WRITE),
|
||||
"/b/": (ram2, MountMode.WRITE)
|
||||
}, )
|
||||
|
||||
|
||||
def test_cross_nl_reports_own_name():
|
||||
# STREAM commands fetch operand bytes through a native cat sub-run; the
|
||||
# error line must still carry the command's own name, like single-mount.
|
||||
out, err, code = _run(_make_cross_numbered_ws(),
|
||||
"nl /a/f.txt /b/missing.txt")
|
||||
assert out == " 1\t1\n 2\t2\n"
|
||||
assert err == "nl: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cross_md5_good_then_missing():
|
||||
out, err, code = _run(_make_cross_numbered_ws(),
|
||||
"md5 /a/f.txt /b/missing.txt")
|
||||
assert out == "6ddb4095eb719e2a9f0a3f95677d24e0 /a/f.txt\n"
|
||||
assert err == "md5: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_stat_good_then_missing_keeps_row():
|
||||
out, err, code = _run(_make_ws(), "stat /a/f.txt /a/missing.txt")
|
||||
assert "name=f.txt" in out
|
||||
assert err == "stat: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_sed_good_then_missing_keeps_output():
|
||||
out, err, code = _run(_make_numbered_ws(),
|
||||
"sed s/1/X/ /a/f.txt /a/missing.txt")
|
||||
assert out == "X\n2\n"
|
||||
assert err == "sed: /a/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cross_sed_good_then_missing_keeps_output():
|
||||
out, err, code = _run(_make_cross_numbered_ws(),
|
||||
"sed s/1/X/ /a/f.txt /b/missing.txt")
|
||||
assert out == "X\n2\n"
|
||||
assert err == "sed: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_cross_sort_aborts_like_single_mount():
|
||||
out, err, code = _run(_make_cross_numbered_ws(),
|
||||
"sort /a/f.txt /b/missing.txt")
|
||||
assert out == ""
|
||||
assert err == "sort: /b/missing.txt: No such file or directory\n"
|
||||
assert code == 1
|
||||
@@ -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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.workspace.workspace import DEFAULT_SESSION_ID
|
||||
|
||||
|
||||
async def _slow_stdin():
|
||||
await asyncio.sleep(5)
|
||||
yield b"late\n"
|
||||
|
||||
|
||||
async def _quick_stdin():
|
||||
yield b"hi\n"
|
||||
|
||||
|
||||
async def _probed_stdin(flag):
|
||||
try:
|
||||
await asyncio.sleep(5)
|
||||
yield b"late\n"
|
||||
finally:
|
||||
flag["closed"] = True
|
||||
|
||||
|
||||
async def _multiline_stdin():
|
||||
for i in range(1000):
|
||||
yield f"line{i}\n".encode()
|
||||
|
||||
|
||||
def _ws():
|
||||
return Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_budget_bounds_slow_final_stage():
|
||||
ws = _ws()
|
||||
ws._session_mgr.get(DEFAULT_SESSION_ID).pipeline_timeout_seconds = 0.1
|
||||
r = await ws.execute("cat | cat", stdin=_slow_stdin())
|
||||
assert r.exit_code == 124
|
||||
assert "pipeline: timed out after 0.1s" in (await r.stderr_str())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_without_budget_is_unbounded():
|
||||
ws = _ws()
|
||||
r = await ws.execute("cat | cat", stdin=_quick_stdin())
|
||||
assert r.exit_code == 0
|
||||
assert (await r.stdout_str()) == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_budget_tears_down_upstream_producer():
|
||||
ws = _ws()
|
||||
ws._session_mgr.get(DEFAULT_SESSION_ID).pipeline_timeout_seconds = 0.1
|
||||
flag = {"closed": False}
|
||||
r = await ws.execute("cat | cat", stdin=_probed_stdin(flag))
|
||||
assert r.exit_code == 124
|
||||
assert flag["closed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_early_finish_reports_downstream_code_not_sigpipe():
|
||||
ws = _ws()
|
||||
session = ws._session_mgr.get(DEFAULT_SESSION_ID)
|
||||
session.shell_options["pipefail"] = True
|
||||
r = await ws.execute("cat | head -n1", stdin=_multiline_stdin())
|
||||
assert r.exit_code == 0
|
||||
assert (await r.stdout_str()) == "line0\n"
|
||||
@@ -0,0 +1,308 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import DEFAULT_SESSION_ID, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _stdout(io):
|
||||
if io.stdout is None:
|
||||
return b""
|
||||
if isinstance(io.stdout, bytes):
|
||||
return io.stdout
|
||||
if isinstance(io.stdout, memoryview):
|
||||
return bytes(io.stdout)
|
||||
return b""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# RAM resource integration
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
|
||||
def _ram_ws():
|
||||
p = RAMResource()
|
||||
p._store.files["/hello.txt"] = b"hello world\n"
|
||||
p._store.files["/data.csv"] = b"name,age\nalice,30\nbob,25\n"
|
||||
p._store.dirs.add("/sub")
|
||||
p._store.files["/sub/nested.txt"] = b"nested content\n"
|
||||
ws = Workspace(resources={"/ram/": (p, MountMode.WRITE)}, )
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/ram"
|
||||
return ws
|
||||
|
||||
|
||||
def test_ram_cat():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("cat /ram/hello.txt"))
|
||||
assert io.exit_code == 0
|
||||
assert b"hello world" in _stdout(io)
|
||||
|
||||
|
||||
def test_ram_grep():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("grep alice /ram/data.csv"))
|
||||
assert io.exit_code == 0
|
||||
assert b"alice" in _stdout(io)
|
||||
|
||||
|
||||
def test_ram_pipeline():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("cat /ram/data.csv | grep alice | wc -l"))
|
||||
assert io.exit_code == 0
|
||||
assert b"1" in _stdout(io)
|
||||
|
||||
|
||||
def test_ram_redirect_write():
|
||||
ws = _ram_ws()
|
||||
_run(ws.execute("echo test > /ram/out.txt"))
|
||||
io = _run(ws.execute("cat /ram/out.txt"))
|
||||
assert b"test" in _stdout(io)
|
||||
|
||||
|
||||
def test_ram_ls():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("ls /ram/"))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_ram_head():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("head -n 1 /ram/data.csv"))
|
||||
assert io.exit_code == 0
|
||||
assert b"name" in _stdout(io)
|
||||
|
||||
|
||||
def test_ram_awk():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("awk -F, '{print $1}' /ram/data.csv"))
|
||||
assert io.exit_code == 0
|
||||
assert b"alice" in _stdout(io)
|
||||
|
||||
|
||||
def test_ram_sed():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("sed 's/alice/ALICE/' /ram/data.csv"))
|
||||
assert b"ALICE" in _stdout(io)
|
||||
|
||||
|
||||
def test_ram_sort():
|
||||
ws = _ram_ws()
|
||||
io = _run(ws.execute("cat /ram/data.csv | sort"))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Disk resource integration
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disk_ws(tmp_path):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "hello.txt").write_bytes(b"hello from disk\n")
|
||||
(data_dir / "nums.txt").write_bytes(b"3\n1\n2\n")
|
||||
(data_dir / "report.csv").write_bytes(b"name,age\nalice,30\nbob,25\n")
|
||||
sub = data_dir / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "nested.txt").write_bytes(b"nested\n")
|
||||
|
||||
p = DiskResource(root=str(data_dir))
|
||||
ws = Workspace(resources={"/disk/": (p, MountMode.WRITE)}, )
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/disk"
|
||||
return ws
|
||||
|
||||
|
||||
def test_disk_cat(disk_ws):
|
||||
io = _run(disk_ws.execute("cat /disk/hello.txt"))
|
||||
assert io.exit_code == 0
|
||||
assert b"hello from disk" in _stdout(io)
|
||||
|
||||
|
||||
def test_disk_grep(disk_ws):
|
||||
io = _run(disk_ws.execute("grep alice /disk/report.csv"))
|
||||
assert io.exit_code == 0
|
||||
assert b"alice" in _stdout(io)
|
||||
|
||||
|
||||
def test_disk_pipeline(disk_ws):
|
||||
io = _run(disk_ws.execute("cat /disk/report.csv | grep alice | wc -l"))
|
||||
assert io.exit_code == 0
|
||||
assert b"1" in _stdout(io)
|
||||
|
||||
|
||||
def test_disk_ls(disk_ws):
|
||||
io = _run(disk_ws.execute("ls /disk/"))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_disk_head(disk_ws):
|
||||
io = _run(disk_ws.execute("head -n 1 /disk/report.csv"))
|
||||
assert io.exit_code == 0
|
||||
assert b"name" in _stdout(io)
|
||||
|
||||
|
||||
def test_disk_sort(disk_ws):
|
||||
io = _run(disk_ws.execute("sort -n /disk/nums.txt"))
|
||||
assert io.exit_code == 0
|
||||
lines = _stdout(io).decode().strip().split("\n")
|
||||
assert lines == ["1", "2", "3"]
|
||||
|
||||
|
||||
def test_disk_redirect_write(disk_ws):
|
||||
_run(disk_ws.execute("echo written > /disk/out.txt"))
|
||||
io = _run(disk_ws.execute("cat /disk/out.txt"))
|
||||
assert b"written" in _stdout(io)
|
||||
|
||||
|
||||
def test_disk_nested_cat(disk_ws):
|
||||
io = _run(disk_ws.execute("cat /disk/sub/nested.txt"))
|
||||
assert io.exit_code == 0
|
||||
assert b"nested" in _stdout(io)
|
||||
|
||||
|
||||
def test_disk_awk(disk_ws):
|
||||
io = _run(disk_ws.execute("awk -F, '{print $1}' /disk/report.csv"))
|
||||
assert io.exit_code == 0
|
||||
assert b"alice" in _stdout(io)
|
||||
|
||||
|
||||
def test_disk_sed(disk_ws):
|
||||
io = _run(disk_ws.execute("sed 's/bob/BOB/' /disk/report.csv"))
|
||||
assert b"BOB" in _stdout(io)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════
|
||||
# Cross-resource: RAM + Disk
|
||||
# ═══════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def multi_ws(tmp_path):
|
||||
data_dir = tmp_path / "diskdata"
|
||||
data_dir.mkdir()
|
||||
(data_dir / "disk_file.txt").write_bytes(b"from disk\n")
|
||||
|
||||
ram = RAMResource()
|
||||
ram._store.files["/ram_file.txt"] = b"from ram\n"
|
||||
|
||||
disk = DiskResource(root=str(data_dir))
|
||||
|
||||
ws = Workspace(resources={
|
||||
"/ram/": (ram, MountMode.WRITE),
|
||||
"/disk/": (disk, MountMode.WRITE),
|
||||
}, )
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/ram"
|
||||
return ws
|
||||
|
||||
|
||||
def test_cross_cat_ram(multi_ws):
|
||||
io = _run(multi_ws.execute("cat /ram/ram_file.txt"))
|
||||
assert b"from ram" in _stdout(io)
|
||||
|
||||
|
||||
def test_cross_cat_disk(multi_ws):
|
||||
io = _run(multi_ws.execute("cat /disk/disk_file.txt"))
|
||||
assert b"from disk" in _stdout(io)
|
||||
|
||||
|
||||
def test_cross_pipeline(multi_ws):
|
||||
"""Read from RAM, pipe through commands, write to Disk."""
|
||||
_run(
|
||||
multi_ws.execute(
|
||||
"cat /ram/ram_file.txt | tr 'a-z' 'A-Z' > /disk/upper.txt"))
|
||||
io = _run(multi_ws.execute("cat /disk/upper.txt"))
|
||||
assert b"FROM RAM" in _stdout(io)
|
||||
|
||||
|
||||
def test_cross_for_loop(multi_ws):
|
||||
"""for loop across resources."""
|
||||
_run(
|
||||
multi_ws.execute("for f in /ram/ram_file.txt /disk/disk_file.txt; do "
|
||||
"cat $f; done"))
|
||||
|
||||
|
||||
def test_cross_redirect(multi_ws):
|
||||
"""Read RAM, write to Disk."""
|
||||
_run(multi_ws.execute("echo hello > /disk/from_ram.txt"))
|
||||
io = _run(multi_ws.execute("cat /disk/from_ram.txt"))
|
||||
assert b"hello" in _stdout(io)
|
||||
|
||||
|
||||
def test_cross_cp_ram_to_disk(multi_ws):
|
||||
"""cp /ram/file /disk/file → cross-mount copy."""
|
||||
io = _run(multi_ws.execute("cp /ram/ram_file.txt /disk/copied.txt"))
|
||||
assert io.exit_code == 0
|
||||
io = _run(multi_ws.execute("cat /disk/copied.txt"))
|
||||
assert b"from ram" in _stdout(io)
|
||||
|
||||
|
||||
def test_cross_cp_disk_to_ram(multi_ws):
|
||||
"""cp /disk/file /ram/file → cross-mount copy."""
|
||||
io = _run(multi_ws.execute("cp /disk/disk_file.txt /ram/copied.txt"))
|
||||
assert io.exit_code == 0
|
||||
io = _run(multi_ws.execute("cat /ram/copied.txt"))
|
||||
assert b"from disk" in _stdout(io)
|
||||
|
||||
|
||||
def test_cross_mv_ram_to_disk(multi_ws):
|
||||
"""mv /ram/file /disk/file → cross-mount move."""
|
||||
_run(multi_ws.execute("echo moveme > /ram/move_src.txt"))
|
||||
io = _run(multi_ws.execute("mv /ram/move_src.txt /disk/move_dst.txt"))
|
||||
assert io.exit_code == 0
|
||||
io = _run(multi_ws.execute("cat /disk/move_dst.txt"))
|
||||
assert b"moveme" in _stdout(io)
|
||||
io = _run(multi_ws.execute("cat /ram/move_src.txt"))
|
||||
assert io.exit_code == 1
|
||||
|
||||
|
||||
def test_cross_diff_same(multi_ws):
|
||||
"""diff across mounts — identical files."""
|
||||
_run(multi_ws.execute("echo same > /ram/a.txt"))
|
||||
_run(multi_ws.execute("echo same > /disk/a.txt"))
|
||||
io = _run(multi_ws.execute("diff /ram/a.txt /disk/a.txt"))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_cross_diff_different(multi_ws):
|
||||
"""diff across mounts — different files."""
|
||||
io = _run(multi_ws.execute("diff /ram/ram_file.txt /disk/disk_file.txt"))
|
||||
assert io.exit_code == 1
|
||||
out = _stdout(io)
|
||||
assert b"from ram" in out or b"---" in out
|
||||
|
||||
|
||||
def test_cross_cmp_same(multi_ws):
|
||||
"""cmp across mounts — identical files."""
|
||||
_run(multi_ws.execute("echo identical > /ram/c.txt"))
|
||||
_run(multi_ws.execute("echo identical > /disk/c.txt"))
|
||||
io = _run(multi_ws.execute("cmp /ram/c.txt /disk/c.txt"))
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
def test_cross_cmp_different(multi_ws):
|
||||
"""cmp across mounts — different files."""
|
||||
io = _run(multi_ws.execute("cmp /ram/ram_file.txt /disk/disk_file.txt"))
|
||||
assert io.exit_code == 1
|
||||
assert b"differ" in _stdout(io)
|
||||
@@ -0,0 +1,178 @@
|
||||
# ========= 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 import MountMode, Workspace
|
||||
from mirage.commands.config import command
|
||||
from mirage.commands.spec import SPECS
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.ops.registry import op
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
return Workspace({"/data": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws_two_mounts():
|
||||
return Workspace({
|
||||
"/a": RAMResource(),
|
||||
"/b": RAMResource(),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def test_ws_mounts_returns_all(ws):
|
||||
mounts = ws.mounts()
|
||||
prefixes = [m.prefix for m in mounts]
|
||||
assert "/data/" in prefixes
|
||||
|
||||
|
||||
def test_ws_mount_by_prefix(ws):
|
||||
m = ws.mount("/data/")
|
||||
assert m.prefix == "/data/"
|
||||
|
||||
|
||||
def test_commands_introspection(ws):
|
||||
m = ws.mount("/data/")
|
||||
cmds = m.commands()
|
||||
assert isinstance(cmds, dict)
|
||||
assert "cat" in cmds
|
||||
assert None in cmds["cat"]
|
||||
|
||||
|
||||
def test_commands_has_filetype_variants(ws):
|
||||
m = ws.mount("/data/")
|
||||
cmds = m.commands()
|
||||
assert len(cmds.get("cat", [])) > 1
|
||||
|
||||
|
||||
def test_registered_ops_introspection(ws):
|
||||
m = ws.mount("/data/")
|
||||
ops = m.registered_ops()
|
||||
assert isinstance(ops, dict)
|
||||
assert "read" in ops
|
||||
assert "stat" in ops
|
||||
|
||||
|
||||
def test_register_fns_adds_command(ws):
|
||||
|
||||
@command("test_custom", resource="ram", spec=SPECS["cat"])
|
||||
async def custom(accessor, paths, *texts, **kw):
|
||||
return b"custom", IOResult()
|
||||
|
||||
m = ws.mount("/data/")
|
||||
assert "test_custom" not in m.commands()
|
||||
m.register_fns([custom])
|
||||
assert "test_custom" in m.commands()
|
||||
|
||||
|
||||
def test_register_fns_adds_op(ws):
|
||||
|
||||
@op("test_custom_op", resource="ram")
|
||||
async def custom_op(accessor, scope, **kwargs):
|
||||
return b"hello"
|
||||
|
||||
m = ws.mount("/data/")
|
||||
assert "test_custom_op" not in m.registered_ops()
|
||||
m.register_fns([custom_op])
|
||||
assert "test_custom_op" in m.registered_ops()
|
||||
|
||||
|
||||
def test_unregister_removes_command(ws):
|
||||
m = ws.mount("/data/")
|
||||
assert "rm" in m.commands()
|
||||
m.unregister(["rm"])
|
||||
assert "rm" not in m.commands()
|
||||
|
||||
|
||||
def test_unregister_removes_all_filetypes(ws):
|
||||
m = ws.mount("/data/")
|
||||
cmds = m.commands()
|
||||
assert len(cmds.get("cat", [])) > 1
|
||||
m.unregister(["cat"])
|
||||
assert "cat" not in m.commands()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unregister_then_register_works(ws):
|
||||
m = ws.mount("/data/")
|
||||
m.unregister(["cat"])
|
||||
assert "cat" not in m.commands()
|
||||
|
||||
@command("cat", resource="ram", spec=SPECS["cat"])
|
||||
async def custom_cat(accessor, paths, *texts, **kw):
|
||||
return b"custom cat output", IOResult()
|
||||
|
||||
m.register_fns([custom_cat])
|
||||
assert "cat" in m.commands()
|
||||
await ws.execute('echo hello | tee /data/hello.txt')
|
||||
result = await ws.execute("cat /data/hello.txt")
|
||||
assert result.exit_code == 0
|
||||
assert b"custom cat output" in result.stdout
|
||||
|
||||
|
||||
def test_register_isolated_per_mount(ws_two_mounts):
|
||||
ma = ws_two_mounts.mount("/a/")
|
||||
mb = ws_two_mounts.mount("/b/")
|
||||
ma.unregister(["rm"])
|
||||
assert "rm" not in ma.commands()
|
||||
assert "rm" in mb.commands()
|
||||
|
||||
|
||||
def test_register_fns_isolated_per_mount(ws_two_mounts):
|
||||
|
||||
@command("only_on_a", resource="ram", spec=SPECS["cat"])
|
||||
async def only_a(accessor, paths, *texts, **kw):
|
||||
return b"a", IOResult()
|
||||
|
||||
ws_two_mounts.mount("/a/").register_fns([only_a])
|
||||
assert "only_on_a" in ws_two_mounts.mount("/a/").commands()
|
||||
assert "only_on_a" not in ws_two_mounts.mount("/b/").commands()
|
||||
|
||||
|
||||
def test_register_fns_wrong_resource_raises(ws):
|
||||
|
||||
@command("s3_only", resource="s3", spec=SPECS["cat"])
|
||||
async def s3_cmd(accessor, paths, *texts, **kw):
|
||||
return b"s3", IOResult()
|
||||
|
||||
m = ws.mount("/data/")
|
||||
with pytest.raises(ValueError, match=r"'s3'"):
|
||||
m.register_fns([s3_cmd])
|
||||
|
||||
|
||||
def test_register_fns_wrong_resource_op_raises(ws):
|
||||
|
||||
@op("s3_read", resource="s3")
|
||||
async def s3_op(accessor, scope, **kwargs):
|
||||
return b"s3"
|
||||
|
||||
m = ws.mount("/data/")
|
||||
with pytest.raises(ValueError, match=r"'s3'"):
|
||||
m.register_fns([s3_op])
|
||||
|
||||
|
||||
def test_register_fns_multi_resource_filters_to_matching(ws):
|
||||
|
||||
@command("multi", resource=["ram", "s3"], spec=SPECS["cat"])
|
||||
async def multi(accessor, paths, *texts, **kw):
|
||||
return b"multi", IOResult()
|
||||
|
||||
m = ws.mount("/data/")
|
||||
m.register_fns([multi])
|
||||
assert "multi" in m.commands()
|
||||
@@ -0,0 +1,106 @@
|
||||
# ========= 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 asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace, WorkspaceRunner
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
def _make_ws() -> Workspace:
|
||||
return Workspace(
|
||||
{"/": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_executes_on_its_own_loop():
|
||||
ws = _make_ws()
|
||||
runner = WorkspaceRunner(ws)
|
||||
try:
|
||||
outer = asyncio.get_running_loop()
|
||||
assert runner.loop is not outer
|
||||
result = await runner.call(runner.ws.execute("echo hello"))
|
||||
assert result.exit_code == 0
|
||||
assert (result.stdout or b"").startswith(b"hello")
|
||||
finally:
|
||||
await runner.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_call_does_not_block_caller_loop():
|
||||
ws = _make_ws()
|
||||
runner = WorkspaceRunner(ws)
|
||||
try:
|
||||
slow = asyncio.create_task(runner.call(runner.ws.execute("sleep 0.5")))
|
||||
ticks = 0
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.05)
|
||||
ticks += 1
|
||||
if slow.done():
|
||||
break
|
||||
assert ticks >= 5
|
||||
await slow
|
||||
finally:
|
||||
await runner.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_runners_are_isolated():
|
||||
ws_a = _make_ws()
|
||||
ws_b = _make_ws()
|
||||
runner_a = WorkspaceRunner(ws_a)
|
||||
runner_b = WorkspaceRunner(ws_b)
|
||||
try:
|
||||
slow = asyncio.create_task(
|
||||
runner_a.call(runner_a.ws.execute("sleep 1.0")))
|
||||
await asyncio.sleep(0.05)
|
||||
start = time.monotonic()
|
||||
fast_result = await runner_b.call(runner_b.ws.execute("echo quick"))
|
||||
elapsed = time.monotonic() - start
|
||||
assert fast_result.exit_code == 0
|
||||
assert elapsed < 0.5, (
|
||||
f"workspace B's quick command took {elapsed:.2f}s "
|
||||
"while workspace A was sleeping; isolation violated")
|
||||
await slow
|
||||
finally:
|
||||
await runner_a.stop()
|
||||
await runner_b.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_is_idempotent():
|
||||
ws = _make_ws()
|
||||
runner = WorkspaceRunner(ws)
|
||||
await runner.stop()
|
||||
await runner.stop()
|
||||
assert not runner._thread.is_alive()
|
||||
|
||||
|
||||
def test_call_sync_runs_on_workspace_loop():
|
||||
ws = _make_ws()
|
||||
runner = WorkspaceRunner(ws)
|
||||
try:
|
||||
result = runner.call_sync(runner.ws.execute("echo sync"), timeout=5.0)
|
||||
assert result.exit_code == 0
|
||||
assert (result.stdout or b"").startswith(b"sync")
|
||||
finally:
|
||||
runner.call_sync(runner.ws.close(), timeout=5.0)
|
||||
runner.loop.call_soon_threadsafe(runner.loop.stop)
|
||||
runner._thread.join(timeout=2.0)
|
||||
runner.loop.close()
|
||||
@@ -0,0 +1,256 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.session import reset_current_session, set_current_session
|
||||
|
||||
|
||||
def _seed(name: str, body: bytes) -> RAMResource:
|
||||
r = RAMResource()
|
||||
r._store.files[f"/{name}"] = body
|
||||
return r
|
||||
|
||||
|
||||
def test_session_outside_allowlist_is_denied():
|
||||
a = _seed("x.txt", b"public")
|
||||
b = _seed("secret.txt", b"SECRET")
|
||||
ws = Workspace({"/a": a, "/b": b})
|
||||
ws.create_session("agent", allowed_mounts=frozenset({"/a"}))
|
||||
|
||||
async def run():
|
||||
ok = await ws.execute("cat /a/x.txt", session_id="agent")
|
||||
denied = await ws.execute("cat /b/secret.txt", session_id="agent")
|
||||
return ok, denied
|
||||
|
||||
ok, denied = asyncio.run(run())
|
||||
assert ok.exit_code == 0
|
||||
assert b"public" in (ok.stdout or b"")
|
||||
assert denied.exit_code != 0
|
||||
stderr = denied.stderr or b""
|
||||
assert b"not allowed" in stderr
|
||||
assert b"/b" in stderr
|
||||
|
||||
|
||||
def test_default_session_unrestricted():
|
||||
a = _seed("x.txt", b"hi")
|
||||
ws = Workspace({"/a": a})
|
||||
|
||||
async def run():
|
||||
return await ws.execute("cat /a/x.txt")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code == 0
|
||||
assert b"hi" in (io.stdout or b"")
|
||||
|
||||
|
||||
def test_allowed_session_can_write_to_its_mount():
|
||||
a = _seed("x.txt", b"hi")
|
||||
ws = Workspace({"/a": (a, MountMode.WRITE)}, mode=MountMode.WRITE)
|
||||
ws.create_session("agent", allowed_mounts=frozenset({"/a"}))
|
||||
|
||||
async def run():
|
||||
return await ws.execute("echo new > /a/y.txt", session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code == 0, f"unexpected denial: {io}"
|
||||
assert a._store.files.get("/y.txt") == b"new\n"
|
||||
|
||||
|
||||
def test_history_view_always_allowed():
|
||||
a = _seed("x.txt", b"hi")
|
||||
ws = Workspace({"/a": a})
|
||||
ws.create_session("agent", allowed_mounts=frozenset({"/a"}))
|
||||
|
||||
async def run():
|
||||
await ws.execute("ls /a", session_id="agent")
|
||||
return await ws.execute("history", session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code == 0, (
|
||||
f"history view should always be reachable, got {io}")
|
||||
|
||||
|
||||
def test_ops_blocks_programmatic_read_outside_allowlist():
|
||||
a = _seed("x.txt", b"public")
|
||||
b = _seed("secret.txt", b"SECRET")
|
||||
ws = Workspace({"/a": a, "/b": b})
|
||||
sess = ws.create_session("agent", allowed_mounts=frozenset({"/a"}))
|
||||
|
||||
async def run():
|
||||
token = set_current_session(sess)
|
||||
try:
|
||||
assert await ws.ops.read("/a/x.txt") == b"public"
|
||||
with pytest.raises(PermissionError, match="not allowed"):
|
||||
await ws.ops.read("/b/secret.txt")
|
||||
finally:
|
||||
reset_current_session(token)
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def _two_mounts_with_secret() -> Workspace:
|
||||
a = _seed("x.txt", b"public-A\n")
|
||||
a._store.files["/y.txt"] = b"public-B\n"
|
||||
b = _seed("secret.txt", b"SECRET-FROM-B\n")
|
||||
ws = Workspace({
|
||||
"/a": (a, MountMode.WRITE),
|
||||
"/b": (b, MountMode.WRITE)
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
ws.create_session("agent", allowed_mounts=frozenset({"/a"}))
|
||||
return ws
|
||||
|
||||
|
||||
def test_pipe_across_mounts_blocks_forbidden_read():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("cat /b/secret.txt | wc -l",
|
||||
session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
# Bash convention: a downstream success masks an upstream failure
|
||||
# (no `pipefail`). Security guarantee: no leak + audit on stderr.
|
||||
assert b"SECRET" not in (io.stdout or b""), (
|
||||
f"forbidden read must not reach the pipe, got stdout={io.stdout!r}")
|
||||
assert b"not allowed" in (io.stderr or b"")
|
||||
assert b"/b" in (io.stderr or b"")
|
||||
|
||||
|
||||
def test_pipe_within_allowed_mount_succeeds():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("cat /a/x.txt | wc -c", session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code == 0, f"in-allowlist pipe must succeed, got {io}"
|
||||
|
||||
|
||||
def test_command_substitution_into_forbidden_mount_is_denied():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("echo $(cat /b/secret.txt)",
|
||||
session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code != 0 or b"SECRET" not in (io.stdout or b""), (
|
||||
f"command substitution must not leak forbidden read, got {io}")
|
||||
|
||||
|
||||
def test_subshell_inherits_session_capability():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("(cat /b/secret.txt)", session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code != 0
|
||||
assert b"not allowed" in (io.stderr or b"")
|
||||
|
||||
|
||||
def test_and_chain_short_circuits_on_denial():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("cat /b/secret.txt && cat /a/x.txt",
|
||||
session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code != 0
|
||||
assert b"public-A" not in (io.stdout or b""), (
|
||||
"denied left side should short-circuit the && chain")
|
||||
|
||||
|
||||
def test_or_chain_falls_through_to_allowed():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("cat /b/secret.txt || cat /a/x.txt",
|
||||
session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert b"public-A" in (io.stdout or b""), (
|
||||
f"|| should fall through to the allowed branch, got {io}")
|
||||
|
||||
|
||||
def test_redirect_to_forbidden_mount_is_denied():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("echo leaked > /b/leaked.txt",
|
||||
session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code != 0
|
||||
assert b"not allowed" in (io.stderr or b"")
|
||||
|
||||
|
||||
def test_cross_mount_copy_into_forbidden_mount_is_denied():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
return await ws.execute("cp /a/x.txt /b/leaked.txt",
|
||||
session_id="agent")
|
||||
|
||||
io = asyncio.run(run())
|
||||
assert io.exit_code != 0
|
||||
assert b"not allowed" in (io.stderr or b"")
|
||||
|
||||
|
||||
def test_concurrent_sessions_isolated():
|
||||
a = _seed("x.txt", b"A-only\n")
|
||||
b = _seed("y.txt", b"B-only\n")
|
||||
ws = Workspace({"/a": a, "/b": b})
|
||||
ws.create_session("agent_a", allowed_mounts=frozenset({"/a"}))
|
||||
ws.create_session("agent_b", allowed_mounts=frozenset({"/b"}))
|
||||
|
||||
async def run():
|
||||
results = await asyncio.gather(
|
||||
ws.execute("cat /a/x.txt", session_id="agent_a"),
|
||||
ws.execute("cat /b/y.txt", session_id="agent_b"),
|
||||
ws.execute("cat /b/y.txt", session_id="agent_a"),
|
||||
ws.execute("cat /a/x.txt", session_id="agent_b"),
|
||||
)
|
||||
return results
|
||||
|
||||
a_ok, b_ok, a_denied, b_denied = asyncio.run(run())
|
||||
assert a_ok.exit_code == 0 and b"A-only" in (a_ok.stdout or b"")
|
||||
assert b_ok.exit_code == 0 and b"B-only" in (b_ok.stdout or b"")
|
||||
assert a_denied.exit_code != 0
|
||||
assert b_denied.exit_code != 0
|
||||
|
||||
|
||||
def test_background_job_inherits_capability():
|
||||
ws = _two_mounts_with_secret()
|
||||
|
||||
async def run():
|
||||
# Background a forbidden read; the job runs in a Task that
|
||||
# snapshots the contextvar. wait reaps it; jobs reports state.
|
||||
return await ws.execute(
|
||||
"cat /b/secret.txt &; wait",
|
||||
session_id="agent",
|
||||
)
|
||||
|
||||
io = asyncio.run(run())
|
||||
out = (io.stdout or b"") + (io.stderr or b"")
|
||||
assert b"SECRET" not in out, (
|
||||
f"background job must not leak forbidden read, got {io}")
|
||||
@@ -0,0 +1,454 @@
|
||||
# ========= 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 asyncio
|
||||
import importlib.metadata
|
||||
import json
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.snapshot import to_state_dict
|
||||
|
||||
|
||||
def _load(*args, **kwargs):
|
||||
return asyncio.run(Workspace.load(*args, **kwargs))
|
||||
|
||||
|
||||
def _seed(ws, mount: str = "/m") -> None:
|
||||
|
||||
async def _do():
|
||||
await ws.execute(f"echo hello > {mount}/a.txt")
|
||||
await ws.execute(
|
||||
f"mkdir -p {mount}/sub && echo world > {mount}/sub/b.txt")
|
||||
|
||||
asyncio.run(_do())
|
||||
|
||||
|
||||
def _read(ws, path: str) -> str:
|
||||
|
||||
async def _do():
|
||||
r = await ws.execute(f"cat {path}")
|
||||
return await r.stdout_str()
|
||||
|
||||
return asyncio.run(_do())
|
||||
|
||||
|
||||
# ── RAM round trip ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_load_ram_round_trip(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
_seed(src)
|
||||
snap = tmp_path / "ram.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
assert snap.exists() and snap.stat().st_size > 0
|
||||
|
||||
dst = _load(snap)
|
||||
assert _read(dst, "/m/a.txt") == "hello\n"
|
||||
assert _read(dst, "/m/sub/b.txt") == "world\n"
|
||||
|
||||
|
||||
def test_history_survives_snapshot_round_trip(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo one"))
|
||||
asyncio.run(src.execute("echo two"))
|
||||
assert len(asyncio.run(src.history())) == 2
|
||||
snap = tmp_path / "history.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = _load(snap)
|
||||
entries = asyncio.run(dst.history())
|
||||
assert [e["command"] for e in entries] == ["echo one", "echo two"]
|
||||
|
||||
|
||||
def test_from_state_rebuilds_in_process_without_tar():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
_seed(src)
|
||||
|
||||
dst = asyncio.run(Workspace.from_state(asyncio.run(to_state_dict(src))))
|
||||
assert _read(dst, "/m/a.txt") == "hello\n"
|
||||
assert _read(dst, "/m/sub/b.txt") == "world\n"
|
||||
|
||||
|
||||
def test_save_load_ram_compressed_gz(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
_seed(src)
|
||||
snap = tmp_path / "ram.tar.gz"
|
||||
asyncio.run(src.snapshot(snap, compress="gz"))
|
||||
|
||||
dst = _load(snap)
|
||||
assert _read(dst, "/m/a.txt") == "hello\n"
|
||||
|
||||
|
||||
# ── Disk round trip ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_save_load_disk_round_trip(tmp_path):
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
src = Workspace(
|
||||
{"/m": (DiskResource(root=str(src_root)), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
_seed(src)
|
||||
snap = tmp_path / "disk.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = _load(snap)
|
||||
assert _read(dst, "/m/a.txt") == "hello\n"
|
||||
assert _read(dst, "/m/sub/b.txt") == "world\n"
|
||||
|
||||
|
||||
def test_save_load_disk_with_override_root(tmp_path):
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
src = Workspace(
|
||||
{"/m": (DiskResource(root=str(src_root)), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
_seed(src)
|
||||
snap = tmp_path / "disk.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst_root = tmp_path / "dst"
|
||||
dst_root.mkdir()
|
||||
dst = _load(snap, resources={"/m": DiskResource(root=str(dst_root))})
|
||||
assert (dst_root / "a.txt").read_bytes() == b"hello\n"
|
||||
assert (dst_root / "sub" / "b.txt").read_bytes() == b"world\n"
|
||||
assert _read(dst, "/m/a.txt") == "hello\n"
|
||||
|
||||
|
||||
# ── redacted secret override enforcement ─────────────────────────────
|
||||
|
||||
|
||||
def test_redacted_secret_missing_resource_raises(tmp_path):
|
||||
cfg = S3Config(bucket="b",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="AKIA-LEAK",
|
||||
aws_secret_access_key="SECRET-LEAK")
|
||||
src = Workspace({"/s3": (S3Resource(cfg), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
snap = tmp_path / "s3.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
with pytest.raises(ValueError, match=r"resources="):
|
||||
_load(snap)
|
||||
|
||||
|
||||
def test_redacted_secret_lists_all_missing(tmp_path):
|
||||
src = Workspace(
|
||||
{
|
||||
"/ram": (RAMResource(), MountMode.WRITE),
|
||||
"/s3a": (S3Resource(
|
||||
S3Config(bucket="a",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="x",
|
||||
aws_secret_access_key="x")), MountMode.WRITE),
|
||||
"/s3b": (S3Resource(
|
||||
S3Config(bucket="b",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="x",
|
||||
aws_secret_access_key="x")), MountMode.WRITE),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
snap = tmp_path / "two-s3.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
with pytest.raises(ValueError) as ei:
|
||||
_load(snap)
|
||||
msg = str(ei.value)
|
||||
assert "/s3a" in msg
|
||||
assert "/s3b" in msg
|
||||
|
||||
|
||||
def test_s3_without_inline_secret_loads_without_override(tmp_path):
|
||||
cfg = S3Config(bucket="b", region="us-east-1", aws_profile="dev")
|
||||
src = Workspace({"/s3": (S3Resource(cfg), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
snap = tmp_path / "s3-profile.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = _load(snap)
|
||||
mount = dst._registry.mount_for("/s3/")
|
||||
assert isinstance(mount.resource, S3Resource)
|
||||
assert mount.resource.config.aws_profile == "dev"
|
||||
|
||||
|
||||
# ── cred redaction in raw bytes ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_no_real_creds_in_tar_bytes(tmp_path):
|
||||
cfg = S3Config(bucket="b",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="AKIA-OBVIOUS-LEAK",
|
||||
aws_secret_access_key="SECRET-OBVIOUS-LEAK")
|
||||
src = Workspace({"/s3": (S3Resource(cfg), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
snap = tmp_path / "s3.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
raw = snap.read_bytes()
|
||||
assert b"AKIA-OBVIOUS-LEAK" not in raw
|
||||
assert b"SECRET-OBVIOUS-LEAK" not in raw
|
||||
assert b"<REDACTED>" in raw
|
||||
|
||||
|
||||
# ── manifest validity ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_manifest_is_valid_json(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
_seed(src)
|
||||
snap = tmp_path / "snap.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
with tarfile.open(snap, "r") as tar:
|
||||
f = tar.extractfile("manifest.json")
|
||||
manifest = json.loads(f.read().decode("utf-8"))
|
||||
assert manifest["version"] == 2
|
||||
assert "mounts" in manifest
|
||||
assert "cache" in manifest
|
||||
|
||||
|
||||
def test_disk_files_extractable_from_tar(tmp_path):
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
src = Workspace(
|
||||
{"/m": (DiskResource(root=str(src_root)), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
_seed(src)
|
||||
snap = tmp_path / "disk.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
extract = tmp_path / "extract"
|
||||
extract.mkdir()
|
||||
with tarfile.open(snap, "r") as tar:
|
||||
for member in tar.getmembers():
|
||||
if member.name.startswith("mounts/0/files/"):
|
||||
tar.extract(member, extract, filter="data")
|
||||
assert (extract / "mounts/0/files/a.txt").read_bytes() == b"hello\n"
|
||||
assert (extract / "mounts/0/files/sub/b.txt").read_bytes() == b"world\n"
|
||||
|
||||
|
||||
# ── path-traversal defense ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_load_rejects_path_traversal_in_blob_ref(tmp_path):
|
||||
snap = tmp_path / "bad.tar"
|
||||
manifest = {
|
||||
"version":
|
||||
1,
|
||||
"mirage_version":
|
||||
"0.1.0",
|
||||
"default_session_id":
|
||||
"default",
|
||||
"default_agent_id":
|
||||
"default",
|
||||
"current_agent_id":
|
||||
"default",
|
||||
"sessions": [],
|
||||
"history":
|
||||
None,
|
||||
"mounts": [{
|
||||
"index": 0,
|
||||
"prefix": "/m",
|
||||
"mode": "WRITE",
|
||||
"consistency": "LAZY",
|
||||
"resource_class": "mirage.resource.ram.RAMResource",
|
||||
"resource_state": {
|
||||
"type": "ram",
|
||||
"files": {
|
||||
"/x": {
|
||||
"__file": "../../etc/passwd"
|
||||
}
|
||||
},
|
||||
"dirs": [],
|
||||
"modified": {},
|
||||
},
|
||||
}],
|
||||
"cache": {
|
||||
"limit": 0,
|
||||
"max_drain_bytes": None,
|
||||
"entries": []
|
||||
},
|
||||
"jobs": [],
|
||||
}
|
||||
with tarfile.open(snap, "w") as tar:
|
||||
data = json.dumps(manifest).encode("utf-8")
|
||||
info = tarfile.TarInfo(name="manifest.json")
|
||||
info.size = len(data)
|
||||
import io as _io
|
||||
tar.addfile(info, _io.BytesIO(data))
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe blob path"):
|
||||
_load(snap)
|
||||
|
||||
|
||||
# ── copy ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_workspace_copy_independence_ram():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo hi > /m/a.txt"))
|
||||
|
||||
cp = asyncio.run(src.copy())
|
||||
asyncio.run(cp.execute("echo bye > /m/a.txt"))
|
||||
|
||||
assert _read(src, "/m/a.txt") == "hi\n"
|
||||
assert _read(cp, "/m/a.txt") == "bye\n"
|
||||
|
||||
|
||||
def test_workspace_copy_preserves_max_drain_bytes():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
src.max_drain_bytes = 1234
|
||||
cp = asyncio.run(src.copy())
|
||||
assert cp.max_drain_bytes == 1234
|
||||
|
||||
|
||||
# ── state dict shape ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_to_state_dict_shape():
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
state = asyncio.run(to_state_dict(ws))
|
||||
assert state["version"] == 2
|
||||
assert state["mirage_version"] == importlib.metadata.version("mirage-ai")
|
||||
assert state["mirage_version"] != "unknown"
|
||||
assert isinstance(state["mounts"], list)
|
||||
assert state["cache"]["entries"] == []
|
||||
assert state["jobs"] == []
|
||||
|
||||
|
||||
def test_snapshot_round_trip_no_sync_policy(tmp_path):
|
||||
ws = Workspace({"/data": RAMResource()})
|
||||
target = tmp_path / "snap.tar"
|
||||
asyncio.run(ws.snapshot(str(target)))
|
||||
restored = _load(str(target))
|
||||
assert restored is not None
|
||||
|
||||
|
||||
# ── filenames with spaces / unicode ───────────────────────────────
|
||||
|
||||
|
||||
def test_ram_round_trip_filenames_with_spaces(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
src._registry.mount_for("/m/").resource._store.files["/my file.txt"] = (
|
||||
b"with spaces")
|
||||
src._registry.mount_for("/m/").resource._store.files[
|
||||
"/dir with space/data.txt"] = b"nested with space"
|
||||
src._registry.mount_for("/m/").resource._store.files["/数据.txt"] = (
|
||||
"你好".encode())
|
||||
|
||||
snap = tmp_path / "spaces.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
dst = _load(snap)
|
||||
|
||||
files = dst._registry.mount_for("/m/").resource._store.files
|
||||
assert files["/my file.txt"] == b"with spaces"
|
||||
assert files["/dir with space/data.txt"] == b"nested with space"
|
||||
assert files["/数据.txt"].decode() == "你好"
|
||||
|
||||
|
||||
def test_disk_round_trip_filenames_with_spaces(tmp_path):
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
(src_root / "my file.txt").write_bytes(b"hello space")
|
||||
(src_root / "dir with space").mkdir()
|
||||
(src_root / "dir with space" / "data.txt").write_bytes(b"deep space")
|
||||
(src_root / "数据.txt").write_bytes("你好".encode())
|
||||
|
||||
src = Workspace(
|
||||
{"/m": (DiskResource(root=str(src_root)), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
snap = tmp_path / "disk-spaces.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst_root = tmp_path / "dst"
|
||||
dst_root.mkdir()
|
||||
_load(snap, resources={"/m": DiskResource(root=str(dst_root))})
|
||||
assert (dst_root / "my file.txt").read_bytes() == b"hello space"
|
||||
assert ((dst_root / "dir with space" /
|
||||
"data.txt").read_bytes() == b"deep space")
|
||||
assert (dst_root / "数据.txt").read_bytes().decode() == "你好"
|
||||
|
||||
|
||||
def test_is_safe_blob_path_allows_spaces_and_unicode():
|
||||
from mirage.workspace.snapshot import is_safe_blob_path
|
||||
assert is_safe_blob_path("my file.txt")
|
||||
assert is_safe_blob_path("dir with space/data.txt")
|
||||
assert is_safe_blob_path("数据.txt")
|
||||
assert not is_safe_blob_path("../etc/passwd")
|
||||
assert not is_safe_blob_path("/abs/path")
|
||||
assert not is_safe_blob_path("")
|
||||
assert not is_safe_blob_path("foo/../bar")
|
||||
assert not is_safe_blob_path("foo\x00bar")
|
||||
|
||||
|
||||
# ── Redis round trip ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not __import__("os").environ.get("REDIS_URL"),
|
||||
reason="REDIS_URL not set")
|
||||
def test_redis_round_trip_filenames_with_spaces(tmp_path):
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import redis as sync_redis
|
||||
|
||||
from mirage.resource.redis import RedisResource
|
||||
redis_url = os.environ["REDIS_URL"]
|
||||
src_prefix = f"mirage:test:src:{uuid.uuid4().hex}:"
|
||||
dst_prefix = f"mirage:test:dst:{uuid.uuid4().hex}:"
|
||||
|
||||
sc = sync_redis.Redis.from_url(redis_url)
|
||||
sc.set(f"{src_prefix}file:/my file.txt", b"hello space")
|
||||
sc.set(f"{src_prefix}file:/dir with space/data.txt", b"deep space")
|
||||
sc.sadd(f"{src_prefix}dir", "/dir with space")
|
||||
sc.close()
|
||||
|
||||
src = Workspace(
|
||||
{
|
||||
"/m": (RedisResource(url=redis_url,
|
||||
key_prefix=src_prefix), MountMode.WRITE)
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
snap = tmp_path / "redis-spaces.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst_resource = RedisResource(url=redis_url, key_prefix=dst_prefix)
|
||||
_load(snap, resources={"/m": dst_resource})
|
||||
|
||||
sc = sync_redis.Redis.from_url(redis_url)
|
||||
try:
|
||||
assert sc.get(f"{dst_prefix}file:/my file.txt") == b"hello space"
|
||||
assert (sc.get(f"{dst_prefix}file:/dir with space/data.txt") ==
|
||||
b"deep space")
|
||||
finally:
|
||||
for prefix in (src_prefix, dst_prefix):
|
||||
for key in sc.scan_iter(f"{prefix}*"):
|
||||
sc.delete(key)
|
||||
sc.close()
|
||||
@@ -0,0 +1,211 @@
|
||||
# ========= 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 asyncio
|
||||
from contextlib import ExitStack
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.types import DriftPolicy, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.snapshot import ContentDriftError, install_fingerprints
|
||||
from tests.integration.s3_mock import patch_s3_multi
|
||||
|
||||
|
||||
def _load(*args, **kwargs):
|
||||
return asyncio.run(Workspace.load(*args, **kwargs))
|
||||
|
||||
|
||||
def test_install_fingerprints_pins_revision_and_queues_drift():
|
||||
ws = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
entries = [
|
||||
{
|
||||
"path": "/m/pinned.txt",
|
||||
"mount_prefix": "/m/",
|
||||
"revision": "v9"
|
||||
},
|
||||
{
|
||||
"path": "/m/checked.txt",
|
||||
"mount_prefix": "/m/",
|
||||
"fingerprint": "fp1"
|
||||
},
|
||||
]
|
||||
|
||||
install_fingerprints(ws, entries, DriftPolicy.STRICT)
|
||||
|
||||
mount = ws._registry.mount_for("/m/pinned.txt")
|
||||
assert mount.revisions["/m/pinned.txt"] == "v9"
|
||||
assert ws._drift_check_pending is True
|
||||
queued = [path for (_, path, _) in ws._pending_drift]
|
||||
assert "/m/checked.txt" in queued
|
||||
|
||||
|
||||
def _config() -> S3Config:
|
||||
return S3Config(
|
||||
bucket="test-bucket",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="fake",
|
||||
aws_secret_access_key="fake",
|
||||
)
|
||||
|
||||
|
||||
def test_strict_load_raises_when_s3_etag_drifts(tmp_path):
|
||||
"""Snapshot a workspace with one read on /s3, mutate the underlying
|
||||
object so its ETag changes, load with strict policy: the next read
|
||||
must raise ContentDriftError rather than silently serve drifted
|
||||
bytes.
|
||||
"""
|
||||
store = {"data.csv": b"version 1 bytes\n"}
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
||||
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
result = asyncio.run(src.execute("cat /s3/data.csv"))
|
||||
assert b"version 1" in result.stdout
|
||||
|
||||
snap = tmp_path / "snap.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
store["data.csv"] = b"VERSION 2 DRIFTED\n"
|
||||
|
||||
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
||||
with pytest.raises(ContentDriftError) as exc_info:
|
||||
asyncio.run(dst.execute("cat /s3/data.csv"))
|
||||
assert exc_info.value.path == "/s3/data.csv"
|
||||
live = exc_info.value.live_fingerprint
|
||||
recorded = exc_info.value.snapshot_fingerprint
|
||||
assert live != recorded
|
||||
|
||||
|
||||
def test_off_load_serves_drifted_bytes_silently(tmp_path):
|
||||
"""drift_policy=OFF disables the check: the load returns the new
|
||||
bytes with no error. This is the only opt-out from drift detection.
|
||||
"""
|
||||
store = {"data.csv": b"version 1 bytes\n"}
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
||||
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("cat /s3/data.csv"))
|
||||
|
||||
snap = tmp_path / "snap.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
store["data.csv"] = b"VERSION 2 DRIFTED\n"
|
||||
|
||||
dst = _load(snap,
|
||||
resources={"/s3": S3Resource(_config())},
|
||||
drift_policy=DriftPolicy.OFF)
|
||||
result = asyncio.run(dst.execute("cat /s3/data.csv"))
|
||||
assert b"VERSION 2 DRIFTED" in result.stdout
|
||||
|
||||
|
||||
def test_strict_load_passes_when_etag_unchanged(tmp_path):
|
||||
"""The control case: same bytes still in S3, strict load must succeed
|
||||
and serve them. Verifies the check is precise (no false positives).
|
||||
"""
|
||||
store = {"data.csv": b"stable bytes\n"}
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
||||
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("cat /s3/data.csv"))
|
||||
|
||||
snap = tmp_path / "snap.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
||||
result = asyncio.run(dst.execute("cat /s3/data.csv"))
|
||||
assert b"stable bytes" in result.stdout
|
||||
|
||||
|
||||
def test_unrecorded_path_skips_drift_check(tmp_path):
|
||||
"""A path the agent did not read at snapshot time has no recorded
|
||||
fingerprint, so it must NOT be drift-checked at load — just live-
|
||||
served. Tests that drift-check is opt-in per recorded path.
|
||||
"""
|
||||
store = {
|
||||
"read-me.txt": b"recorded\n",
|
||||
"added-later.txt": b"not in snapshot\n"
|
||||
}
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch_s3_multi({"test-bucket": store}))
|
||||
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("cat /s3/read-me.txt"))
|
||||
|
||||
snap = tmp_path / "snap.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
||||
result = asyncio.run(dst.execute("cat /s3/added-later.txt"))
|
||||
assert b"not in snapshot" in result.stdout
|
||||
|
||||
|
||||
def test_version_pin_serves_original_bytes_on_versioned_bucket(tmp_path):
|
||||
"""On a versioned S3 bucket, snapshot captures the object's VersionId
|
||||
alongside its ETag. After the live bytes drift, load with default
|
||||
STRICT policy pins reads to the recorded VersionId and serves the
|
||||
ORIGINAL bytes the agent saw, instead of raising drift or returning
|
||||
the current head.
|
||||
"""
|
||||
store = {"data.csv": b"original\n"}
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch_s3_multi({"test-bucket": store}, versioned={"test-bucket"}))
|
||||
src = Workspace({"/s3": (S3Resource(_config()), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("cat /s3/data.csv"))
|
||||
|
||||
snap = tmp_path / "snap.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
store["data.csv"] = b"mutated bytes\n"
|
||||
|
||||
dst = _load(snap, resources={"/s3": S3Resource(_config())})
|
||||
# Cache holds snapshot bytes; clear so we hit S3 and exercise the
|
||||
# pin path, not the cache path.
|
||||
_drop_path_from_cache(dst, "/s3/data.csv")
|
||||
result = asyncio.run(dst.execute("cat /s3/data.csv"))
|
||||
assert result.stdout == b"original\n"
|
||||
|
||||
|
||||
def _drop_path_from_cache(ws, path: str) -> None:
|
||||
cache = ws._cache
|
||||
cache._entries.pop(path, None)
|
||||
cache._store.files.pop(path, None)
|
||||
|
||||
|
||||
def test_live_only_mount_does_not_block_snapshot(tmp_path, caplog):
|
||||
"""Workspaces with non-SUPPORTS_SNAPSHOT mounts (RAM here as a stand-
|
||||
in for Gmail/Slack/Linear) snapshot fine; no fingerprints are
|
||||
captured for those paths and the load layer logs an honest warning
|
||||
surfacing the live-only mount list.
|
||||
"""
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo body > /m/note.txt"))
|
||||
asyncio.run(src.execute("cat /m/note.txt"))
|
||||
|
||||
snap = tmp_path / "snap.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
_load(snap)
|
||||
assert any("live-only" in r.message.lower()
|
||||
or "live-only" in r.getMessage().lower()
|
||||
for r in caplog.records) or any(
|
||||
"no drift" in r.message.lower()
|
||||
or "no drift" in r.getMessage().lower()
|
||||
for r in caplog.records)
|
||||
@@ -0,0 +1,304 @@
|
||||
# ========= 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.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _ws():
|
||||
return Workspace({"/data": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ln_readlink_verbatim():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
r = await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("readlink /data/link.txt")
|
||||
assert r.stdout.decode() == "/data/a.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ln_relative_target_kept_verbatim():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s a.txt /data/link.txt")
|
||||
r = await ws.execute("readlink /data/link.txt")
|
||||
assert r.stdout.decode() == "a.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ln_sf_overwrites():
|
||||
ws = _ws()
|
||||
await ws.execute("echo a > /data/a.txt")
|
||||
await ws.execute("echo b > /data/b.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
await ws.execute("ln -s -f /data/b.txt /data/link.txt")
|
||||
r = await ws.execute("readlink /data/link.txt")
|
||||
assert r.stdout.decode() == "/data/b.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ln_no_force_refuses_existing_link():
|
||||
ws = _ws()
|
||||
await ws.execute("echo a > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
assert r.exit_code == 1
|
||||
assert b"File exists" in r.stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_through_symlink():
|
||||
ws = _ws()
|
||||
await ws.execute("mkdir -p /data/real")
|
||||
await ws.execute("ln -s /data/real /data/slink")
|
||||
r = await ws.execute("cd /data/slink && pwd")
|
||||
assert r.stdout.decode() == "/data/real\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cd_symlink_loop_is_eloop():
|
||||
ws = _ws()
|
||||
await ws.execute("ln -s /data/b /data/a")
|
||||
await ws.execute("ln -s /data/a /data/b")
|
||||
r = await ws.execute("cd /data/a")
|
||||
assert r.exit_code == 1
|
||||
assert b"Too many levels of symbolic links" in r.stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_symlink_survives_snapshot(tmp_path):
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
target = str(tmp_path / "snap.tar")
|
||||
await ws.snapshot(target)
|
||||
ws2 = await Workspace.load(target)
|
||||
r = await ws2.execute("readlink /data/link.txt")
|
||||
assert r.stdout.decode() == "/data/a.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_follows_link():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("cat /data/link.txt")
|
||||
assert r.exit_code == 0
|
||||
assert r.stdout.decode() == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_follows_midpath_dir_link():
|
||||
ws = _ws()
|
||||
await ws.execute("mkdir -p /data/real && echo hi > /data/real/f.txt")
|
||||
await ws.execute("ln -s /data/real /data/dirlink")
|
||||
r = await ws.execute("cat /data/dirlink/f.txt")
|
||||
assert r.stdout.decode() == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_follows_relative_target():
|
||||
ws = _ws()
|
||||
await ws.execute("mkdir -p /data/sub && echo hi > /data/sub/a.txt")
|
||||
await ws.execute("ln -s a.txt /data/sub/link.txt")
|
||||
r = await ws.execute("cat /data/sub/link.txt")
|
||||
assert r.stdout.decode() == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_through_link_updates_target():
|
||||
ws = _ws()
|
||||
await ws.execute("echo old > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
await ws.execute("echo new > /data/link.txt")
|
||||
r = await ws.execute("cat /data/a.txt")
|
||||
assert r.stdout.decode() == "new\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_dangling_link_errors_with_typed_name():
|
||||
ws = _ws()
|
||||
await ws.execute("ln -s /data/missing /data/dangle")
|
||||
r = await ws.execute("cat /data/dangle")
|
||||
assert r.exit_code == 1
|
||||
assert b"/data/dangle" in r.stderr
|
||||
assert b"No such file" in r.stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_loop_is_eloop_with_operand():
|
||||
ws = _ws()
|
||||
await ws.execute("ln -s /data/b /data/a")
|
||||
await ws.execute("ln -s /data/a /data/b")
|
||||
r = await ws.execute("cat /data/a")
|
||||
assert r.exit_code == 1
|
||||
assert b"cat: /data/a: Too many levels of symbolic links" in r.stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_lists_links():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("ls /data")
|
||||
assert "link.txt" in r.stdout.decode()
|
||||
r = await ws.execute("ls -F /data")
|
||||
assert "link.txt@" in r.stdout.decode()
|
||||
r = await ws.execute("ls -l /data")
|
||||
assert "link.txt -> /data/a.txt" in r.stdout.decode()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_through_dir_link():
|
||||
ws = _ws()
|
||||
await ws.execute("mkdir -p /data/real && echo hi > /data/real/f.txt")
|
||||
await ws.execute("ln -s /data/real /data/dirlink")
|
||||
r = await ws.execute("ls /data/dirlink")
|
||||
assert r.stdout.decode() == "f.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_removes_link_not_target():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("rm /data/link.txt")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("readlink /data/link.txt")
|
||||
assert r.exit_code == 1
|
||||
r = await ws.execute("cat /data/a.txt")
|
||||
assert r.stdout.decode() == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_dangling_link():
|
||||
ws = _ws()
|
||||
await ws.execute("ln -s /data/missing /data/dangle")
|
||||
r = await ws.execute("rm /data/dangle")
|
||||
assert r.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_mixed_link_and_file():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt && echo x > /data/b.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("rm /data/link.txt /data/b.txt")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("ls /data")
|
||||
assert r.stdout.decode() == "a.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_target_leaves_link_dangling():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
await ws.execute("rm /data/a.txt")
|
||||
r = await ws.execute("readlink /data/link.txt")
|
||||
assert r.stdout.decode() == "/data/a.txt\n"
|
||||
r = await ws.execute("cat /data/link.txt")
|
||||
assert r.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_r_purges_links_under_dir():
|
||||
ws = _ws()
|
||||
await ws.execute("mkdir -p /data/sub && echo hi > /data/sub/f.txt")
|
||||
await ws.execute("ln -s /data/sub/f.txt /data/sub/inner")
|
||||
r = await ws.execute("rm -r /data/sub")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("readlink /data/sub/inner")
|
||||
assert r.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_renames_link_entry():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("mv /data/link.txt /data/renamed.txt")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("readlink /data/renamed.txt")
|
||||
assert r.stdout.decode() == "/data/a.txt\n"
|
||||
r = await ws.execute("readlink /data/link.txt")
|
||||
assert r.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_link_into_existing_dir():
|
||||
ws = _ws()
|
||||
await ws.execute("mkdir -p /data/dir && echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("mv /data/link.txt /data/dir")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("readlink /data/dir/link.txt")
|
||||
assert r.stdout.decode() == "/data/a.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_file_onto_link_replaces_entry():
|
||||
ws = _ws()
|
||||
await ws.execute("echo a > /data/a.txt && echo b > /data/b.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("mv /data/b.txt /data/link.txt")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("readlink /data/link.txt")
|
||||
assert r.exit_code == 1
|
||||
r = await ws.execute("cat /data/link.txt")
|
||||
assert r.stdout.decode() == "b\n"
|
||||
r = await ws.execute("cat /data/a.txt")
|
||||
assert r.stdout.decode() == "a\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cross_mount_link_follow():
|
||||
ws = Workspace(
|
||||
{
|
||||
"/data": (RAMResource(), MountMode.WRITE),
|
||||
"/other": (RAMResource(), MountMode.WRITE),
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
await ws.execute("echo remote > /other/g.txt")
|
||||
await ws.execute("ln -s /other/g.txt /data/xlink")
|
||||
r = await ws.execute("cat /data/xlink")
|
||||
assert r.stdout.decode() == "remote\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_follows_source_link():
|
||||
ws = _ws()
|
||||
await ws.execute("echo hi > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("cp /data/link.txt /data/copy.txt")
|
||||
assert r.exit_code == 0
|
||||
r = await ws.execute("cat /data/copy.txt")
|
||||
assert r.stdout.decode() == "hi\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_follows_link():
|
||||
ws = _ws()
|
||||
await ws.execute("printf 'alpha\\nbeta\\n' > /data/a.txt")
|
||||
await ws.execute("ln -s /data/a.txt /data/link.txt")
|
||||
r = await ws.execute("grep beta /data/link.txt")
|
||||
assert r.exit_code == 0
|
||||
assert "beta" in r.stdout.decode()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
# ========= 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 asyncio
|
||||
import copy as _copy
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.shell.job_table import Job, JobStatus
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from tests.integration.s3_mock import patch_s3_multi
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
|
||||
|
||||
def _load(*args, **kwargs):
|
||||
return asyncio.run(Workspace.load(*args, **kwargs))
|
||||
|
||||
|
||||
def _read(ws, path):
|
||||
|
||||
async def _do():
|
||||
r = await ws.execute(f"cat {path}")
|
||||
return await r.stdout_str()
|
||||
|
||||
return asyncio.run(_do())
|
||||
|
||||
|
||||
# ── Workspace.save / Workspace.load (instance + classmethod) ─────────
|
||||
|
||||
|
||||
def test_workspace_save_then_load_classmethod(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo hi > /m/a.txt"))
|
||||
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
assert snap.exists() and snap.stat().st_size > 0
|
||||
|
||||
dst = _load(snap)
|
||||
assert _read(dst, "/m/a.txt") == "hi\n"
|
||||
|
||||
|
||||
def test_workspace_save_compressed(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo hi > /m/a.txt"))
|
||||
|
||||
snap = tmp_path / "ws.tar.gz"
|
||||
asyncio.run(src.snapshot(snap, compress="gz"))
|
||||
dst = _load(snap)
|
||||
assert _read(dst, "/m/a.txt") == "hi\n"
|
||||
|
||||
|
||||
def test_workspace_load_with_disk_override(tmp_path):
|
||||
src_root = tmp_path / "src"
|
||||
src_root.mkdir()
|
||||
(src_root / "a.txt").write_bytes(b"hello\n")
|
||||
src = Workspace(
|
||||
{"/m": (DiskResource(root=str(src_root)), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst_root = tmp_path / "dst"
|
||||
dst_root.mkdir()
|
||||
dst = _load(snap, resources={"/m": DiskResource(root=str(dst_root))})
|
||||
assert _read(dst, "/m/a.txt") == "hello\n"
|
||||
assert (dst_root / "a.txt").read_bytes() == b"hello\n"
|
||||
|
||||
|
||||
# ── Workspace.copy ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_workspace_copy_method_independence_ram():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo hi > /m/a.txt"))
|
||||
|
||||
cp = asyncio.run(src.copy())
|
||||
asyncio.run(cp.execute("echo bye > /m/a.txt"))
|
||||
|
||||
assert _read(src, "/m/a.txt") == "hi\n"
|
||||
assert _read(cp, "/m/a.txt") == "bye\n"
|
||||
|
||||
|
||||
# ── copy.deepcopy(ws) → uses __deepcopy__ → uses copy() ──────────
|
||||
|
||||
|
||||
def test_deepcopy_via_stdlib():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo hi > /m/a.txt"))
|
||||
|
||||
cp = asyncio.run((src).copy())
|
||||
asyncio.run(cp.execute("echo bye > /m/a.txt"))
|
||||
|
||||
assert _read(src, "/m/a.txt") == "hi\n"
|
||||
assert _read(cp, "/m/a.txt") == "bye\n"
|
||||
|
||||
|
||||
# ── copy.copy(ws) must raise — shallow copy makes no sense ────────
|
||||
|
||||
|
||||
def test_shallow_copy_raises():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
with pytest.raises(NotImplementedError, match="useful shallow copy"):
|
||||
_copy.copy(src)
|
||||
|
||||
|
||||
def test_shallow_copy_error_mentions_alternatives():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
with pytest.raises(NotImplementedError) as exc_info:
|
||||
_copy.copy(src)
|
||||
msg = str(exc_info.value)
|
||||
assert "ws.copy()" in msg
|
||||
|
||||
|
||||
# ── max_drain_bytes preserved across save/load ───────────────────
|
||||
|
||||
|
||||
def test_save_load_preserves_max_drain_bytes(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
src.max_drain_bytes = 1234
|
||||
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
dst = _load(snap)
|
||||
assert dst.max_drain_bytes == 1234
|
||||
|
||||
|
||||
# ── history round trip ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_history_round_trip(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src.execute("echo a > /m/a.txt"))
|
||||
asyncio.run(src.execute("echo b > /m/b.txt"))
|
||||
asyncio.run(src.execute("cat /m/a.txt"))
|
||||
expected_commands = [e["command"] for e in asyncio.run(src.history())]
|
||||
assert len(expected_commands) == 3
|
||||
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
dst = _load(snap)
|
||||
|
||||
got_commands = [e["command"] for e in asyncio.run(dst.history())]
|
||||
assert got_commands == expected_commands
|
||||
|
||||
|
||||
# ── finished jobs survive, pending dropped ────────────────────────
|
||||
|
||||
|
||||
def test_finished_jobs_survive(tmp_path):
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
finished = Job(id=1,
|
||||
command="echo done",
|
||||
task=None,
|
||||
cwd="/",
|
||||
status=JobStatus.COMPLETED,
|
||||
stdout=b"done\n",
|
||||
stderr=b"",
|
||||
exit_code=0,
|
||||
created_at=time.time(),
|
||||
agent="test",
|
||||
session_id="default")
|
||||
src.job_table._jobs[1] = finished
|
||||
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
dst = _load(snap)
|
||||
|
||||
job_ids = {j.id for j in dst.job_table.list_jobs()}
|
||||
assert 1 in job_ids
|
||||
# Next job id continues from max(finished)+1 (= 2)
|
||||
assert dst.job_table._next_id == 2
|
||||
|
||||
|
||||
# ── copy() shares Redis backend (documented divergence) ──────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set")
|
||||
def test_copy_shares_redis_backend():
|
||||
import redis as sync_redis
|
||||
|
||||
from mirage.resource.redis import RedisResource
|
||||
|
||||
prefix = f"mirage:test:copy:{uuid.uuid4().hex}:"
|
||||
src = Workspace(
|
||||
{
|
||||
"/r":
|
||||
(RedisResource(url=REDIS_URL, key_prefix=prefix), MountMode.WRITE)
|
||||
},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
sc = sync_redis.Redis.from_url(REDIS_URL)
|
||||
sc.set(f"{prefix}file:/seed.txt", b"shared")
|
||||
sc.close()
|
||||
|
||||
cp = asyncio.run(src.copy())
|
||||
asyncio.run(cp.execute("echo new > /r/added.txt"))
|
||||
|
||||
sc = sync_redis.Redis.from_url(REDIS_URL)
|
||||
try:
|
||||
# Both src and cp see the new key — they share the same Redis
|
||||
# instance even though they are independent Workspaces.
|
||||
assert sc.get(f"{prefix}file:/added.txt") == b"new\n"
|
||||
finally:
|
||||
for key in sc.scan_iter(f"{prefix}*"):
|
||||
sc.delete(key)
|
||||
sc.close()
|
||||
|
||||
|
||||
# ── copy() independence of cache ─────────────────────────────────
|
||||
|
||||
|
||||
def test_copy_independence_of_cache():
|
||||
src = Workspace({"/m": (RAMResource(), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
asyncio.run(src._cache.set("/m/a.txt", b"src-cached"))
|
||||
|
||||
cp = asyncio.run(src.copy())
|
||||
asyncio.run(cp._cache.set("/m/a.txt", b"cp-cached"))
|
||||
|
||||
src_cached = asyncio.run(src._cache.get("/m/a.txt"))
|
||||
cp_cached = asyncio.run(cp._cache.get("/m/a.txt"))
|
||||
assert src_cached == b"src-cached"
|
||||
assert cp_cached == b"cp-cached"
|
||||
|
||||
|
||||
# ── S3 round trip via workspace.save (mocked) ──────────────────
|
||||
|
||||
|
||||
def test_workspace_save_load_s3_mounted(tmp_path):
|
||||
cfg_src = S3Config(bucket="src-bkt",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="OLD-AKIA-OBVIOUS",
|
||||
aws_secret_access_key="OLD-SECRET-OBVIOUS")
|
||||
cfg_dst = S3Config(bucket="dst-bkt",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="NEW-AKIA",
|
||||
aws_secret_access_key="NEW-SECRET")
|
||||
buckets: dict = {"src-bkt": {}, "dst-bkt": {}}
|
||||
|
||||
with patch_s3_multi(buckets):
|
||||
src = Workspace({"/s3": (S3Resource(cfg_src), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
# Saved tar must not contain old creds
|
||||
raw = snap.read_bytes()
|
||||
assert b"OLD-AKIA-OBVIOUS" not in raw
|
||||
assert b"OLD-SECRET-OBVIOUS" not in raw
|
||||
assert b"<REDACTED>" in raw
|
||||
|
||||
dst = _load(snap, resources={"/s3": S3Resource(cfg_dst)})
|
||||
# New mount uses fresh creds, fresh bucket
|
||||
assert dst.mount("/s3").resource.config.bucket == "dst-bkt"
|
||||
|
||||
|
||||
# ── override drops saved index ───────────────────────────────────
|
||||
|
||||
|
||||
def test_override_drops_saved_index(tmp_path):
|
||||
"""When the caller supplies an override resource, that resource's
|
||||
own (fresh, empty) index is used — not whatever was on the saved
|
||||
resource. We verify by checking the loaded mount is the override
|
||||
object itself.
|
||||
"""
|
||||
cfg = S3Config(bucket="b",
|
||||
region="us-east-1",
|
||||
aws_access_key_id="x",
|
||||
aws_secret_access_key="y")
|
||||
buckets: dict = {"b": {}}
|
||||
with patch_s3_multi(buckets):
|
||||
src = Workspace({"/s3": (S3Resource(cfg), MountMode.WRITE)},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
snap = tmp_path / "ws.tar"
|
||||
asyncio.run(src.snapshot(snap))
|
||||
|
||||
fresh = S3Resource(cfg)
|
||||
dst = _load(snap, resources={"/s3": fresh})
|
||||
# The mounted resource IS the user-supplied fresh one,
|
||||
# carrying its own (empty) index — not anything from the snapshot.
|
||||
assert dst.mount("/s3").resource is fresh
|
||||
@@ -0,0 +1,24 @@
|
||||
# ========= 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 asyncio
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def test_workspace_default_no_fuse():
|
||||
ws = Workspace(resources={"/data": RAMResource()})
|
||||
assert ws.fuse_mountpoint is None
|
||||
asyncio.run(ws.close())
|
||||
Reference in New Issue
Block a user