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,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
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import DEFAULT_SESSION_ID, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shell(tmp_path):
|
||||
return ShellTestEnv(tmp_path)
|
||||
|
||||
|
||||
class ShellTestEnv:
|
||||
|
||||
def __init__(self, tmp_path):
|
||||
self.tmp_path = tmp_path
|
||||
self.mem = RAMResource()
|
||||
self.ws = Workspace(
|
||||
{"/data": (self.mem, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
self.ws.get_session(DEFAULT_SESSION_ID).cwd = "/data"
|
||||
|
||||
def create_file(self, name: str, content: bytes):
|
||||
local_path = self.tmp_path / name
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(content)
|
||||
store = self.mem._store
|
||||
remote = "/" + name
|
||||
parts = remote.strip("/").split("/")
|
||||
for i in range(1, len(parts)):
|
||||
store.dirs.add("/" + "/".join(parts[:i]))
|
||||
store.files[remote] = content
|
||||
|
||||
def native(self, cmd: str, stdin: bytes | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
["/bin/sh", "-c", cmd],
|
||||
cwd=str(self.tmp_path),
|
||||
capture_output=True,
|
||||
input=stdin,
|
||||
)
|
||||
return result.stdout.decode(errors="replace")
|
||||
|
||||
def mirage(self, cmd: str, stdin: bytes | None = None) -> str:
|
||||
|
||||
async def _run():
|
||||
io = await self.ws.execute(cmd, stdin=stdin)
|
||||
return await io.stdout_str()
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
def mirage_exit(self, cmd: str, stdin: bytes | None = None) -> int:
|
||||
io = asyncio.run(self.ws.execute(cmd, stdin=stdin))
|
||||
return io.exit_code
|
||||
|
||||
def native_exit(self, cmd: str) -> int:
|
||||
result = subprocess.run(
|
||||
["/bin/sh", "-c", cmd],
|
||||
cwd=str(self.tmp_path),
|
||||
capture_output=True,
|
||||
)
|
||||
return result.returncode
|
||||
@@ -0,0 +1,97 @@
|
||||
import pytest
|
||||
|
||||
from mirage.shell.arith import evaluate_arith
|
||||
from mirage.shell.errors import ArithError
|
||||
|
||||
|
||||
def test_basic_precedence():
|
||||
assert evaluate_arith("1 + 2 * 3", {})[0] == 7
|
||||
assert evaluate_arith("(1 + 2) * 3", {})[0] == 9
|
||||
assert evaluate_arith("2 ** 3 ** 2", {})[0] == 512
|
||||
|
||||
|
||||
def test_trunc_division_and_mod_match_c():
|
||||
assert evaluate_arith("-7 / 2", {})[0] == -3
|
||||
assert evaluate_arith("7 / -2", {})[0] == -3
|
||||
assert evaluate_arith("-7 % 2", {})[0] == -1
|
||||
assert evaluate_arith("7 % -2", {})[0] == 1
|
||||
|
||||
|
||||
def test_literals():
|
||||
assert evaluate_arith("0x10", {})[0] == 16
|
||||
assert evaluate_arith("010", {})[0] == 8
|
||||
with pytest.raises(ArithError):
|
||||
evaluate_arith("08", {})
|
||||
|
||||
|
||||
def test_assignment_and_updates():
|
||||
value, updates = evaluate_arith("y = 3, y + 2", {})
|
||||
assert value == 5
|
||||
assert updates == {"y": "3"}
|
||||
value, updates = evaluate_arith("v += 9", {"v": "1"})
|
||||
assert (value, updates) == (10, {"v": "10"})
|
||||
|
||||
|
||||
def test_increment_decrement():
|
||||
value, updates = evaluate_arith("i++", {})
|
||||
assert (value, updates) == (0, {"i": "1"})
|
||||
value, updates = evaluate_arith("++i", {"i": "1"})
|
||||
assert (value, updates) == (2, {"i": "2"})
|
||||
value, updates = evaluate_arith("i--", {"i": "5"})
|
||||
assert (value, updates) == (5, {"i": "4"})
|
||||
|
||||
|
||||
def test_short_circuit_skips_side_effects():
|
||||
value, updates = evaluate_arith("0 && (q = 7)", {})
|
||||
assert (value, updates) == (0, {})
|
||||
value, updates = evaluate_arith("1 || (q = 7)", {})
|
||||
assert (value, updates) == (1, {})
|
||||
|
||||
|
||||
def test_ternary_evaluates_taken_arm_only():
|
||||
value, updates = evaluate_arith("1 ? (w = 4) : (w = 9)", {})
|
||||
assert (value, updates) == (4, {"w": "4"})
|
||||
assert evaluate_arith("5 > 3 ? 10 : 20", {})[0] == 10
|
||||
|
||||
|
||||
def test_variables_resolve_recursively():
|
||||
assert evaluate_arith("x + 1", {})[0] == 1
|
||||
assert evaluate_arith("s * 2", {"s": "1+2"})[0] == 6
|
||||
assert evaluate_arith("z + 1", {"z": ""})[0] == 1
|
||||
|
||||
|
||||
def test_logical_and_comparison_results_are_zero_or_one():
|
||||
assert evaluate_arith("3 && 4", {})[0] == 1
|
||||
assert evaluate_arith("!5", {})[0] == 0
|
||||
assert evaluate_arith("2 == 2", {})[0] == 1
|
||||
assert evaluate_arith("2 != 2", {})[0] == 0
|
||||
|
||||
|
||||
def test_bitwise_and_shifts():
|
||||
assert evaluate_arith("6 & 3", {})[0] == 2
|
||||
assert evaluate_arith("6 | 3", {})[0] == 7
|
||||
assert evaluate_arith("6 ^ 3", {})[0] == 5
|
||||
assert evaluate_arith("~0", {})[0] == -1
|
||||
assert evaluate_arith("1 << 4", {})[0] == 16
|
||||
assert evaluate_arith("-16 >> 2", {})[0] == -4
|
||||
|
||||
|
||||
def test_sixty_four_bit_wrap():
|
||||
assert evaluate_arith("(1 << 63) - 1 + 1", {})[0] == -(1 << 63)
|
||||
|
||||
|
||||
def test_errors():
|
||||
with pytest.raises(ArithError):
|
||||
evaluate_arith("1 / 0", {})
|
||||
with pytest.raises(ArithError):
|
||||
evaluate_arith("2 ** -1", {})
|
||||
with pytest.raises(ArithError):
|
||||
evaluate_arith("1 +", {})
|
||||
with pytest.raises(ArithError):
|
||||
evaluate_arith("@", {})
|
||||
with pytest.raises(ArithError):
|
||||
evaluate_arith("r + 1", {"r": "r + 1"})
|
||||
|
||||
|
||||
def test_empty_expression_is_zero():
|
||||
assert evaluate_arith("", {}) == (0, {})
|
||||
@@ -0,0 +1,33 @@
|
||||
# ========= 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
|
||||
|
||||
CASES = [
|
||||
('a=(one two three); echo "${a[0]}"', "one\n"),
|
||||
('a=(one two three); echo "${a[1]}"', "two\n"),
|
||||
('a=(one two three); echo "${a[2]}"', "three\n"),
|
||||
('a=(one two three); echo "${a[@]}"', "one two three\n"),
|
||||
('a=(one two three); echo "${a[*]}"', "one two three\n"),
|
||||
('a=(one two three); echo "${#a[@]}"', "3\n"),
|
||||
('a=(); echo "${#a[@]}"', "0\n"),
|
||||
('a=(x y z); for i in "${a[@]}"; do echo $i; done', "x\ny\nz\n"),
|
||||
('declare -a arr=(a b c); echo "${arr[@]}"', "a b c\n"),
|
||||
('a=("hello world" foo); echo "${a[0]}"', "hello world\n"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd,expected", CASES)
|
||||
def test_array(shell, cmd, expected):
|
||||
assert shell.mirage(cmd) == expected
|
||||
@@ -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. =========
|
||||
|
||||
|
||||
def test_subshell_isolates_readonly(shell):
|
||||
out = shell.mirage("(readonly X=1); X=2; echo $X")
|
||||
assert out == "2\n"
|
||||
|
||||
|
||||
def test_subshell_isolates_arrays(shell):
|
||||
out = shell.mirage('(a=(1 2 3)); echo "${a[0]:-empty}"')
|
||||
assert out == "empty\n"
|
||||
|
||||
|
||||
def test_subshell_isolates_shell_options(shell):
|
||||
out = shell.mirage("set -e; (set +e; true); echo continued")
|
||||
assert out == "continued\n"
|
||||
|
||||
|
||||
def test_function_prefix_persists_in_parent_env(shell):
|
||||
out = shell.mirage(
|
||||
'f() { echo "FOO=$FOO"; }; FOO=bar f; echo "after=$FOO"')
|
||||
assert out == "FOO=bar\nafter=bar\n"
|
||||
|
||||
|
||||
def test_command_prefix_does_not_persist(shell):
|
||||
out = shell.mirage('FOO=bar echo "FOO=$FOO"; echo "after=$FOO"')
|
||||
assert "after=\n" in out
|
||||
|
||||
|
||||
def test_readonly_blocks_prefix_assignment(shell):
|
||||
out = shell.mirage("readonly X=1; X=2 echo done; echo $X")
|
||||
assert out.endswith("1\n")
|
||||
|
||||
|
||||
def test_array_in_mixed_string_no_data_loss(shell):
|
||||
out = shell.mirage('a=(1 2 3); echo "x${a[@]}y"')
|
||||
assert "x" in out and "y" in out
|
||||
assert "1" in out and "3" in out
|
||||
|
||||
|
||||
def test_array_single_element_with_prefix_suffix(shell):
|
||||
out = shell.mirage('a=(only); echo "x${a[@]}y"')
|
||||
assert out == "xonlyy\n"
|
||||
|
||||
|
||||
def test_errexit_inside_subshell_body(shell):
|
||||
out = shell.mirage("set -e; (false; echo unreached); echo after")
|
||||
assert "unreached" not in out
|
||||
|
||||
|
||||
def test_errexit_inside_function_body(shell):
|
||||
out = shell.mirage("set -e; f() { false; echo unreached; }; f; echo after")
|
||||
assert "unreached" not in out
|
||||
|
||||
|
||||
def test_errexit_inside_compound_group(shell):
|
||||
out = shell.mirage("set -e; { false; echo unreached; }; echo after")
|
||||
assert "unreached" not in out
|
||||
@@ -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.io.types import IOResult
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.shell.job_table import JobStatus, JobTable
|
||||
from mirage.types import DEFAULT_SESSION_ID, MountMode
|
||||
from mirage.workspace import Workspace
|
||||
from mirage.workspace.types import ExecutionNode
|
||||
|
||||
|
||||
async def _make_failing_task():
|
||||
raise RuntimeError("resource API error")
|
||||
|
||||
|
||||
async def _make_successful_task():
|
||||
return b"hello", IOResult(exit_code=0), ExecutionNode(command="echo hello",
|
||||
exit_code=0)
|
||||
|
||||
|
||||
def test_wait_handles_task_exception():
|
||||
|
||||
async def _run():
|
||||
table = JobTable()
|
||||
task = asyncio.create_task(_make_failing_task())
|
||||
table.submit(command="bad_cmd", task=task, cwd="/")
|
||||
job = await table.wait(1)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
assert job.exit_code == 1
|
||||
assert b"resource API error" in job.stderr
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_wait_all_survives_failing_task():
|
||||
|
||||
async def _run():
|
||||
table = JobTable()
|
||||
task1 = asyncio.create_task(_make_failing_task())
|
||||
task2 = asyncio.create_task(_make_successful_task())
|
||||
table.submit(command="bad", task=task1, cwd="/")
|
||||
table.submit(command="good", task=task2, cwd="/")
|
||||
jobs = await table.wait_all()
|
||||
assert len(jobs) == 2
|
||||
bad = table.get(1)
|
||||
good = table.get(2)
|
||||
assert bad.exit_code == 1
|
||||
assert good.exit_code == 0
|
||||
assert good.stdout == b"hello"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_wait_successful_task():
|
||||
|
||||
async def _run():
|
||||
table = JobTable()
|
||||
task = asyncio.create_task(_make_successful_task())
|
||||
table.submit(command="echo hello", task=task, cwd="/")
|
||||
job = await table.wait(1)
|
||||
assert job.status == JobStatus.COMPLETED
|
||||
assert job.exit_code == 0
|
||||
assert job.stdout == b"hello"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_background_does_not_consume_stdin():
|
||||
|
||||
async def _run():
|
||||
mem = RAMResource()
|
||||
ws = Workspace(
|
||||
{"/data": (mem, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/data"
|
||||
io = await ws.execute("sleep 0 & cat", stdin=b"hello\n")
|
||||
assert (await io.stdout_str()).strip() == "hello"
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -0,0 +1,114 @@
|
||||
# ========= 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. =========
|
||||
|
||||
|
||||
def test_grep_q_in_if(shell):
|
||||
shell.create_file("f.txt", b"hello world\n")
|
||||
cmd_m = "if grep -q hello /data/f.txt; then echo found; fi"
|
||||
cmd_n = "if grep -q hello f.txt; then echo found; fi"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_grep_q_in_if_no_match(shell):
|
||||
shell.create_file("f.txt", b"hello world\n")
|
||||
cmd_m = ("if grep -q missing /data/f.txt;"
|
||||
" then echo found; else echo nope; fi")
|
||||
cmd_n = ("if grep -q missing f.txt;"
|
||||
" then echo found; else echo nope; fi")
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_grep_q_in_and(shell):
|
||||
shell.create_file("f.txt", b"hello world\n")
|
||||
cmd_m = "grep -q hello /data/f.txt && echo yes"
|
||||
cmd_n = "grep -q hello f.txt && echo yes"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_grep_q_in_and_no_match(shell):
|
||||
shell.create_file("f.txt", b"hello world\n")
|
||||
cmd_m = "grep -q missing /data/f.txt && echo yes"
|
||||
cmd_n = "grep -q missing f.txt && echo yes"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_grep_q_in_or(shell):
|
||||
shell.create_file("f.txt", b"hello world\n")
|
||||
cmd_m = "grep -q missing /data/f.txt || echo fallback"
|
||||
cmd_n = "grep -q missing f.txt || echo fallback"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_grep_q_in_while(shell):
|
||||
shell.create_file("f.txt", b"hello\n")
|
||||
cmd_m = ("while grep -q missing /data/f.txt;"
|
||||
" do echo loop; break; done; echo done")
|
||||
cmd_n = ("while grep -q missing f.txt;"
|
||||
" do echo loop; break; done; echo done")
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_pipe_stays_lazy(shell):
|
||||
shell.create_file("f.txt", b"hello\nworld\nfoo\nbar\n")
|
||||
cmd_m = "cat /data/f.txt | grep o"
|
||||
cmd_n = "cat f.txt | grep o"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_pipe_chain_stays_lazy(shell):
|
||||
shell.create_file("f.txt", b"c\na\nb\na\nc\n")
|
||||
cmd_m = "cat /data/f.txt | sort | uniq"
|
||||
cmd_n = "cat f.txt | sort | uniq"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_and_both_sides_output(shell):
|
||||
cmd = "echo a && echo b"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_or_left_succeeds_output(shell):
|
||||
cmd = "echo a || echo b"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_and_exit_code_on_failure(shell):
|
||||
assert shell.mirage_exit("false && echo yes") == shell.native_exit(
|
||||
"false && echo yes")
|
||||
|
||||
|
||||
def test_or_exit_code_on_success(shell):
|
||||
assert shell.mirage_exit("true || echo no") == shell.native_exit(
|
||||
"true || echo no")
|
||||
|
||||
|
||||
def test_redirect_materializes(shell):
|
||||
shell.create_file("f.txt", b"hello\nworld\n")
|
||||
cmd_m = "grep hello /data/f.txt > /data/out.txt; cat /data/out.txt"
|
||||
cmd_n = "grep hello f.txt > out.txt; cat out.txt"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_semicolon_exit_code_with_grep(shell):
|
||||
shell.create_file("f.txt", b"hello\n")
|
||||
cmd_m = "grep missing /data/f.txt; echo $?"
|
||||
cmd_n = "grep missing f.txt; echo $?"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
|
||||
|
||||
def test_semicolon_materializes_both_sides(shell):
|
||||
shell.create_file("f.txt", b"hello\nworld\n")
|
||||
cmd_m = "grep hello /data/f.txt; grep world /data/f.txt"
|
||||
cmd_n = "grep hello f.txt; grep world f.txt"
|
||||
assert shell.mirage(cmd_m) == shell.native(cmd_n)
|
||||
@@ -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. =========
|
||||
|
||||
from mirage.shell.call_stack import CallStack
|
||||
|
||||
|
||||
def test_initial_frame():
|
||||
cs = CallStack()
|
||||
assert cs.depth == 1
|
||||
assert cs.get_positional_count() == 0
|
||||
|
||||
|
||||
def test_push_pop():
|
||||
cs = CallStack()
|
||||
cs.push(["a", "b", "c"])
|
||||
assert cs.depth == 2
|
||||
assert cs.get_positional(1) == "a"
|
||||
assert cs.get_positional(3) == "c"
|
||||
assert cs.get_positional_count() == 3
|
||||
cs.pop()
|
||||
assert cs.depth == 1
|
||||
assert cs.get_positional_count() == 0
|
||||
|
||||
|
||||
def test_shift():
|
||||
cs = CallStack()
|
||||
cs.push(["a", "b", "c"])
|
||||
cs.shift()
|
||||
assert cs.get_positional(1) == "b"
|
||||
assert cs.get_positional_count() == 2
|
||||
|
||||
|
||||
def test_shift_n():
|
||||
cs = CallStack()
|
||||
cs.push(["a", "b", "c", "d"])
|
||||
cs.shift(2)
|
||||
assert cs.get_positional(1) == "c"
|
||||
|
||||
|
||||
def test_get_all_positional():
|
||||
cs = CallStack()
|
||||
cs.push(["x", "y"])
|
||||
assert cs.get_all_positional() == ["x", "y"]
|
||||
|
||||
|
||||
def test_set_positional():
|
||||
cs = CallStack()
|
||||
cs.set_positional(["a", "b"])
|
||||
assert cs.get_all_positional() == ["a", "b"]
|
||||
|
||||
|
||||
def test_missing_positional():
|
||||
cs = CallStack()
|
||||
assert cs.get_positional(5) == ""
|
||||
|
||||
|
||||
def test_pop_bottom_frame_safe():
|
||||
cs = CallStack()
|
||||
cs.pop()
|
||||
assert cs.depth == 1
|
||||
|
||||
|
||||
def test_local_vars():
|
||||
cs = CallStack()
|
||||
cs.push()
|
||||
cs.set_local("x", "inner")
|
||||
assert cs.get_local("x") == "inner"
|
||||
cs.pop()
|
||||
assert cs.get_local("x") is None
|
||||
|
||||
|
||||
def test_local_vars_nested():
|
||||
cs = CallStack()
|
||||
cs.push()
|
||||
cs.set_local("x", "outer")
|
||||
cs.push()
|
||||
cs.set_local("x", "inner")
|
||||
assert cs.get_local("x") == "inner"
|
||||
cs.pop()
|
||||
assert cs.get_local("x") == "outer"
|
||||
|
||||
|
||||
def test_function_name():
|
||||
cs = CallStack()
|
||||
cs.push(["a"], function_name="greet")
|
||||
assert cs.current.function_name == "greet"
|
||||
cs.pop()
|
||||
assert cs.current.function_name == ""
|
||||
|
||||
|
||||
def test_loop_level_reset_on_push():
|
||||
cs = CallStack()
|
||||
cs.current.loop_level = 3
|
||||
cs.push([], function_name="f")
|
||||
assert cs.current.loop_level == 0
|
||||
cs.pop()
|
||||
assert cs.current.loop_level == 3
|
||||
@@ -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. =========
|
||||
|
||||
|
||||
def test_dev_null_cat(shell):
|
||||
assert shell.mirage("cat /dev/null") == ""
|
||||
|
||||
|
||||
def test_dev_null_redirect_stdout(shell):
|
||||
assert shell.mirage("echo hello > /dev/null") == ""
|
||||
|
||||
|
||||
def test_dev_null_redirect_stderr(shell):
|
||||
cmd = "cat /data/nope.txt 2>/dev/null || echo recovered"
|
||||
assert "recovered" in shell.mirage(cmd)
|
||||
|
||||
|
||||
def test_dev_null_preserves_exit_code(shell):
|
||||
cmd = ("if cat /data/nope.txt 2>/dev/null; "
|
||||
"then echo found; else echo missing; fi")
|
||||
assert shell.mirage(cmd) == "missing\n"
|
||||
|
||||
|
||||
def test_dev_null_in_pipe(shell):
|
||||
assert shell.mirage("echo hello | cat > /dev/null") == ""
|
||||
|
||||
|
||||
def test_dev_zero_head(shell):
|
||||
result = shell.mirage("head -c 4 /dev/zero")
|
||||
assert result == "\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def test_dev_null_stat(shell):
|
||||
cmd = "if [ -f /dev/null ]; then echo exists; fi"
|
||||
assert shell.mirage(cmd) == "exists\n"
|
||||
@@ -0,0 +1,651 @@
|
||||
# ========= 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 tree_sitter
|
||||
import tree_sitter_bash
|
||||
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import RedirectKind
|
||||
|
||||
from mirage.shell.helpers import ( # isort: skip
|
||||
get_case_items, get_case_word, get_command_assignments, get_command_name,
|
||||
get_declaration_assignments, get_declaration_keyword, get_for_parts,
|
||||
get_function_body, get_function_name, get_heredoc_parts, get_if_branches,
|
||||
get_list_parts, get_negated_command, get_parts, get_pipeline_commands,
|
||||
get_process_sub_command, get_redirect_parts, get_redirect_target_node,
|
||||
get_subshell_body, get_test_argv, get_text, get_unset_names,
|
||||
get_while_parts)
|
||||
|
||||
_LANG = tree_sitter.Language(tree_sitter_bash.language())
|
||||
_PARSER = tree_sitter.Parser(_LANG)
|
||||
|
||||
|
||||
def _parse(cmd: str):
|
||||
return _PARSER.parse(cmd.encode()).root_node
|
||||
|
||||
|
||||
def _first(cmd: str):
|
||||
return _parse(cmd).children[0]
|
||||
|
||||
|
||||
def test_get_command_name():
|
||||
assert get_command_name(_first("cat /data/file")) == "cat"
|
||||
|
||||
|
||||
def test_get_command_name_with_assignment():
|
||||
assert get_command_name(_first("VAR=x echo hello")) == "echo"
|
||||
|
||||
|
||||
def test_get_command_name_empty():
|
||||
assert get_command_name(_first("VAR=x")) == ""
|
||||
|
||||
|
||||
def test_get_parts():
|
||||
parts = get_parts(_first("cat -n /data/file.txt"))
|
||||
texts = [get_text(p) for p in parts]
|
||||
assert texts == ["cat", "-n", "/data/file.txt"]
|
||||
|
||||
|
||||
def test_get_parts_with_expansion():
|
||||
parts = get_parts(_first("echo $VAR"))
|
||||
assert len(parts) == 2
|
||||
assert parts[1].type == NT.SIMPLE_EXPANSION
|
||||
|
||||
|
||||
def test_get_pipeline_commands():
|
||||
cmds, stderr = get_pipeline_commands(_first("echo a | grep b"))
|
||||
assert len(cmds) == 2
|
||||
assert stderr == [False]
|
||||
|
||||
|
||||
def test_get_pipeline_commands_stderr():
|
||||
cmds, stderr = get_pipeline_commands(_first("echo a |& grep b"))
|
||||
assert len(cmds) == 2
|
||||
assert stderr == [True]
|
||||
|
||||
|
||||
def test_get_pipeline_three():
|
||||
cmds, stderr = get_pipeline_commands(_first("a | b |& c"))
|
||||
assert len(cmds) == 3
|
||||
assert stderr == [False, True]
|
||||
|
||||
|
||||
def test_get_while_parts():
|
||||
cond, body = get_while_parts(_first("while true; do echo loop; done"))
|
||||
assert get_text(cond) == "true"
|
||||
|
||||
|
||||
def test_get_while_until():
|
||||
node = _first("until false; do echo; done")
|
||||
assert node.children[0].type == NT.UNTIL
|
||||
cond, body = get_while_parts(node)
|
||||
assert get_text(cond) == "false"
|
||||
|
||||
|
||||
def test_get_for_parts():
|
||||
var, values, body = get_for_parts(_first("for f in a b c; do echo; done"))
|
||||
assert var == "f"
|
||||
assert [get_text(v) for v in values] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_get_for_select():
|
||||
node = _first("select f in x y; do echo; done")
|
||||
assert node.children[0].type == NT.SELECT
|
||||
var, values, body = get_for_parts(node)
|
||||
assert var == "f"
|
||||
assert [get_text(v) for v in values] == ["x", "y"]
|
||||
|
||||
|
||||
def test_get_subshell_body():
|
||||
body = get_subshell_body(_first("(echo a; echo b)"))
|
||||
assert len(body) == 2
|
||||
|
||||
|
||||
def test_get_redirect_parts():
|
||||
cmd, target, append, stream = get_redirect_parts(
|
||||
_first("cat file > out.txt"))
|
||||
assert target == "out.txt"
|
||||
assert append is False
|
||||
assert stream == RedirectKind.STDOUT
|
||||
|
||||
|
||||
def test_get_redirect_append():
|
||||
cmd, target, append, stream = get_redirect_parts(
|
||||
_first("echo x >> log.txt"))
|
||||
assert append is True
|
||||
|
||||
|
||||
def test_get_redirect_stdin():
|
||||
cmd, target, append, stream = get_redirect_parts(_first("cat < input.txt"))
|
||||
assert stream == RedirectKind.STDIN
|
||||
|
||||
|
||||
def test_get_list_parts_and():
|
||||
left, op, right = get_list_parts(_first("echo a && echo b"))
|
||||
assert op == "&&"
|
||||
|
||||
|
||||
def test_get_list_parts_or():
|
||||
left, op, right = get_list_parts(_first("echo a || echo b"))
|
||||
assert op == "||"
|
||||
|
||||
|
||||
def test_get_if_branches():
|
||||
branches, else_body = get_if_branches(
|
||||
_first("if true; then echo y; "
|
||||
"elif false; then echo n; "
|
||||
"else echo x; fi"))
|
||||
assert len(branches) == 2
|
||||
assert else_body is not None
|
||||
|
||||
|
||||
def test_get_if_simple():
|
||||
branches, else_body = get_if_branches(_first("if true; then echo y; fi"))
|
||||
assert len(branches) == 1
|
||||
assert else_body is None
|
||||
|
||||
|
||||
def test_get_case_word():
|
||||
node = _first("case $x in a) echo;; esac")
|
||||
word = get_case_word(node)
|
||||
assert word.type == NT.SIMPLE_EXPANSION
|
||||
|
||||
|
||||
def test_get_case_items():
|
||||
items = get_case_items(_first("case x in a) echo a;; b) echo b;; esac"))
|
||||
assert len(items) == 2
|
||||
assert items[0][0] == ["a"]
|
||||
assert items[1][0] == ["b"]
|
||||
|
||||
|
||||
def test_get_function_name():
|
||||
assert get_function_name(_first("f() { echo; }")) == "f"
|
||||
|
||||
|
||||
def test_get_function_body():
|
||||
body = get_function_body(_first("f() { echo hello; }"))
|
||||
assert body is not None
|
||||
|
||||
|
||||
def test_get_command_name_no_args():
|
||||
assert get_command_name(_first("ls")) == "ls"
|
||||
|
||||
|
||||
def test_get_parts_no_args():
|
||||
parts = get_parts(_first("ls"))
|
||||
assert len(parts) == 1
|
||||
assert get_text(parts[0]) == "ls"
|
||||
|
||||
|
||||
def test_get_parts_excludes_redirect():
|
||||
parts = get_parts(_first("echo hello > out.txt").named_children[0])
|
||||
texts = [get_text(p) for p in parts]
|
||||
assert "out.txt" not in texts
|
||||
|
||||
|
||||
def test_semicolon_is_program_level():
|
||||
"""Semicolon creates separate commands, not a list node."""
|
||||
root = _parse("echo a; echo b")
|
||||
assert len(root.named_children) == 2
|
||||
assert root.named_children[0].type == NT.COMMAND
|
||||
assert root.named_children[1].type == NT.COMMAND
|
||||
|
||||
|
||||
def test_get_if_multiple_elif():
|
||||
branches, else_body = get_if_branches(
|
||||
_first("if a; then b; elif c; then d; "
|
||||
"elif e; then f; else g; fi"))
|
||||
assert len(branches) == 3
|
||||
assert else_body is not None
|
||||
|
||||
|
||||
def test_get_case_empty_body():
|
||||
items = get_case_items(_first("case x in a) ;; b) echo b;; esac"))
|
||||
assert len(items) == 2
|
||||
assert items[0][1] == []
|
||||
assert len(items[1][1]) == 1
|
||||
|
||||
|
||||
def test_get_case_multi_statement_body():
|
||||
items = get_case_items(_first("case x in a) echo 1; echo 2;; esac"))
|
||||
assert len(items) == 1
|
||||
assert len(items[0][1]) == 2
|
||||
|
||||
|
||||
def test_get_redirect_stderr():
|
||||
cmd, target, append, stream = get_redirect_parts(
|
||||
_first("echo err 2> error.log"))
|
||||
assert stream == RedirectKind.STDERR
|
||||
assert target == "error.log"
|
||||
|
||||
|
||||
def test_get_for_single_value():
|
||||
var, values, body = get_for_parts(_first("for x in hello; do echo; done"))
|
||||
assert var == "x"
|
||||
assert [get_text(v) for v in values] == ["hello"]
|
||||
|
||||
|
||||
def test_get_for_with_expansion():
|
||||
var, values, body = get_for_parts(
|
||||
_first("for f in $DIR/*.txt; do echo; done"))
|
||||
assert var == "f"
|
||||
assert len(values) == 1
|
||||
assert values[0].type == NT.CONCATENATION
|
||||
|
||||
|
||||
def test_get_while_body_type():
|
||||
cond, body = get_while_parts(_first("while true; do echo loop; done"))
|
||||
assert len(body) == 1
|
||||
assert body[0].type == NT.COMMAND
|
||||
|
||||
|
||||
def test_background_detection():
|
||||
root = _parse("echo hello &")
|
||||
children = root.children
|
||||
has_bg = any(c.type == NT.BACKGROUND for c in children)
|
||||
assert has_bg
|
||||
|
||||
|
||||
def test_process_substitution():
|
||||
node = _first("diff <(echo a) <(echo b)")
|
||||
parts = get_parts(node)
|
||||
ps_nodes = [p for p in parts if p.type == NT.PROCESS_SUBSTITUTION]
|
||||
assert len(ps_nodes) == 2
|
||||
|
||||
|
||||
def test_heredoc():
|
||||
node = _first("cat <<EOF\nhello\nEOF")
|
||||
nc = node.named_children
|
||||
has_heredoc = any(c.type == NT.HEREDOC_REDIRECT for c in nc)
|
||||
assert has_heredoc
|
||||
|
||||
|
||||
def test_get_pipeline_single():
|
||||
cmds, stderr = get_pipeline_commands(_first("echo a | grep b"))
|
||||
assert get_command_name(cmds[0]) == "echo"
|
||||
assert get_command_name(cmds[1]) == "grep"
|
||||
|
||||
|
||||
def test_get_subshell_single():
|
||||
body = get_subshell_body(_first("(echo a)"))
|
||||
assert len(body) == 1
|
||||
|
||||
|
||||
def test_export_assignments():
|
||||
assigns = get_declaration_assignments(_first("export A=1 B=2"))
|
||||
assert assigns == ["A=1", "B=2"]
|
||||
|
||||
|
||||
def test_export_keyword():
|
||||
assert get_declaration_keyword(_first("export A=1")) == "export"
|
||||
|
||||
|
||||
def test_local_keyword():
|
||||
assert get_declaration_keyword(_first("local X=hello")) == "local"
|
||||
|
||||
|
||||
def test_declare_assignments():
|
||||
assigns = get_declaration_assignments(_first("declare -i NUM=42"))
|
||||
assert assigns == ["NUM=42"]
|
||||
|
||||
|
||||
def test_unset_names():
|
||||
names = get_unset_names(_first("unset VAR1 VAR2"))
|
||||
assert names == ["VAR1", "VAR2"]
|
||||
|
||||
|
||||
def test_test_command_bracket():
|
||||
argv = get_test_argv(_first("[ -f /data/file ]"))
|
||||
assert len(argv) == 1
|
||||
assert "-f" in argv[0]
|
||||
|
||||
|
||||
def test_test_command_double_bracket():
|
||||
argv = get_test_argv(_first("[[ $x == hello ]]"))
|
||||
assert len(argv) == 1
|
||||
assert "==" in argv[0]
|
||||
|
||||
|
||||
def test_command_prefix_assignments():
|
||||
assigns = get_command_assignments(_first("A=1 B=2 echo hello"))
|
||||
assert assigns == ["A=1", "B=2"]
|
||||
|
||||
|
||||
def test_command_no_assignments():
|
||||
assigns = get_command_assignments(_first("echo hello"))
|
||||
assert assigns == []
|
||||
|
||||
|
||||
# ── negated command ──────────────────────────────
|
||||
|
||||
|
||||
def test_negated_command():
|
||||
node = _first("! echo hello")
|
||||
assert node.type == NT.NEGATED_COMMAND
|
||||
inner = get_negated_command(node)
|
||||
assert get_command_name(inner) == "echo"
|
||||
|
||||
|
||||
# ── heredoc ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_heredoc_parts():
|
||||
node = _first("cat <<EOF\nhello\nworld\nEOF")
|
||||
redirect = node.named_children[1]
|
||||
delim, body = get_heredoc_parts(redirect)
|
||||
assert delim == "EOF"
|
||||
assert "hello" in body
|
||||
assert "world" in body
|
||||
|
||||
|
||||
# ── process substitution ─────────────────────────
|
||||
|
||||
|
||||
def test_process_sub_command():
|
||||
parts = get_parts(_first("diff <(echo a) <(echo b)"))
|
||||
ps_nodes = [p for p in parts if p.type == NT.PROCESS_SUBSTITUTION]
|
||||
assert len(ps_nodes) == 2
|
||||
inner = get_process_sub_command(ps_nodes[0])
|
||||
assert get_command_name(inner) == "echo"
|
||||
|
||||
|
||||
# ── expansion nodes ──────────────────────────────
|
||||
|
||||
|
||||
def test_simple_expansion_in_parts():
|
||||
parts = get_parts(_first("echo $VAR"))
|
||||
assert parts[1].type == NT.SIMPLE_EXPANSION
|
||||
|
||||
|
||||
def test_expansion_with_default():
|
||||
parts = get_parts(_first("echo ${VAR:-default}"))
|
||||
assert parts[1].type == NT.EXPANSION
|
||||
|
||||
|
||||
def test_command_substitution_in_parts():
|
||||
parts = get_parts(_first("echo $(ls /data)"))
|
||||
assert parts[1].type == NT.COMMAND_SUBSTITUTION
|
||||
|
||||
|
||||
def test_arithmetic_in_parts():
|
||||
parts = get_parts(_first("echo $((1 + 2))"))
|
||||
assert parts[1].type == NT.ARITHMETIC_EXPANSION
|
||||
|
||||
|
||||
def test_concatenation_in_parts():
|
||||
parts = get_parts(_first("echo $DIR/file.txt"))
|
||||
assert parts[1].type == NT.CONCATENATION
|
||||
|
||||
|
||||
def test_string_in_parts():
|
||||
parts = get_parts(_first('echo "hello $VAR"'))
|
||||
assert parts[1].type == NT.STRING
|
||||
|
||||
|
||||
def test_raw_string_in_parts():
|
||||
parts = get_parts(_first("echo 'no expansion'"))
|
||||
assert parts[1].type == NT.RAW_STRING
|
||||
|
||||
|
||||
# ── edge cases ───────────────────────────────────
|
||||
|
||||
|
||||
def test_nested_command_substitution():
|
||||
parts = get_parts(_first("echo $(cat $(echo file))"))
|
||||
cs = parts[1]
|
||||
assert cs.type == NT.COMMAND_SUBSTITUTION
|
||||
inner_cmd = cs.named_children[0]
|
||||
inner_parts = get_parts(inner_cmd)
|
||||
assert inner_parts[1].type == NT.COMMAND_SUBSTITUTION
|
||||
|
||||
|
||||
def test_mixed_expansion():
|
||||
parts = get_parts(_first("echo $DIR/$(cmd)/$((1+2))/*.txt"))
|
||||
assert parts[1].type == NT.CONCATENATION
|
||||
|
||||
|
||||
def test_empty_command():
|
||||
root = _parse("")
|
||||
assert len(root.named_children) == 0
|
||||
|
||||
|
||||
def test_redirect_with_pipe():
|
||||
node = _first("echo a | grep b > out.txt")
|
||||
assert node.type == NT.REDIRECTED_STATEMENT
|
||||
cmd, target, append, stream = get_redirect_parts(node)
|
||||
assert cmd.type == NT.PIPELINE
|
||||
assert target == "out.txt"
|
||||
|
||||
|
||||
# ── quotes and escapes ──────────────────────────
|
||||
|
||||
|
||||
def test_double_quoted_string():
|
||||
parts = get_parts(_first('echo "hello world"'))
|
||||
assert parts[1].type == NT.STRING
|
||||
|
||||
|
||||
def test_single_quoted_raw():
|
||||
parts = get_parts(_first("echo 'no $expansion'"))
|
||||
assert parts[1].type == NT.RAW_STRING
|
||||
|
||||
|
||||
def test_quoted_glob_not_expanded():
|
||||
"""Glob inside quotes is string_content, not glob."""
|
||||
parts = get_parts(_first('echo "*.txt"'))
|
||||
assert parts[1].type == NT.STRING
|
||||
content = parts[1].named_children[0]
|
||||
assert content.type == NT.STRING_CONTENT
|
||||
assert get_text(content) == "*.txt"
|
||||
|
||||
|
||||
def test_empty_string():
|
||||
parts = get_parts(_first('echo ""'))
|
||||
assert parts[1].type == NT.STRING
|
||||
assert len(parts[1].named_children) == 0
|
||||
|
||||
|
||||
def test_single_in_double_quotes():
|
||||
parts = get_parts(_first("""echo "she said 'hi'" """))
|
||||
assert parts[1].type == NT.STRING
|
||||
assert "hi" in get_text(parts[1])
|
||||
|
||||
|
||||
def test_mixed_quotes():
|
||||
parts = get_parts(_first("""echo "hello" 'world'"""))
|
||||
assert parts[1].type == NT.STRING
|
||||
assert parts[2].type == NT.RAW_STRING
|
||||
|
||||
|
||||
def test_expansion_in_double_quotes():
|
||||
parts = get_parts(_first('echo "hello $VAR"'))
|
||||
assert parts[1].type == NT.STRING
|
||||
expansions = [
|
||||
c for c in parts[1].named_children if c.type == NT.SIMPLE_EXPANSION
|
||||
]
|
||||
assert len(expansions) == 1
|
||||
|
||||
|
||||
def test_no_expansion_in_single_quotes():
|
||||
parts = get_parts(_first("echo '$VAR'"))
|
||||
assert parts[1].type == NT.RAW_STRING
|
||||
|
||||
|
||||
def test_backslash_in_word():
|
||||
parts = get_parts(_first("echo hello\\\\nworld"))
|
||||
assert "\\n" in get_text(parts[1])
|
||||
|
||||
|
||||
# ── multi-statement bodies ─────────────────────
|
||||
|
||||
|
||||
def test_for_multi_statement_body():
|
||||
_, values, body = get_for_parts(
|
||||
_first("for x in a; do echo 1; echo 2; echo 3; done"))
|
||||
assert len(body) == 3
|
||||
|
||||
|
||||
def test_while_multi_statement_body():
|
||||
cond, body = get_while_parts(_first("while true; do echo a; echo b; done"))
|
||||
assert len(body) == 2
|
||||
|
||||
|
||||
def test_if_multi_statement_then():
|
||||
branches, _ = get_if_branches(
|
||||
_first("if true; then echo a; echo b; echo c; fi"))
|
||||
assert len(branches) == 1
|
||||
cond, body = branches[0]
|
||||
assert len(body) == 3
|
||||
|
||||
|
||||
def test_if_multi_statement_else():
|
||||
branches, else_body = get_if_branches(
|
||||
_first("if true; then echo a; else echo b; echo c; fi"))
|
||||
assert len(branches) == 1
|
||||
assert len(else_body) == 2
|
||||
|
||||
|
||||
def test_if_multi_statement_elif():
|
||||
branches, else_body = get_if_branches(
|
||||
_first("if true; then echo a; "
|
||||
"elif false; then echo b; echo c; "
|
||||
"else echo d; fi"))
|
||||
assert len(branches) == 2
|
||||
assert len(branches[1][1]) == 2
|
||||
assert len(else_body) == 1
|
||||
|
||||
|
||||
def test_function_multi_statement_body():
|
||||
body = get_function_body(_first("f() { echo a; echo b; echo c; }"))
|
||||
assert body is not None
|
||||
assert len(body) == 3
|
||||
|
||||
|
||||
def test_function_single_statement_body():
|
||||
body = get_function_body(_first("f() { echo hello; }"))
|
||||
assert body is not None
|
||||
assert len(body) == 1
|
||||
|
||||
|
||||
def test_select_multi_statement_body():
|
||||
node = _first("select f in a b; do echo 1; echo 2; done")
|
||||
_, _, body = get_for_parts(node)
|
||||
assert len(body) == 2
|
||||
|
||||
|
||||
# ── redirect target types ──────────────────────
|
||||
|
||||
|
||||
def test_redirect_target_concat():
|
||||
node = _first("echo x > $DIR/out.txt")
|
||||
target_node = get_redirect_target_node(node)
|
||||
assert target_node is not None
|
||||
assert target_node.type == NT.CONCATENATION
|
||||
|
||||
|
||||
def test_redirect_target_expansion():
|
||||
node = _first("echo x > $OUT")
|
||||
target_node = get_redirect_target_node(node)
|
||||
assert target_node is not None
|
||||
assert target_node.type == NT.SIMPLE_EXPANSION
|
||||
|
||||
|
||||
def test_redirect_target_cmd_sub():
|
||||
node = _first("echo x > $(echo out.txt)")
|
||||
target_node = get_redirect_target_node(node)
|
||||
assert target_node is not None
|
||||
assert target_node.type == NT.COMMAND_SUBSTITUTION
|
||||
|
||||
|
||||
def test_redirect_target_heredoc_none():
|
||||
node = _first("cat <<EOF\nhello\nEOF")
|
||||
target_node = get_redirect_target_node(node)
|
||||
assert target_node is None
|
||||
|
||||
|
||||
def test_redirect_target_stderr():
|
||||
node = _first("cmd 2> /err.txt")
|
||||
target_node = get_redirect_target_node(node)
|
||||
assert target_node is not None
|
||||
assert get_text(target_node) == "/err.txt"
|
||||
|
||||
|
||||
# ── python command parsing ─────────────────────
|
||||
|
||||
|
||||
def test_python3_c_flag():
|
||||
cmd = _first('python3 -c "print(1)"')
|
||||
assert get_command_name(cmd) == "python3"
|
||||
parts = get_parts(cmd)
|
||||
assert get_text(parts[1]) == "-c"
|
||||
assert parts[2].type == NT.STRING
|
||||
|
||||
|
||||
def test_python3_script_path():
|
||||
cmd = _first("python3 /data/script.py")
|
||||
assert get_command_name(cmd) == "python3"
|
||||
parts = get_parts(cmd)
|
||||
assert get_text(parts[1]) == "/data/script.py"
|
||||
|
||||
|
||||
def test_python3_with_args():
|
||||
cmd = _first("python3 /data/script.py arg1 arg2")
|
||||
parts = get_parts(cmd)
|
||||
assert len(parts) == 4
|
||||
assert get_text(parts[2]) == "arg1"
|
||||
assert get_text(parts[3]) == "arg2"
|
||||
|
||||
|
||||
def test_python3_heredoc():
|
||||
node = _first("python3 <<PYEOF\nprint('hello')\nPYEOF")
|
||||
assert node.type == NT.REDIRECTED_STATEMENT
|
||||
cmd, target, append, stream = get_redirect_parts(node)
|
||||
assert get_command_name(cmd) == "python3"
|
||||
redirect = node.named_children[1]
|
||||
assert redirect.type == NT.HEREDOC_REDIRECT
|
||||
delim, body = get_heredoc_parts(redirect)
|
||||
assert delim == "PYEOF"
|
||||
assert "print" in body
|
||||
|
||||
|
||||
def test_python3_heredoc_quoted_delim():
|
||||
node = _first("python3 <<'SCRIPT'\nprint('hi')\nSCRIPT")
|
||||
redirect = node.named_children[1]
|
||||
delim, body = get_heredoc_parts(redirect)
|
||||
assert "SCRIPT" in delim
|
||||
assert "print" in body
|
||||
|
||||
|
||||
def test_python3_heredoc_double_quoted():
|
||||
node = _first('python3 <<"EOF"\nimport sys\nEOF')
|
||||
redirect = node.named_children[1]
|
||||
delim, body = get_heredoc_parts(redirect)
|
||||
assert "EOF" in delim
|
||||
assert "import sys" in body
|
||||
|
||||
|
||||
def test_python_command_name():
|
||||
cmd = _first("python script.py")
|
||||
assert get_command_name(cmd) == "python"
|
||||
|
||||
|
||||
def test_python3_c_with_expansion():
|
||||
cmd = _first('python3 -c "print($VAR)"')
|
||||
parts = get_parts(cmd)
|
||||
string_node = parts[2]
|
||||
assert string_node.type == NT.STRING
|
||||
expansions = [
|
||||
c for c in string_node.named_children if c.type == NT.SIMPLE_EXPANSION
|
||||
]
|
||||
assert len(expansions) == 1
|
||||
@@ -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. =========
|
||||
|
||||
|
||||
def test_ifs_comma_splits_read(shell):
|
||||
out = shell.mirage('IFS=, read a b c <<< "1,2,3"; echo "$a:$b:$c"')
|
||||
assert out == "1:2:3\n"
|
||||
|
||||
|
||||
def test_ifs_default_whitespace(shell):
|
||||
out = shell.mirage('echo "1 2 3" | { read a b c; echo "$a:$b:$c"; }')
|
||||
assert out == "1:2:3\n"
|
||||
|
||||
|
||||
def test_ifs_prefix_does_not_persist(shell):
|
||||
out = shell.mirage('IFS=, read a b c <<< "1,2,3"; '
|
||||
'echo "${IFS-default}"')
|
||||
assert "," not in out
|
||||
|
||||
|
||||
def test_env_prefix_to_command(shell):
|
||||
out = shell.mirage('FOO=bar bash -c "echo $FOO"')
|
||||
assert "bar" in out
|
||||
|
||||
|
||||
def test_ifs_colon_split(shell):
|
||||
out = shell.mirage('IFS=: read a b c <<< "x:y:z"; echo "$a-$b-$c"')
|
||||
assert out == "x-y-z\n"
|
||||
@@ -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. =========
|
||||
|
||||
|
||||
def test_exit_code_true(shell):
|
||||
cmd = "true; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_false(shell):
|
||||
cmd = "false; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_pipe_last(shell):
|
||||
cmd = "true | false; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_pipe_success(shell):
|
||||
cmd = "false | true; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_and_success(shell):
|
||||
cmd = "true && echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_and_short_circuit(shell):
|
||||
cmd = "false && echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_or_fallback(shell):
|
||||
cmd = "false || echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_or_short_circuit(shell):
|
||||
cmd = "true || echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_and_then_semi(shell):
|
||||
cmd = "true && false; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_or_then_semi(shell):
|
||||
cmd = "false || true; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_if_true(shell):
|
||||
cmd = "if true; then echo $?; fi"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_if_else(shell):
|
||||
cmd = "if false; then echo a; else echo $?; fi"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_semicolon_chain_true(shell):
|
||||
cmd = "false; true; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_semicolon_chain_false(shell):
|
||||
cmd = "true; false; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_if_resets(shell):
|
||||
cmd = "false; if true; then echo $?; fi"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_subshell(shell):
|
||||
cmd = "true; (false); echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_while_no_body(shell):
|
||||
cmd = "false; while false; do echo x; done; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_negation_true(shell):
|
||||
cmd = "! true; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_exit_code_negation_false(shell):
|
||||
cmd = "! false; echo $?"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
@@ -0,0 +1,74 @@
|
||||
# ========= 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.shell.node_kind import NodeKind, node_kind
|
||||
from mirage.shell.parse import parse
|
||||
|
||||
# One snippet per statement kind. The map must cover the full enum
|
||||
# (asserted below), so adding a NodeKind without deciding how it
|
||||
# classifies fails here before it can drift between the walkers.
|
||||
SNIPPETS = {
|
||||
NodeKind.COMMENT: "# a comment",
|
||||
NodeKind.PROGRAM: "true",
|
||||
NodeKind.COMMAND: "cat /data/a.txt",
|
||||
NodeKind.PIPELINE: "cat /data/a.txt | wc -l",
|
||||
NodeKind.LIST: "true && false",
|
||||
NodeKind.REDIRECT: "cat /data/a.txt > /data/b.txt",
|
||||
NodeKind.SUBSHELL: "(true)",
|
||||
NodeKind.COMPOUND: "{ true; }",
|
||||
NodeKind.IF: "if true; then false; fi",
|
||||
NodeKind.FOR: "for i in 1 2; do true; done",
|
||||
NodeKind.SELECT: "select x in a b; do true; done",
|
||||
NodeKind.WHILE: "while true; do false; done",
|
||||
NodeKind.UNTIL: "until false; do true; done",
|
||||
NodeKind.CASE: "case x in x) true;; esac",
|
||||
NodeKind.FUNCTION_DEF: "f() { true; }",
|
||||
NodeKind.DECLARATION: "export FOO=1",
|
||||
NodeKind.UNSET: "unset FOO",
|
||||
NodeKind.TEST: "[[ -n x ]]",
|
||||
NodeKind.NEGATED: "! true",
|
||||
NodeKind.VAR_ASSIGN: "FOO=1",
|
||||
NodeKind.UNSUPPORTED: "for ((i=0;i<2;i++)); do true; done",
|
||||
}
|
||||
|
||||
|
||||
def _first_statement(snippet: str):
|
||||
root = parse(snippet)
|
||||
assert root.type == "program"
|
||||
return root.named_children[0]
|
||||
|
||||
|
||||
def test_snippets_cover_the_full_enum():
|
||||
assert set(SNIPPETS) == set(NodeKind)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", list(NodeKind))
|
||||
def test_node_kind_classifies(kind):
|
||||
node = _first_statement(SNIPPETS[kind])
|
||||
if kind == NodeKind.PROGRAM:
|
||||
node = parse(SNIPPETS[kind])
|
||||
assert node_kind(node) == kind
|
||||
|
||||
|
||||
def test_select_and_until_disambiguate():
|
||||
assert node_kind(_first_statement("select x in a; do true; done")) \
|
||||
== NodeKind.SELECT
|
||||
assert node_kind(_first_statement("for i in a; do true; done")) \
|
||||
== NodeKind.FOR
|
||||
assert node_kind(_first_statement("until false; do true; done")) \
|
||||
== NodeKind.UNTIL
|
||||
assert node_kind(_first_statement("while true; do false; done")) \
|
||||
== NodeKind.WHILE
|
||||
@@ -0,0 +1,94 @@
|
||||
# ========= 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. =========
|
||||
|
||||
|
||||
def test_semicolon(shell):
|
||||
assert shell.mirage("echo hello; echo world") == shell.native(
|
||||
"echo hello; echo world")
|
||||
|
||||
|
||||
def test_and_success(shell):
|
||||
assert shell.mirage("true && echo yes") == shell.native("true && echo yes")
|
||||
|
||||
|
||||
def test_and_failure(shell):
|
||||
assert shell.mirage("false && echo yes") == shell.native(
|
||||
"false && echo yes")
|
||||
|
||||
|
||||
def test_or_success(shell):
|
||||
assert shell.mirage("true || echo fallback") == shell.native(
|
||||
"true || echo fallback")
|
||||
|
||||
|
||||
def test_or_failure(shell):
|
||||
assert shell.mirage("false || echo fallback") == shell.native(
|
||||
"false || echo fallback")
|
||||
|
||||
|
||||
def test_and_or_chain(shell):
|
||||
assert shell.mirage("true && echo a || echo b") == shell.native(
|
||||
"true && echo a || echo b")
|
||||
|
||||
|
||||
def test_and_or_chain_fail(shell):
|
||||
assert shell.mirage("false && echo a || echo b") == shell.native(
|
||||
"false && echo a || echo b")
|
||||
|
||||
|
||||
def test_semicolon_continues_after_failure(shell):
|
||||
assert shell.mirage("false; echo still") == shell.native(
|
||||
"false; echo still")
|
||||
|
||||
|
||||
def test_exit_code_true(shell):
|
||||
assert shell.mirage_exit("true") == 0
|
||||
|
||||
|
||||
def test_exit_code_false(shell):
|
||||
assert shell.mirage_exit("false") == 1
|
||||
|
||||
|
||||
def test_and_chain_three(shell):
|
||||
cmd = "true && true && echo all_true"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_or_chain_three(shell):
|
||||
cmd = "false || false || echo last_resort"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_mixed_and_or_semi(shell):
|
||||
cmd = "echo a; false && echo b; echo c"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_semicolon_three(shell):
|
||||
cmd = "echo a; echo b; echo c"
|
||||
assert shell.mirage(cmd) == shell.native(cmd)
|
||||
|
||||
|
||||
def test_and_with_grep(shell):
|
||||
shell.create_file("f.txt", b"hello\n")
|
||||
m = shell.mirage("grep hello /data/f.txt && echo found")
|
||||
n = shell.native("grep hello f.txt && echo found")
|
||||
assert m == n
|
||||
|
||||
|
||||
def test_or_with_grep(shell):
|
||||
shell.create_file("f.txt", b"hello\n")
|
||||
m = shell.mirage("grep nope /data/f.txt || echo missing")
|
||||
n = shell.native("grep nope f.txt || echo missing")
|
||||
assert m == n
|
||||
@@ -0,0 +1,51 @@
|
||||
# ========= 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
|
||||
|
||||
CASES = [
|
||||
('X=hi; echo "${X:-fallback}"', "hi\n"),
|
||||
('echo "${UNSET:-fallback}"', "fallback\n"),
|
||||
('X=""; echo "${X:-fallback}"', "fallback\n"),
|
||||
('X=""; echo "${X-fallback}"', "\n"),
|
||||
('echo "${UNSET-fallback}"', "fallback\n"),
|
||||
('X=hi; echo "${X:+yes}"', "yes\n"),
|
||||
('echo "${UNSET:+yes}"', "\n"),
|
||||
('X=""; echo "${X:+yes}"', "\n"),
|
||||
('X=""; echo "${X+yes}"', "yes\n"),
|
||||
('X=hello; echo "${#X}"', "5\n"),
|
||||
('X=""; echo "${#X}"', "0\n"),
|
||||
('X=hello; echo "${X:1:3}"', "ell\n"),
|
||||
('X=hello; echo "${X:1}"', "ello\n"),
|
||||
('X=hello; echo "${X: -3}"', "llo\n"),
|
||||
('X=foobar; echo "${X#foo}"', "bar\n"),
|
||||
('X=foobar; echo "${X%bar}"', "foo\n"),
|
||||
('X=a/b/c/d; echo "${X##*/}"', "d\n"),
|
||||
('X=a/b/c/d; echo "${X%%/*}"', "a\n"),
|
||||
('X=a/b/c/d; echo "${X#*/}"', "b/c/d\n"),
|
||||
('X=a/b/c/d; echo "${X%/*}"', "a/b/c\n"),
|
||||
('X=foobarfoo; echo "${X/foo/baz}"', "bazbarfoo\n"),
|
||||
('X=foobarfoo; echo "${X//foo/baz}"', "bazbarbaz\n"),
|
||||
('X=foobar; echo "${X/foo/}"', "bar\n"),
|
||||
('X=hello; echo "${X^^}"', "HELLO\n"),
|
||||
('X=HELLO; echo "${X,,}"', "hello\n"),
|
||||
('X=hello; echo "${X^}"', "Hello\n"),
|
||||
('X=HELLO; echo "${X,}"', "hELLO\n"),
|
||||
('X=hello; Y=X; echo "${!Y}"', "hello\n"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd,expected", CASES)
|
||||
def test_param_expansion(shell, cmd, expected):
|
||||
assert shell.mirage(cmd) == expected
|
||||
@@ -0,0 +1,285 @@
|
||||
# ========= 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 tree_sitter
|
||||
|
||||
from mirage.shell import parse
|
||||
from mirage.shell.helpers import (get_command_name, get_for_parts,
|
||||
get_if_branches, get_list_parts, get_parts,
|
||||
get_pipeline_commands, get_redirect_parts,
|
||||
get_redirects, get_text, get_while_parts)
|
||||
from mirage.shell.types import NodeType as NT
|
||||
from mirage.shell.types import RedirectKind
|
||||
|
||||
|
||||
def test_parse_returns_node():
|
||||
root = parse("echo hello")
|
||||
assert isinstance(root, tree_sitter.Node)
|
||||
|
||||
|
||||
def test_parse_root_is_program():
|
||||
root = parse("echo hello")
|
||||
assert root.type == "program"
|
||||
|
||||
|
||||
def test_simple_command():
|
||||
root = parse("echo hello")
|
||||
cmd = root.named_children[0]
|
||||
assert cmd.type == NT.COMMAND
|
||||
assert get_command_name(cmd) == "echo"
|
||||
|
||||
|
||||
def test_command_with_flags():
|
||||
cmd = parse("grep -n pattern /s3/file").named_children[0]
|
||||
parts = get_parts(cmd)
|
||||
texts = [get_text(p) for p in parts]
|
||||
assert texts == ["grep", "-n", "pattern", "/s3/file"]
|
||||
|
||||
|
||||
def test_pipeline():
|
||||
node = parse("grep p file | sort").named_children[0]
|
||||
assert node.type == NT.PIPELINE
|
||||
cmds, stderr = get_pipeline_commands(node)
|
||||
assert len(cmds) == 2
|
||||
assert stderr == [False]
|
||||
|
||||
|
||||
def test_multi_pipe():
|
||||
node = parse("cat f | grep p | sort | uniq").named_children[0]
|
||||
cmds, stderr = get_pipeline_commands(node)
|
||||
assert len(cmds) == 4
|
||||
assert stderr == [False, False, False]
|
||||
|
||||
|
||||
def test_pipe_stderr():
|
||||
node = parse("cmd1 |& cmd2").named_children[0]
|
||||
cmds, stderr = get_pipeline_commands(node)
|
||||
assert stderr == [True]
|
||||
|
||||
|
||||
def test_list_and():
|
||||
node = parse("cmd1 && cmd2").named_children[0]
|
||||
assert node.type == NT.LIST
|
||||
left, op, right = get_list_parts(node)
|
||||
assert op == NT.AND
|
||||
|
||||
|
||||
def test_list_or():
|
||||
node = parse("cmd1 || cmd2").named_children[0]
|
||||
left, op, right = get_list_parts(node)
|
||||
assert op == NT.OR
|
||||
|
||||
|
||||
def test_semicolon_multiple():
|
||||
root = parse("cmd1; cmd2; cmd3")
|
||||
assert len(root.named_children) == 3
|
||||
|
||||
|
||||
def test_redirect_stdout():
|
||||
node = parse("echo hello > /out.txt").named_children[0]
|
||||
assert node.type == NT.REDIRECTED_STATEMENT
|
||||
cmd, target, append, stream = get_redirect_parts(node)
|
||||
assert target == "/out.txt"
|
||||
assert not append
|
||||
assert stream == RedirectKind.STDOUT
|
||||
|
||||
|
||||
def test_redirect_append():
|
||||
node = parse("echo hello >> /out.txt").named_children[0]
|
||||
_, target, append, stream = get_redirect_parts(node)
|
||||
assert append is True
|
||||
|
||||
|
||||
def test_redirect_stdin():
|
||||
node = parse("sort < /input.txt").named_children[0]
|
||||
_, target, append, stream = get_redirect_parts(node)
|
||||
assert stream == RedirectKind.STDIN
|
||||
|
||||
|
||||
def test_redirect_stderr():
|
||||
node = parse("cmd 2> /err.txt").named_children[0]
|
||||
_, target, append, stream = get_redirect_parts(node)
|
||||
assert stream == RedirectKind.STDERR
|
||||
assert target == "/err.txt"
|
||||
|
||||
|
||||
def test_redirect_on_list_detected():
|
||||
"""tree-sitter parses 'a || echo x > file' with > on the list.
|
||||
|
||||
The executor re-associates this shape to the last command
|
||||
(execute_node NodeKind.REDIRECT), so the parse must keep exposing
|
||||
the list body.
|
||||
"""
|
||||
node = parse("a || echo x > /out.txt").named_children[0]
|
||||
assert node.type == NT.REDIRECTED_STATEMENT
|
||||
body, redirects = get_redirects(node)
|
||||
assert body.type == NT.LIST
|
||||
assert len(redirects) == 1
|
||||
|
||||
|
||||
def test_redirect_on_and_chain_detected():
|
||||
"""tree-sitter hoists > from 'a && echo x > file'."""
|
||||
node = parse("a && echo x > /out.txt").named_children[0]
|
||||
body, redirects = get_redirects(node)
|
||||
assert body.type == NT.LIST
|
||||
assert len(redirects) == 1
|
||||
|
||||
|
||||
def test_redirect_on_simple_command_not_list():
|
||||
"""Normal redirect on a command is not a list redirect."""
|
||||
node = parse("echo hello > /out.txt").named_children[0]
|
||||
body, redirects = get_redirects(node)
|
||||
assert body.type == NT.COMMAND
|
||||
assert len(redirects) == 1
|
||||
|
||||
|
||||
def test_subshell():
|
||||
node = parse("(grep p file | sort)").named_children[0]
|
||||
assert node.type == NT.SUBSHELL
|
||||
|
||||
|
||||
def test_if_simple():
|
||||
node = parse("if true; then echo yes; fi").named_children[0]
|
||||
assert node.type == NT.IF_STATEMENT
|
||||
branches, else_body = get_if_branches(node)
|
||||
assert len(branches) == 1
|
||||
assert else_body is None
|
||||
|
||||
|
||||
def test_if_else():
|
||||
node = parse("if true; then echo yes; else echo no; fi").named_children[0]
|
||||
branches, else_body = get_if_branches(node)
|
||||
assert else_body is not None
|
||||
|
||||
|
||||
def test_if_elif_else():
|
||||
node = parse("if true; then echo a; elif false; then echo b; "
|
||||
"else echo c; fi").named_children[0]
|
||||
branches, else_body = get_if_branches(node)
|
||||
assert len(branches) == 2
|
||||
assert else_body is not None
|
||||
|
||||
|
||||
def test_for_loop():
|
||||
node = parse("for x in a b c; do echo; done").named_children[0]
|
||||
assert node.type == NT.FOR_STATEMENT
|
||||
var, values, body = get_for_parts(node)
|
||||
assert var == "x"
|
||||
assert [get_text(v) for v in values] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_while_loop():
|
||||
node = parse("while true; do echo loop; done").named_children[0]
|
||||
assert node.type == NT.WHILE_STATEMENT
|
||||
cond, body = get_while_parts(node)
|
||||
assert get_text(cond) == "true"
|
||||
|
||||
|
||||
def test_until_loop():
|
||||
node = parse("until false; do echo loop; done").named_children[0]
|
||||
assert node.type == NT.WHILE_STATEMENT
|
||||
assert node.children[0].type == NT.UNTIL
|
||||
|
||||
|
||||
def test_select():
|
||||
node = parse("select opt in a b c; do echo; done").named_children[0]
|
||||
assert node.type == NT.FOR_STATEMENT
|
||||
assert node.children[0].type == NT.SELECT
|
||||
var, values, body = get_for_parts(node)
|
||||
assert var == "opt"
|
||||
assert [get_text(v) for v in values] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_case():
|
||||
node = parse("case $x in a) echo A;; b) echo B;; esac").named_children[0]
|
||||
assert node.type == NT.CASE_STATEMENT
|
||||
|
||||
|
||||
def test_function():
|
||||
node = parse("foo() { echo hello; }").named_children[0]
|
||||
assert node.type == NT.FUNCTION_DEFINITION
|
||||
|
||||
|
||||
def test_export():
|
||||
node = parse("export FOO=bar").named_children[0]
|
||||
assert node.type == NT.DECLARATION_COMMAND
|
||||
|
||||
|
||||
def test_unset():
|
||||
node = parse("unset FOO").named_children[0]
|
||||
assert node.type == NT.UNSET_COMMAND
|
||||
|
||||
|
||||
def test_test_bracket():
|
||||
node = parse("[ -f /file ]").named_children[0]
|
||||
assert node.type == NT.TEST_COMMAND
|
||||
|
||||
|
||||
def test_test_double_bracket():
|
||||
node = parse("[[ -f /file ]]").named_children[0]
|
||||
assert node.type == NT.TEST_COMMAND
|
||||
|
||||
|
||||
def test_background():
|
||||
root = parse("cmd &")
|
||||
has_bg = any(c.type == NT.BACKGROUND for c in root.children)
|
||||
assert has_bg
|
||||
|
||||
|
||||
def test_empty():
|
||||
root = parse("")
|
||||
assert len(root.named_children) == 0
|
||||
|
||||
|
||||
def test_preserves_expansions():
|
||||
cmd = parse("echo $VAR $(cmd) $((1+2))").named_children[0]
|
||||
types = {c.type for c in cmd.named_children}
|
||||
assert NT.SIMPLE_EXPANSION in types
|
||||
assert NT.COMMAND_SUBSTITUTION in types
|
||||
assert NT.ARITHMETIC_EXPANSION in types
|
||||
|
||||
|
||||
def test_preserves_quotes():
|
||||
cmd = parse('echo "hello" \'world\'').named_children[0]
|
||||
types = [c.type for c in cmd.named_children if c.type != NT.COMMAND_NAME]
|
||||
assert NT.STRING in types
|
||||
assert NT.RAW_STRING in types
|
||||
|
||||
|
||||
def test_complex_command():
|
||||
root = parse("for f in $(ls /data/); do "
|
||||
"cat $f | grep error > /out/$f; done")
|
||||
assert root.named_children[0].type == NT.FOR_STATEMENT
|
||||
|
||||
|
||||
def test_chained_and_or():
|
||||
node = parse("cmd1 && cmd2 || cmd3").named_children[0]
|
||||
assert node.type == NT.LIST
|
||||
|
||||
|
||||
def test_heredoc():
|
||||
node = parse("cat <<EOF\nhello\nEOF").named_children[0]
|
||||
assert node.type == NT.REDIRECTED_STATEMENT
|
||||
|
||||
|
||||
def test_process_substitution():
|
||||
cmd = parse("diff <(sort a) <(sort b)").named_children[0]
|
||||
parts = get_parts(cmd)
|
||||
ps = [p for p in parts if p.type == NT.PROCESS_SUBSTITUTION]
|
||||
assert len(ps) == 2
|
||||
|
||||
|
||||
def test_negated_command():
|
||||
node = parse("! echo hello").named_children[0]
|
||||
assert node.type == NT.NEGATED_COMMAND
|
||||
@@ -0,0 +1,35 @@
|
||||
# ========= 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. =========
|
||||
|
||||
|
||||
def test_pipefail_off_default(shell):
|
||||
assert shell.mirage("false | true; echo $?") == "0\n"
|
||||
|
||||
|
||||
def test_pipefail_on_propagates_failure(shell):
|
||||
assert shell.mirage("set -o pipefail; false | true; echo $?") == "1\n"
|
||||
|
||||
|
||||
def test_pipefail_zero_when_all_pass(shell):
|
||||
assert shell.mirage("set -o pipefail; true | true; echo $?") == "0\n"
|
||||
|
||||
|
||||
def test_pipefail_disabled_via_plus_o(shell):
|
||||
assert shell.mirage("set -o pipefail; set +o pipefail; false | true; "
|
||||
"echo $?") == "0\n"
|
||||
|
||||
|
||||
def test_pipefail_rightmost_failure(shell):
|
||||
assert shell.mirage(
|
||||
"set -o pipefail; false | false | true; echo $?") == "1\n"
|
||||
@@ -0,0 +1,255 @@
|
||||
# ========= 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. =========
|
||||
"""Coverage matrix for shell quoting / escaping edge cases.
|
||||
|
||||
Each test is one realistic agent pattern. Failures here surface as
|
||||
either parser bugs, classifier bugs (TEXT vs PATH), or
|
||||
expansion-time bugs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
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 _ws_with_paths():
|
||||
ram = RAMResource()
|
||||
ram._store.files["/plain.txt"] = b"plain content\n"
|
||||
ram._store.files["/my folder/note.txt"] = b"in spaced folder\n"
|
||||
ram._store.files["/my folder/My File.txt"] = b"camelcase with space\n"
|
||||
ram._store.files["/file's copy.txt"] = b"with apostrophe\n"
|
||||
ram._store.files["/数据/中文.txt"] = b"unicode path content\n"
|
||||
ram._store.dirs.add("/my folder")
|
||||
ram._store.dirs.add("/数据")
|
||||
ws = Workspace(resources={"/data/": (ram, MountMode.WRITE)}, )
|
||||
ws.get_session(DEFAULT_SESSION_ID).cwd = "/data"
|
||||
return ws
|
||||
|
||||
|
||||
def _stdout(io) -> bytes:
|
||||
if io.stdout is None:
|
||||
return b""
|
||||
if isinstance(io.stdout, bytes):
|
||||
return io.stdout
|
||||
return b""
|
||||
|
||||
|
||||
def _exec(ws, cmd, **kw):
|
||||
return _run(ws.execute(cmd, **kw))
|
||||
|
||||
|
||||
# ── paths with spaces ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_single_quoted_path_with_space():
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "cat '/data/my folder/note.txt'")
|
||||
assert _stdout(io) == b"in spaced folder\n"
|
||||
|
||||
|
||||
def test_double_quoted_path_with_space():
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, 'cat "/data/my folder/note.txt"')
|
||||
assert _stdout(io) == b"in spaced folder\n"
|
||||
|
||||
|
||||
def test_ls_directory_with_space():
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "ls '/data/my folder/'")
|
||||
assert b"note.txt" in _stdout(io)
|
||||
|
||||
|
||||
def test_find_name_pattern_with_space():
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "find /data -name 'My File.txt'")
|
||||
assert b"My File.txt" in _stdout(io)
|
||||
|
||||
|
||||
# ── paths with special chars ───────────────────────────────
|
||||
|
||||
|
||||
def test_double_quoted_path_with_apostrophe():
|
||||
"""`cat "/data/file's copy.txt"` — apostrophe inside double quotes."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, 'cat "/data/file\'s copy.txt"')
|
||||
assert _stdout(io) == b"with apostrophe\n"
|
||||
|
||||
|
||||
# ── unicode in paths ───────────────────────────────────────
|
||||
|
||||
|
||||
def test_unicode_path():
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "cat '/data/数据/中文.txt'")
|
||||
assert _stdout(io) == b"unicode path content\n"
|
||||
|
||||
|
||||
def test_unicode_directory_listing():
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "ls /data/数据/")
|
||||
assert b"\xe4\xb8\xad\xe6\x96\x87.txt" in _stdout(io) or \
|
||||
"中文.txt".encode() in _stdout(io)
|
||||
|
||||
|
||||
# ── env vars in paths ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_env_var_in_double_quoted_path():
|
||||
ws = _ws_with_paths()
|
||||
_exec(ws, "export DIR=/data")
|
||||
io = _exec(ws, 'cat "$DIR/plain.txt"')
|
||||
assert _stdout(io) == b"plain content\n"
|
||||
|
||||
|
||||
def test_env_var_braced_in_double_quoted_path():
|
||||
ws = _ws_with_paths()
|
||||
_exec(ws, "export DIR=/data")
|
||||
io = _exec(ws, 'cat "${DIR}/plain.txt"')
|
||||
assert _stdout(io) == b"plain content\n"
|
||||
|
||||
|
||||
def test_env_var_in_single_quoted_path_not_expanded():
|
||||
"""Single quotes preserve $VAR literally — should fail to find file."""
|
||||
ws = _ws_with_paths()
|
||||
_exec(ws, "export DIR=/data")
|
||||
io = _exec(ws, "cat '$DIR/plain.txt'")
|
||||
# Either non-zero exit OR empty stdout (file not found)
|
||||
assert io.exit_code != 0 or _stdout(io) == b""
|
||||
|
||||
|
||||
# ── command substitution in args ──────────────────────────
|
||||
|
||||
|
||||
def test_command_sub_as_path():
|
||||
ws = _ws_with_paths()
|
||||
_exec(ws, "echo /data/plain.txt > /data/path.txt")
|
||||
io = _exec(ws, "cat $(cat /data/path.txt)")
|
||||
assert _stdout(io) == b"plain content\n"
|
||||
|
||||
|
||||
def test_command_sub_in_grep_pattern():
|
||||
ws = _ws_with_paths()
|
||||
_exec(ws, "echo plain > /data/needle.txt")
|
||||
io = _exec(ws, 'grep "$(cat /data/needle.txt)" /data/plain.txt')
|
||||
assert b"plain content" in _stdout(io)
|
||||
|
||||
|
||||
# ── escaping ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_escaped_dollar_in_double_quotes():
|
||||
r"""`echo "\$PATH"` should print literal $PATH, not expand it."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, 'echo "\\$PATH"')
|
||||
assert _stdout(io).strip() == b"$PATH"
|
||||
|
||||
|
||||
def test_single_quoted_dollar_literal():
|
||||
"""`echo '$PATH'` — single quotes, no expansion."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "echo '$PATH'")
|
||||
assert _stdout(io).strip() == b"$PATH"
|
||||
|
||||
|
||||
def test_double_quoted_var_expanded():
|
||||
"""`echo "$X"` — var expanded inside double quotes."""
|
||||
ws = _ws_with_paths()
|
||||
_exec(ws, "export X=hello")
|
||||
io = _exec(ws, 'echo "$X"')
|
||||
assert _stdout(io).strip() == b"hello"
|
||||
|
||||
|
||||
# ── unquoted backslash escapes (POSIX §2.2.1) ──────────────
|
||||
|
||||
|
||||
def test_close_escape_open_single_quote():
|
||||
"""`echo 'a'\\''b'` — POSIX close-escape-open trick → literal a'b."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "echo 'a'\\''b'")
|
||||
assert _stdout(io).strip() == b"a'b"
|
||||
|
||||
|
||||
def test_escaped_space_in_path():
|
||||
r"""`cat /data/my\ folder/note.txt` — backslash-escaped space."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "cat /data/my\\ folder/note.txt")
|
||||
assert _stdout(io) == b"in spaced folder\n"
|
||||
|
||||
|
||||
def test_unquoted_escaped_dollar():
|
||||
r"""`echo \$PATH` — unquoted `\$` is literal `$`."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "echo \\$PATH")
|
||||
assert _stdout(io).strip() == b"$PATH"
|
||||
|
||||
|
||||
def test_unquoted_escaped_backslash():
|
||||
r"""`echo \\` — unquoted `\\` is one literal backslash."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "echo \\\\")
|
||||
assert _stdout(io) == b"\\\n"
|
||||
|
||||
|
||||
def test_unquoted_backslash_n_is_literal_n():
|
||||
r"""`echo foo\nbar` — `\n` outside quotes is literal `n`, not newline."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, "echo foo\\nbar")
|
||||
assert _stdout(io).strip() == b"foonbar"
|
||||
|
||||
|
||||
# ── edge cases ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_empty_string_arg():
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, 'echo ""')
|
||||
assert _stdout(io) == b"\n"
|
||||
|
||||
|
||||
def test_consecutive_quoted_strings():
|
||||
"""`echo "a""b"` should produce `ab` (concatenation)."""
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, 'echo "a""b"')
|
||||
assert _stdout(io).strip() == b"ab"
|
||||
|
||||
|
||||
def test_grep_pattern_with_escaped_quote():
|
||||
"""`grep "she said \\"hi\\"" file` — literal embedded double quote."""
|
||||
ws = _ws_with_paths()
|
||||
ram = ws.mount("/data/").resource
|
||||
ram._store.files['/quote.txt'] = b'she said "hi"\n'
|
||||
io = _exec(ws, 'grep "she said \\"hi\\"" /data/quote.txt')
|
||||
assert b"hi" in _stdout(io)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_text,expected",
|
||||
[
|
||||
("hello world", b"hello world\n"),
|
||||
# single quotes are literal inside double quotes (bash behavior)
|
||||
("'inner'", b"'inner'\n"),
|
||||
("$NONEXISTENT", b"\n"),
|
||||
])
|
||||
def test_echo_quoting_matrix(input_text, expected):
|
||||
ws = _ws_with_paths()
|
||||
io = _exec(ws, f'echo "{input_text}"')
|
||||
assert _stdout(io) == expected
|
||||
@@ -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. =========
|
||||
|
||||
|
||||
def test_readonly_blocks_reassignment(shell):
|
||||
out = shell.mirage("readonly X=1; X=2; echo $X")
|
||||
assert "1" in out
|
||||
assert "2" not in out
|
||||
|
||||
|
||||
def test_readonly_blocks_unset(shell):
|
||||
out = shell.mirage("readonly X=5; unset X; echo $X")
|
||||
assert out == "5\n"
|
||||
|
||||
|
||||
def test_declare_r_blocks_reassignment(shell):
|
||||
out = shell.mirage("declare -r Y=5; Y=10; echo $Y")
|
||||
assert out == "5\n"
|
||||
|
||||
|
||||
def test_readonly_emits_error(shell):
|
||||
code = shell.mirage_exit("readonly X=1; X=2")
|
||||
assert code != 0
|
||||
|
||||
|
||||
def test_readonly_first_assignment_succeeds(shell):
|
||||
assert shell.mirage("readonly X=hello; echo $X") == "hello\n"
|
||||
@@ -0,0 +1,59 @@
|
||||
# ========= 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. =========
|
||||
|
||||
|
||||
def test_set_e_exits_on_failure(shell):
|
||||
out = shell.mirage("set -e; false; echo unreached")
|
||||
assert "unreached" not in out
|
||||
|
||||
|
||||
def test_set_e_continues_when_passes(shell):
|
||||
out = shell.mirage("set -e; true; echo ok")
|
||||
assert out == "ok\n"
|
||||
|
||||
|
||||
def test_set_e_allows_or_chain(shell):
|
||||
out = shell.mirage("set -e; false || echo recovered; echo done")
|
||||
assert out == "recovered\ndone\n"
|
||||
|
||||
|
||||
def test_set_e_allows_and_chain_skip(shell):
|
||||
out = shell.mirage("set -e; false && echo skipped; echo done")
|
||||
assert out == "done\n"
|
||||
|
||||
|
||||
def test_set_e_allows_if_condition(shell):
|
||||
out = shell.mirage("set -e; if false; then echo a; else echo b; fi")
|
||||
assert out == "b\n"
|
||||
|
||||
|
||||
def test_set_e_allows_while_condition(shell):
|
||||
out = shell.mirage("set -e; X=0; while [ $X -lt 2 ]; do echo $X; "
|
||||
"X=$((X+1)); done; echo done")
|
||||
assert out == "0\n1\ndone\n"
|
||||
|
||||
|
||||
def test_set_plus_e_disables(shell):
|
||||
out = shell.mirage("set -e; set +e; false; echo ok")
|
||||
assert out == "ok\n"
|
||||
|
||||
|
||||
def test_set_o_errexit_alias(shell):
|
||||
out = shell.mirage("set -o errexit; false; echo unreached")
|
||||
assert "unreached" not in out
|
||||
|
||||
|
||||
def test_set_e_pipeline_failure(shell):
|
||||
out = shell.mirage("set -e; false | true; echo after")
|
||||
assert out == "after\n"
|
||||
@@ -0,0 +1,26 @@
|
||||
# ========= 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. =========
|
||||
|
||||
|
||||
def test_echo(shell):
|
||||
assert shell.mirage("echo hello") == shell.native("echo hello")
|
||||
|
||||
|
||||
def test_echo_multiple(shell):
|
||||
assert shell.mirage("echo hello world") == shell.native("echo hello world")
|
||||
|
||||
|
||||
def test_cat_file(shell):
|
||||
shell.create_file("f.txt", b"content\n")
|
||||
assert shell.mirage("cat /data/f.txt") == shell.native("cat f.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. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.shell.parse import find_syntax_error, parse
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_cmd", [
|
||||
"if then fi",
|
||||
"echo (",
|
||||
"for x do done",
|
||||
"for",
|
||||
"if",
|
||||
"if; fi",
|
||||
'echo "unterm',
|
||||
])
|
||||
def test_find_syntax_error_detects_error_nodes(bad_cmd):
|
||||
ast = parse(bad_cmd)
|
||||
snippet = find_syntax_error(ast)
|
||||
assert snippet is not None, (
|
||||
f"expected syntax error for {bad_cmd!r}, got None")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("good_cmd", [
|
||||
"echo hi",
|
||||
"for x in a b; do echo $x; done",
|
||||
"if true; then echo y; fi",
|
||||
"cat /tmp/x | sort",
|
||||
])
|
||||
def test_find_syntax_error_returns_none_for_valid(good_cmd):
|
||||
assert find_syntax_error(parse(good_cmd)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_cmd", [
|
||||
"if then fi",
|
||||
"echo (",
|
||||
"for x do done",
|
||||
])
|
||||
def test_execute_returns_clear_syntax_error(bad_cmd):
|
||||
ws = Workspace({"/data": RAMResource()})
|
||||
io = asyncio.run(ws.execute(bad_cmd))
|
||||
assert io.exit_code == 2, (
|
||||
f"expected exit 2 for {bad_cmd!r}, got {io.exit_code}")
|
||||
stderr = io.stderr or b""
|
||||
assert b"syntax error" in stderr, (
|
||||
f"expected 'syntax error' in stderr for {bad_cmd!r}, got {stderr!r}")
|
||||
@@ -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. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", [
|
||||
"bg",
|
||||
"disown",
|
||||
"exec",
|
||||
"complete",
|
||||
"compgen",
|
||||
"ulimit",
|
||||
])
|
||||
def test_unsupported_builtin_returns_clear_error(name):
|
||||
ws = Workspace({"/data": RAMResource()})
|
||||
io = asyncio.run(ws.execute(name))
|
||||
assert io.exit_code == 2, (
|
||||
f"expected exit 2 for {name!r}, got {io.exit_code}")
|
||||
stderr = io.stderr or b""
|
||||
assert f"unsupported builtin: {name}".encode() in stderr, (
|
||||
f"expected 'unsupported builtin: {name}' in stderr, got {stderr!r}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"echo hi >(cat)",
|
||||
"tee >(grep foo) /tmp/out",
|
||||
"cat >(wc)",
|
||||
])
|
||||
def test_output_process_substitution_unsupported(cmd):
|
||||
ws = Workspace({"/data": RAMResource()})
|
||||
io = asyncio.run(ws.execute(cmd))
|
||||
assert io.exit_code == 2, (
|
||||
f"expected exit 2 for {cmd!r}, got {io.exit_code}")
|
||||
stderr = io.stderr or b""
|
||||
assert b"process substitution" in stderr, (
|
||||
f"expected 'process substitution' in stderr, got {stderr!r}")
|
||||
assert b"unsupported" in stderr
|
||||
|
||||
|
||||
def test_input_process_substitution_still_works():
|
||||
ram = RAMResource()
|
||||
ram._store.files["/a"] = b"line1\nline2\n"
|
||||
ws = Workspace({"/data": (ram, MountMode.READ)})
|
||||
io = asyncio.run(ws.execute("cat <(cat /data/a)"))
|
||||
assert io.exit_code == 0, (
|
||||
f"input process substitution should still work, got {io}")
|
||||
Reference in New Issue
Block a user