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,67 @@
|
||||
# ========= 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.accessor.hf_buckets import HfBucketsAccessor, HfBucketsConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
cfg = HfBucketsConfig(bucket="myorg/mybkt")
|
||||
assert cfg.bucket == "myorg/mybkt"
|
||||
assert cfg.namespace == "myorg"
|
||||
assert cfg.bucket_name == "mybkt"
|
||||
assert cfg.token is None
|
||||
assert cfg.endpoint == "https://huggingface.co"
|
||||
assert cfg.timeout == 30
|
||||
assert cfg.key_prefix is None
|
||||
|
||||
|
||||
def test_config_immutable():
|
||||
cfg = HfBucketsConfig(bucket="myorg/mybkt")
|
||||
with pytest.raises(Exception):
|
||||
cfg.bucket = "other/other"
|
||||
|
||||
|
||||
def test_config_rejects_bad_bucket_format():
|
||||
with pytest.raises(ValueError):
|
||||
HfBucketsConfig(bucket="just-one-segment")
|
||||
with pytest.raises(ValueError):
|
||||
HfBucketsConfig(bucket="too/many/slashes")
|
||||
with pytest.raises(ValueError):
|
||||
HfBucketsConfig(bucket="/leading")
|
||||
|
||||
|
||||
def test_config_token_secret():
|
||||
cfg = HfBucketsConfig(bucket="myorg/mybkt", token="hf_abc123")
|
||||
assert reveal_secret(cfg.token) == "hf_abc123"
|
||||
assert "hf_abc123" not in repr(cfg)
|
||||
|
||||
|
||||
def test_accessor_holds_config():
|
||||
cfg = HfBucketsConfig(bucket="myorg/mybkt")
|
||||
acc = HfBucketsAccessor(cfg)
|
||||
assert acc.config is cfg
|
||||
|
||||
|
||||
def test_bucket_uri():
|
||||
cfg = HfBucketsConfig(bucket="myorg/mybkt")
|
||||
acc = HfBucketsAccessor(cfg)
|
||||
assert acc.bucket_uri == "hf://buckets/myorg/mybkt"
|
||||
|
||||
|
||||
def test_key_prefix_normalized():
|
||||
cfg = HfBucketsConfig(bucket="myorg/mybkt", key_prefix="/data/sub/")
|
||||
assert cfg.key_prefix == "data/sub/"
|
||||
@@ -0,0 +1,54 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.hf_datasets import HfDatasetsAccessor, HfDatasetsConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
cfg = HfDatasetsConfig(repo_id="org/dataset")
|
||||
assert cfg.repo_id == "org/dataset"
|
||||
assert cfg.namespace == "org"
|
||||
assert cfg.repo_name == "dataset"
|
||||
assert cfg.token is None
|
||||
assert cfg.endpoint == "https://huggingface.co"
|
||||
assert cfg.key_prefix is None
|
||||
assert cfg.revision is None
|
||||
|
||||
|
||||
def test_config_rejects_bad_repo_id():
|
||||
with pytest.raises(ValueError):
|
||||
HfDatasetsConfig(repo_id="just-one-segment")
|
||||
with pytest.raises(ValueError):
|
||||
HfDatasetsConfig(repo_id="too/many/slashes")
|
||||
|
||||
|
||||
def test_config_token_secret():
|
||||
cfg = HfDatasetsConfig(repo_id="org/dataset", token="hf_abc123")
|
||||
assert reveal_secret(cfg.token) == "hf_abc123"
|
||||
assert "hf_abc123" not in repr(cfg)
|
||||
|
||||
|
||||
def test_accessor_repo_type_and_uri():
|
||||
cfg = HfDatasetsConfig(repo_id="org/dataset")
|
||||
acc = HfDatasetsAccessor(cfg)
|
||||
assert acc.REPO_TYPE == "dataset"
|
||||
assert acc.bucket_uri == "hf://datasets/org/dataset"
|
||||
|
||||
|
||||
def test_key_prefix_normalized():
|
||||
cfg = HfDatasetsConfig(repo_id="org/dataset", key_prefix="/data/sub/")
|
||||
assert cfg.key_prefix == "data/sub/"
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.hf_models import HfModelsAccessor, HfModelsConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
cfg = HfModelsConfig(repo_id="org/model")
|
||||
assert cfg.repo_id == "org/model"
|
||||
assert cfg.namespace == "org"
|
||||
assert cfg.repo_name == "model"
|
||||
assert cfg.token is None
|
||||
assert cfg.endpoint == "https://huggingface.co"
|
||||
assert cfg.revision is None
|
||||
|
||||
|
||||
def test_config_rejects_bad_repo_id():
|
||||
with pytest.raises(ValueError):
|
||||
HfModelsConfig(repo_id="just-one-segment")
|
||||
|
||||
|
||||
def test_config_token_secret():
|
||||
cfg = HfModelsConfig(repo_id="org/model", token="hf_abc123")
|
||||
assert reveal_secret(cfg.token) == "hf_abc123"
|
||||
|
||||
|
||||
def test_accessor_repo_type_and_uri():
|
||||
cfg = HfModelsConfig(repo_id="org/model")
|
||||
acc = HfModelsAccessor(cfg)
|
||||
assert acc.REPO_TYPE == "model"
|
||||
assert acc.bucket_uri == "hf://models/org/model"
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.hf_spaces import HfSpacesAccessor, HfSpacesConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
def test_config_defaults():
|
||||
cfg = HfSpacesConfig(repo_id="org/space")
|
||||
assert cfg.repo_id == "org/space"
|
||||
assert cfg.namespace == "org"
|
||||
assert cfg.repo_name == "space"
|
||||
assert cfg.token is None
|
||||
assert cfg.endpoint == "https://huggingface.co"
|
||||
assert cfg.revision is None
|
||||
|
||||
|
||||
def test_config_rejects_bad_repo_id():
|
||||
with pytest.raises(ValueError):
|
||||
HfSpacesConfig(repo_id="just-one-segment")
|
||||
|
||||
|
||||
def test_config_token_secret():
|
||||
cfg = HfSpacesConfig(repo_id="org/space", token="hf_abc123")
|
||||
assert reveal_secret(cfg.token) == "hf_abc123"
|
||||
|
||||
|
||||
def test_accessor_repo_type_and_uri():
|
||||
cfg = HfSpacesConfig(repo_id="org/space")
|
||||
acc = HfSpacesAccessor(cfg)
|
||||
assert acc.REPO_TYPE == "space"
|
||||
assert acc.bucket_uri == "hf://spaces/org/space"
|
||||
@@ -0,0 +1,63 @@
|
||||
# ========= 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 unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pymongo.driver_info import DriverInfo
|
||||
|
||||
from mirage.accessor.mongodb import _DRIVER_INFO, MongoDBAccessor
|
||||
from mirage.resource.mongodb.config import MongoDBConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return MongoDBAccessor(config=MongoDBConfig(
|
||||
uri="mongodb://localhost:27017"))
|
||||
|
||||
|
||||
def test_driver_info_is_pymongo_driverinfo_named_mirage():
|
||||
assert isinstance(_DRIVER_INFO, DriverInfo)
|
||||
assert _DRIVER_INFO.name == "Mirage"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_constructs_async_mongo_client_with_driver_info(accessor):
|
||||
sentinel = MagicMock()
|
||||
with patch("mirage.accessor.mongodb.AsyncMongoClient",
|
||||
return_value=sentinel) as ctor:
|
||||
client = accessor.client
|
||||
assert client is sentinel
|
||||
ctor.assert_called_once_with("mongodb://localhost:27017",
|
||||
driver=_DRIVER_INFO)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_is_cached_per_event_loop(accessor):
|
||||
with patch("mirage.accessor.mongodb.AsyncMongoClient",
|
||||
side_effect=lambda *a, **k: MagicMock()) as ctor:
|
||||
first = accessor.client
|
||||
second = accessor.client
|
||||
assert first is second
|
||||
ctor.assert_called_once()
|
||||
|
||||
|
||||
def test_client_built_outside_event_loop_uses_loopless_key(accessor):
|
||||
with patch("mirage.accessor.mongodb.AsyncMongoClient",
|
||||
side_effect=lambda *a, **k: MagicMock()) as ctor:
|
||||
first = accessor.client
|
||||
second = accessor.client
|
||||
assert first is second
|
||||
ctor.assert_called_once()
|
||||
assert 0 in accessor._clients
|
||||
@@ -0,0 +1,20 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import importlib.util
|
||||
|
||||
collect_ignore_glob: list[str] = []
|
||||
|
||||
if importlib.util.find_spec("camel") is None:
|
||||
collect_ignore_glob = ["test_*.py"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.agents.camel._async import AsyncRunner
|
||||
|
||||
|
||||
def test_run_from_sync_with_no_loop():
|
||||
runner = AsyncRunner()
|
||||
|
||||
async def coro():
|
||||
await asyncio.sleep(0)
|
||||
return 42
|
||||
|
||||
assert runner.run(coro()) == 42
|
||||
runner.close()
|
||||
|
||||
|
||||
def test_run_returns_exception():
|
||||
runner = AsyncRunner()
|
||||
|
||||
async def boom():
|
||||
raise ValueError("nope")
|
||||
|
||||
with pytest.raises(ValueError, match="nope"):
|
||||
runner.run(boom())
|
||||
runner.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_from_inside_running_loop():
|
||||
runner = AsyncRunner()
|
||||
|
||||
async def coro():
|
||||
await asyncio.sleep(0)
|
||||
return "from-loop"
|
||||
|
||||
result = await asyncio.to_thread(runner.run, coro())
|
||||
assert result == "from-loop"
|
||||
runner.close()
|
||||
@@ -0,0 +1,67 @@
|
||||
# ========= 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 inspect
|
||||
|
||||
from camel.toolkits import BaseToolkit, FileToolkit, FunctionTool
|
||||
|
||||
|
||||
def test_base_toolkit_has_get_tools():
|
||||
assert hasattr(BaseToolkit, "get_tools")
|
||||
|
||||
|
||||
def test_file_toolkit_private_hooks_exist():
|
||||
expected = [
|
||||
"_resolve_filepath",
|
||||
"_resolve_existing_filepath",
|
||||
"_resolve_search_path",
|
||||
"_sanitize_filename",
|
||||
"_create_backup",
|
||||
"_write_text_file",
|
||||
"_write_simple_text_file",
|
||||
"_write_csv_file",
|
||||
"_write_json_file",
|
||||
"_write_docx_file",
|
||||
"_write_pdf_file",
|
||||
"_normalize_notebook_source",
|
||||
"_build_notebook_cell",
|
||||
]
|
||||
missing = [name for name in expected if not hasattr(FileToolkit, name)]
|
||||
assert not missing, f"FileToolkit missing hooks: {missing}"
|
||||
|
||||
|
||||
def test_file_toolkit_public_methods_signatures():
|
||||
public = [
|
||||
"write_to_file",
|
||||
"read_file",
|
||||
"edit_file",
|
||||
"search_files",
|
||||
"notebook_edit_cell",
|
||||
"glob_files",
|
||||
"grep_files",
|
||||
]
|
||||
missing = [name for name in public if not hasattr(FileToolkit, name)]
|
||||
assert not missing, f"FileToolkit missing public methods: {missing}"
|
||||
for name in public:
|
||||
sig = inspect.signature(getattr(FileToolkit, name))
|
||||
assert "self" in sig.parameters
|
||||
|
||||
|
||||
def test_function_tool_constructible():
|
||||
|
||||
def sample() -> str:
|
||||
return "ok"
|
||||
|
||||
tool = FunctionTool(sample)
|
||||
assert tool is not None
|
||||
@@ -0,0 +1,99 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.agents.camel import MirageFileToolkit
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
ram = RAMResource()
|
||||
yield Workspace({"/": ram}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def toolkit(workspace):
|
||||
tk = MirageFileToolkit(workspace)
|
||||
yield tk
|
||||
tk.close()
|
||||
|
||||
|
||||
def _strip_markdown_code_fence(text: str) -> str:
|
||||
text = text.strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.splitlines()
|
||||
return "\n".join(
|
||||
lines[1:-1] if lines[-1].startswith("```") else lines[1:])
|
||||
return text
|
||||
|
||||
|
||||
def test_write_text_file_then_read(toolkit):
|
||||
msg = toolkit.write_to_file(title="Hello",
|
||||
content="hi there",
|
||||
filename="/notes/hello.md")
|
||||
assert "hello.md" in msg
|
||||
out = toolkit.read_file(file_paths="/notes/hello.md")
|
||||
assert "hi there" in out
|
||||
|
||||
|
||||
def test_write_json_file_then_read(toolkit):
|
||||
msg = toolkit.write_to_file(title="data",
|
||||
content={
|
||||
"a": 1,
|
||||
"b": [2, 3]
|
||||
},
|
||||
filename="/data.json")
|
||||
assert "data.json" in msg
|
||||
out = toolkit.read_file(file_paths="/data.json")
|
||||
parsed = json.loads(_strip_markdown_code_fence(out))
|
||||
assert parsed == {"a": 1, "b": [2, 3]}
|
||||
|
||||
|
||||
def test_edit_file_replaces_content(toolkit):
|
||||
toolkit.write_to_file(title="t", content="old\nkeep\n", filename="/e.txt")
|
||||
msg = toolkit.edit_file(file_path="/e.txt",
|
||||
old_content="old",
|
||||
new_content="new")
|
||||
assert "successfully" in msg.lower() or "edited" in msg.lower()
|
||||
out = toolkit.read_file(file_paths="/e.txt")
|
||||
assert "new" in out and "keep" in out and "old" not in out.split("keep")[0]
|
||||
|
||||
|
||||
def test_search_files_by_name(toolkit):
|
||||
toolkit.write_to_file(title="a", content="x", filename="/dir/a.txt")
|
||||
toolkit.write_to_file(title="b", content="y", filename="/dir/b.md")
|
||||
out = toolkit.search_files(file_name="a.txt", path="/dir")
|
||||
assert "a.txt" in out
|
||||
|
||||
|
||||
def test_glob_files(toolkit):
|
||||
toolkit.write_to_file(title="a", content="x", filename="/g/x.py")
|
||||
toolkit.write_to_file(title="a", content="y", filename="/g/y.py")
|
||||
out = toolkit.glob_files(pattern="*.py", path="/g")
|
||||
assert "x.py" in out and "y.py" in out
|
||||
|
||||
|
||||
def test_grep_files(toolkit):
|
||||
toolkit.write_to_file(title="a",
|
||||
content="needle here\n",
|
||||
filename="/q/a.txt")
|
||||
toolkit.write_to_file(title="b", content="haystack\n", filename="/q/b.txt")
|
||||
out = toolkit.grep_files(pattern="needle", path="/q")
|
||||
assert "needle" in out
|
||||
assert "a.txt" in out
|
||||
@@ -0,0 +1,80 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.agents.camel import MirageTerminalToolkit
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
ram = RAMResource()
|
||||
ws = Workspace({"/": ram}, mode=MountMode.WRITE)
|
||||
yield ws
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def toolkit(workspace):
|
||||
tk = MirageTerminalToolkit(workspace)
|
||||
yield tk
|
||||
tk.close()
|
||||
|
||||
|
||||
def test_shell_exec_blocking_returns_stdout(toolkit):
|
||||
out = toolkit.shell_exec(id="t1", command="echo hello", block=True)
|
||||
assert "hello" in out
|
||||
|
||||
|
||||
def test_shell_exec_blocking_captures_stderr(toolkit):
|
||||
out = toolkit.shell_exec(id="t1",
|
||||
command="ls /nonexistent-zzz",
|
||||
block=True)
|
||||
assert out, "expected non-empty output for failing command"
|
||||
|
||||
|
||||
def test_shell_write_content_to_file(toolkit, workspace):
|
||||
msg = toolkit.shell_write_content_to_file(content="line1\nline2\n",
|
||||
file_path="/note.txt")
|
||||
assert "note.txt" in msg
|
||||
out = toolkit.shell_exec(id="t1", command="cat /note.txt", block=True)
|
||||
assert "line1" in out and "line2" in out
|
||||
|
||||
|
||||
def test_shell_exec_nonblocking_returns_session_id(toolkit):
|
||||
msg = toolkit.shell_exec(id="bg1", command="sleep 0.5", block=False)
|
||||
assert "bg1" in msg
|
||||
|
||||
|
||||
def test_shell_view_after_completion(toolkit):
|
||||
toolkit.shell_exec(id="bg2", command="echo done", block=False)
|
||||
out = toolkit.shell_view(id="bg2")
|
||||
assert "done" in out
|
||||
|
||||
|
||||
def test_shell_kill_unknown_session(toolkit):
|
||||
msg = toolkit.shell_kill_process(id="never-started")
|
||||
assert "Error" in msg
|
||||
|
||||
|
||||
def test_shell_write_to_process_returns_clear_error(toolkit):
|
||||
toolkit.shell_exec(id="bg3", command="sleep 0.5", block=False)
|
||||
msg = toolkit.shell_write_to_process(id="bg3", command="anything")
|
||||
assert "not interactive" in msg.lower()
|
||||
|
||||
|
||||
def test_get_tools_returns_six(toolkit):
|
||||
tools = toolkit.get_tools()
|
||||
assert len(tools) == 6
|
||||
@@ -0,0 +1,42 @@
|
||||
import pytest
|
||||
|
||||
claude_agent_sdk = pytest.importorskip("claude_agent_sdk")
|
||||
|
||||
from mirage import MountMode, RAMResource, Workspace # noqa: E402
|
||||
from mirage.agents.claude_agent_sdk.options import build_options # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
return Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def test_build_options_returns_claude_agent_options(workspace):
|
||||
options = build_options(workspace)
|
||||
assert isinstance(options, claude_agent_sdk.ClaudeAgentOptions)
|
||||
|
||||
|
||||
def test_build_options_has_mirage_server(workspace):
|
||||
options = build_options(workspace)
|
||||
assert "mirage" in options.mcp_servers
|
||||
|
||||
|
||||
def test_build_options_allowed_tools(workspace):
|
||||
options = build_options(workspace)
|
||||
assert "mcp__mirage__*" in options.allowed_tools
|
||||
|
||||
|
||||
def test_build_options_disables_builtin_tools(workspace):
|
||||
options = build_options(workspace)
|
||||
assert options.tools == []
|
||||
|
||||
|
||||
def test_build_options_custom_system_prompt(workspace):
|
||||
options = build_options(workspace, system_prompt="custom prompt")
|
||||
assert options.system_prompt == "custom prompt"
|
||||
|
||||
|
||||
def test_build_options_default_system_prompt(workspace):
|
||||
options = build_options(workspace)
|
||||
assert options.system_prompt is not None
|
||||
assert len(options.system_prompt) > 0
|
||||
@@ -0,0 +1,167 @@
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("claude_agent_sdk")
|
||||
|
||||
from mirage import MountMode, RAMResource, Workspace # noqa: E402
|
||||
from mirage.agents.claude_agent_sdk.server import _MirageTools # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
return Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tools(workspace):
|
||||
return _MirageTools(workspace)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_echo(tools):
|
||||
result = await tools.execute_command({"command": "echo hello"})
|
||||
assert "hello" in result["content"][0]["text"]
|
||||
assert result.get("is_error") is not True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_pipe(tools, workspace):
|
||||
await workspace.ops.write("/pipe.txt", b"aaa\nbbb\naaa\n")
|
||||
result = await tools.execute_command(
|
||||
{"command": "cat /pipe.txt | sort | uniq | wc -l"})
|
||||
assert "2" in result["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file(tools, workspace):
|
||||
await workspace.ops.write("/hello.txt", b"line1\nline2\nline3\n")
|
||||
result = await tools.read({"path": "/hello.txt"})
|
||||
text = result["content"][0]["text"]
|
||||
assert "line1" in text
|
||||
assert "line2" in text
|
||||
assert result.get("is_error") is not True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_with_offset_and_limit(tools, workspace):
|
||||
await workspace.ops.write("/multi.txt", b"a\nb\nc\nd\ne\n")
|
||||
result = await tools.read({"path": "/multi.txt", "offset": 1, "limit": 2})
|
||||
text = result["content"][0]["text"]
|
||||
assert "b" in text
|
||||
assert "c" in text
|
||||
assert "a" not in text
|
||||
assert "d" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_not_found(tools):
|
||||
result = await tools.read({"path": "/nonexistent.txt"})
|
||||
assert result["is_error"] is True
|
||||
assert "not found" in result["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_file(tools, workspace):
|
||||
result = await tools.write({"path": "/new.txt", "content": "hello world"})
|
||||
assert result.get("is_error") is not True
|
||||
data = await workspace.ops.read("/new.txt")
|
||||
assert data == b"hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_file_already_exists(tools, workspace):
|
||||
await workspace.ops.write("/exists.txt", b"first")
|
||||
result = await tools.write({"path": "/exists.txt", "content": "second"})
|
||||
assert result["is_error"] is True
|
||||
assert "already exists" in result["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_file(tools, workspace):
|
||||
await workspace.ops.write("/edit.txt", b"foo bar baz")
|
||||
result = await tools.edit({
|
||||
"path": "/edit.txt",
|
||||
"old_string": "bar",
|
||||
"new_string": "qux"
|
||||
})
|
||||
assert result.get("is_error") is not True
|
||||
data = await workspace.ops.read("/edit.txt")
|
||||
assert data == b"foo qux baz"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_file_not_found(tools):
|
||||
result = await tools.edit({
|
||||
"path": "/missing.txt",
|
||||
"old_string": "x",
|
||||
"new_string": "y"
|
||||
})
|
||||
assert result["is_error"] is True
|
||||
assert "not found" in result["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_string_not_found(tools, workspace):
|
||||
await workspace.ops.write("/nostr.txt", b"hello world")
|
||||
result = await tools.edit({
|
||||
"path": "/nostr.txt",
|
||||
"old_string": "xyz",
|
||||
"new_string": "abc"
|
||||
})
|
||||
assert result["is_error"] is True
|
||||
assert "not found" in result["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_multiple_occurrences_without_replace_all(tools, workspace):
|
||||
await workspace.ops.write("/multi.txt", b"aa bb aa")
|
||||
result = await tools.edit({
|
||||
"path": "/multi.txt",
|
||||
"old_string": "aa",
|
||||
"new_string": "cc"
|
||||
})
|
||||
assert result["is_error"] is True
|
||||
assert "replace_all" in result["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_replace_all(tools, workspace):
|
||||
await workspace.ops.write("/all.txt", b"aa bb aa")
|
||||
result = await tools.edit({
|
||||
"path": "/all.txt",
|
||||
"old_string": "aa",
|
||||
"new_string": "cc",
|
||||
"replace_all": True
|
||||
})
|
||||
assert result.get("is_error") is not True
|
||||
data = await workspace.ops.read("/all.txt")
|
||||
assert data == b"cc bb cc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_creates_parent_dirs(tools, workspace):
|
||||
result = await tools.write({
|
||||
"path": "/nested/deep/file.txt",
|
||||
"content": "hi"
|
||||
})
|
||||
assert result.get("is_error") is not True
|
||||
data = await workspace.ops.read("/nested/deep/file.txt")
|
||||
assert data == b"hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls(tools, workspace):
|
||||
await tools.write({"path": "/dir/a.txt", "content": "a"})
|
||||
await tools.write({"path": "/dir/b.txt", "content": "b"})
|
||||
result = await tools.ls({"path": "/dir"})
|
||||
text = result["content"][0]["text"]
|
||||
assert "a.txt" in text
|
||||
assert "b.txt" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep(tools, workspace):
|
||||
await workspace.ops.write("/search.txt",
|
||||
b"hello world\ngoodbye world\nhello again\n")
|
||||
result = await tools.grep({"pattern": "hello", "path": "/"})
|
||||
text = result["content"][0]["text"]
|
||||
assert "hello" in text
|
||||
@@ -0,0 +1,123 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, RAMResource, Workspace
|
||||
from mirage.agents.langchain.backend import LangchainWorkspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
return Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(workspace):
|
||||
return LangchainWorkspace(workspace)
|
||||
|
||||
|
||||
def test_id(backend):
|
||||
assert backend.id == "mirage"
|
||||
|
||||
|
||||
def test_custom_id(workspace):
|
||||
b = LangchainWorkspace(workspace, sandbox_id="custom")
|
||||
assert b.id == "custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_echo(backend):
|
||||
resp = await backend.aexecute("echo hello")
|
||||
assert resp.exit_code == 0
|
||||
assert "hello" in resp.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_failing_command(backend):
|
||||
resp = await backend.aexecute("cat /nonexistent")
|
||||
assert resp.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrite_and_aread(backend):
|
||||
result = await backend.awrite("/test.txt", "hello world")
|
||||
assert result.error is None
|
||||
|
||||
content = await backend.aread("/test.txt")
|
||||
assert "hello world" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrite_existing_file_errors(backend):
|
||||
await backend.awrite("/exists.txt", "first")
|
||||
result = await backend.awrite("/exists.txt", "second")
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aedit(backend):
|
||||
await backend.awrite("/edit.txt", "foo bar baz")
|
||||
result = await backend.aedit("/edit.txt", "bar", "qux")
|
||||
assert result.error is None
|
||||
content = await backend.aread("/edit.txt")
|
||||
assert "qux" in content
|
||||
assert "bar" not in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_als_info(backend):
|
||||
await backend.awrite("/dir/a.txt", "a")
|
||||
await backend.awrite("/dir/b.txt", "b")
|
||||
entries = await backend.als_info("/dir")
|
||||
paths = [e["path"] for e in entries]
|
||||
assert len(paths) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agrep_raw(backend):
|
||||
await backend.awrite("/search.txt",
|
||||
"hello world\ngoodbye world\nhello again")
|
||||
result = await backend.agrep_raw("hello", path="/")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aglob_info(backend):
|
||||
await backend.awrite("/data/a.txt", "a")
|
||||
await backend.awrite("/data/b.py", "b")
|
||||
entries = await backend.aglob_info("*.txt", path="/data")
|
||||
paths = [e["path"] for e in entries]
|
||||
assert any("a.txt" in p for p in paths)
|
||||
assert not any("b.py" in p for p in paths)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_pipe(backend):
|
||||
await backend.awrite("/pipe.txt", "aaa\nbbb\nccc\naaa\n")
|
||||
resp = await backend.aexecute("cat /pipe.txt | sort | uniq | wc -l")
|
||||
assert resp.exit_code == 0
|
||||
assert "3" in resp.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_and_download(backend):
|
||||
files = [("/up1.txt", b"content1"), ("/up2.txt", b"content2")]
|
||||
up_results = await backend.aupload_files(files)
|
||||
assert all(r.error is None for r in up_results)
|
||||
|
||||
down_results = await backend.adownload_files(["/up1.txt", "/up2.txt"])
|
||||
assert down_results[0].content == b"content1"
|
||||
assert down_results[1].content == b"content2"
|
||||
@@ -0,0 +1,72 @@
|
||||
# ========= 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.agents.langchain._convert import (io_to_execute_response,
|
||||
io_to_file_infos,
|
||||
io_to_grep_matches)
|
||||
from mirage.io.types import IOResult
|
||||
|
||||
|
||||
def test_io_to_execute_response_basic():
|
||||
io = IOResult(stdout=b"hello world\n", exit_code=0)
|
||||
resp = io_to_execute_response(io)
|
||||
assert resp.output == "hello world\n"
|
||||
assert resp.exit_code == 0
|
||||
assert resp.truncated is False
|
||||
|
||||
|
||||
def test_io_to_execute_response_none_stdout():
|
||||
io = IOResult(stdout=None, exit_code=1)
|
||||
resp = io_to_execute_response(io)
|
||||
assert resp.output == ""
|
||||
assert resp.exit_code == 1
|
||||
|
||||
|
||||
def test_io_to_execute_response_stderr_appended():
|
||||
io = IOResult(stdout=b"out", stderr=b"err", exit_code=1)
|
||||
resp = io_to_execute_response(io)
|
||||
assert "out" in resp.output
|
||||
assert "err" in resp.output
|
||||
|
||||
|
||||
def test_io_to_grep_matches():
|
||||
io = IOResult(stdout=b"/a.txt:1:hello world\n/b.txt:5:hello there\n",
|
||||
exit_code=0)
|
||||
matches = io_to_grep_matches(io)
|
||||
assert len(matches) == 2
|
||||
assert matches[0]["path"] == "/a.txt"
|
||||
assert matches[0]["line"] == 1
|
||||
assert matches[0]["text"] == "hello world"
|
||||
|
||||
|
||||
def test_io_to_grep_matches_empty():
|
||||
io = IOResult(stdout=b"", exit_code=1)
|
||||
matches = io_to_grep_matches(io)
|
||||
assert matches == []
|
||||
|
||||
|
||||
def test_io_to_file_infos():
|
||||
io = IOResult(stdout=b"/foo/bar.txt\n/foo/baz.py\n/foo/sub/\n",
|
||||
exit_code=0)
|
||||
infos = io_to_file_infos(io)
|
||||
assert len(infos) == 3
|
||||
dirs = [i for i in infos if i.get("is_dir")]
|
||||
assert len(dirs) == 1
|
||||
assert dirs[0]["path"] == "/foo/sub"
|
||||
|
||||
|
||||
def test_io_to_file_infos_empty():
|
||||
io = IOResult(stdout=b"", exit_code=0)
|
||||
infos = io_to_file_infos(io)
|
||||
assert infos == []
|
||||
@@ -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. =========
|
||||
|
||||
from mirage.agents.langchain._messages import extract_text
|
||||
|
||||
|
||||
def test_extract_text_from_string():
|
||||
|
||||
class Msg:
|
||||
content = "hello world"
|
||||
|
||||
assert extract_text([Msg()]) == ["hello world"]
|
||||
|
||||
|
||||
def test_extract_text_from_blocks():
|
||||
|
||||
class Msg:
|
||||
content = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "hello"
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "ls",
|
||||
"input": {}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "goodbye"
|
||||
},
|
||||
]
|
||||
|
||||
assert extract_text([Msg()]) == ["hello", "goodbye"]
|
||||
|
||||
|
||||
def test_extract_text_skips_empty():
|
||||
|
||||
class Msg:
|
||||
content = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "real"
|
||||
},
|
||||
]
|
||||
|
||||
assert extract_text([Msg()]) == ["real"]
|
||||
|
||||
|
||||
def test_extract_text_no_content():
|
||||
|
||||
class Msg:
|
||||
pass
|
||||
|
||||
assert extract_text([Msg()]) == []
|
||||
@@ -0,0 +1,103 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import base64
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.agents.openai_agents.runner import MirageRunner
|
||||
from mirage.types import FileStat, FileType
|
||||
|
||||
|
||||
def _stat(file_type: FileType) -> FileStat:
|
||||
return FileStat(name="x", type=file_type)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws():
|
||||
workspace = MagicMock()
|
||||
workspace.ops = MagicMock()
|
||||
workspace.ops.stat = AsyncMock()
|
||||
workspace.ops.read = AsyncMock()
|
||||
return workspace
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_blocks_image_inlines_base64(ws):
|
||||
ws.ops.stat.return_value = _stat(FileType.IMAGE_PNG)
|
||||
ws.ops.read.return_value = b"\x89PNG\r\n\x1a\n"
|
||||
runner = MirageRunner(ws)
|
||||
blocks = await runner.build_blocks("hi", ["/img.png"])
|
||||
assert blocks[0] == {"type": "input_text", "text": "hi"}
|
||||
assert blocks[1]["type"] == "input_image"
|
||||
expected_b64 = base64.b64encode(b"\x89PNG\r\n\x1a\n").decode()
|
||||
assert blocks[1]["image_url"] == f"data:image/png;base64,{expected_b64}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_blocks_pdf_uploads_to_files_api(ws):
|
||||
ws.ops.stat.return_value = _stat(FileType.PDF)
|
||||
ws.ops.read.return_value = b"%PDF-1.4 ..."
|
||||
fake_client = MagicMock()
|
||||
fake_client.files = MagicMock()
|
||||
fake_client.files.create = AsyncMock(return_value=MagicMock(id="file-abc"))
|
||||
runner = MirageRunner(ws, client=fake_client)
|
||||
blocks = await runner.build_blocks("read", ["/doc.pdf"])
|
||||
assert blocks[1] == {"type": "input_file", "file_id": "file-abc"}
|
||||
fake_client.files.create.assert_awaited_once()
|
||||
args, kwargs = fake_client.files.create.call_args
|
||||
assert kwargs["purpose"] == "user_data"
|
||||
fname, fdata = kwargs["file"]
|
||||
assert fname == "doc.pdf"
|
||||
assert fdata == b"%PDF-1.4 ..."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_blocks_text_decoded_inline(ws):
|
||||
ws.ops.stat.return_value = _stat(FileType.TEXT)
|
||||
ws.ops.read.return_value = b"hello world"
|
||||
runner = MirageRunner(ws)
|
||||
blocks = await runner.build_blocks("look", ["/notes.txt"])
|
||||
assert blocks[1] == {"type": "input_text", "text": "hello world"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_blocks_jpeg(ws):
|
||||
ws.ops.stat.return_value = _stat(FileType.IMAGE_JPEG)
|
||||
ws.ops.read.return_value = b"\xff\xd8\xff..."
|
||||
runner = MirageRunner(ws)
|
||||
blocks = await runner.build_blocks("see", ["/photo.jpg"])
|
||||
assert blocks[1]["type"] == "input_image"
|
||||
assert blocks[1]["image_url"].startswith("data:image/jpeg;base64,")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_blocks_multiple_paths_in_order(ws):
|
||||
types = [FileType.TEXT, FileType.IMAGE_PNG]
|
||||
bytes_seq = [b"first", b"\x89PNG\r\n\x1a\n"]
|
||||
|
||||
async def fake_stat(p):
|
||||
return _stat(types.pop(0))
|
||||
|
||||
async def fake_read(p):
|
||||
return bytes_seq.pop(0)
|
||||
|
||||
ws.ops.stat.side_effect = fake_stat
|
||||
ws.ops.read.side_effect = fake_read
|
||||
runner = MirageRunner(ws)
|
||||
blocks = await runner.build_blocks("two", ["/a.txt", "/b.png"])
|
||||
assert len(blocks) == 3
|
||||
assert blocks[1] == {"type": "input_text", "text": "first"}
|
||||
assert blocks[2]["type"] == "input_image"
|
||||
@@ -0,0 +1,87 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from mirage.agents.openai_agents.sandbox import MirageSandboxClient
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.types import MountMode
|
||||
from mirage.workspace import Workspace
|
||||
|
||||
|
||||
def _make_client() -> MirageSandboxClient:
|
||||
ram = RAMResource()
|
||||
ws = Workspace(
|
||||
{"/": (ram, MountMode.WRITE)},
|
||||
mode=MountMode.WRITE,
|
||||
)
|
||||
return MirageSandboxClient(ws)
|
||||
|
||||
|
||||
def test_create_session_with_default_resources():
|
||||
|
||||
async def _run():
|
||||
client = _make_client()
|
||||
session = await client.create()
|
||||
result = await session.exec("echo hello", shell=False)
|
||||
assert result.exit_code == 0
|
||||
assert b"hello" in result.stdout
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_session_file_read_write():
|
||||
|
||||
async def _run():
|
||||
client = _make_client()
|
||||
session = await client.create()
|
||||
|
||||
content = b"test file content"
|
||||
await session.write(Path("myfile.txt"), io.BytesIO(content))
|
||||
|
||||
stream = await session.read(Path("myfile.txt"))
|
||||
assert stream.read() == content
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_persist_and_hydrate_workspace():
|
||||
|
||||
async def _run():
|
||||
client = _make_client()
|
||||
session = await client.create()
|
||||
await session.write(Path("data.txt"), io.BytesIO(b"persist me"))
|
||||
|
||||
snapshot = await session.persist_workspace()
|
||||
|
||||
client2 = _make_client()
|
||||
session2 = await client2.create()
|
||||
await session2.hydrate_workspace(snapshot)
|
||||
|
||||
stream = await session2.read(Path("data.txt"))
|
||||
assert stream.read() == b"persist me"
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_session_running_state():
|
||||
|
||||
async def _run():
|
||||
client = _make_client()
|
||||
session = await client.create()
|
||||
assert await session.running() is True
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -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 pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("openhands")
|
||||
|
||||
from mirage.agents.openhands import MirageWorkspace # noqa: E402
|
||||
from mirage.resource.ram import RAMResource # noqa: E402
|
||||
from mirage.types import MountMode # noqa: E402
|
||||
from mirage.workspace import Workspace # noqa: E402
|
||||
|
||||
|
||||
def _make_backing() -> Workspace:
|
||||
ram = RAMResource()
|
||||
return Workspace({"/": (ram, MountMode.WRITE)}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
def test_execute_command_basic():
|
||||
with MirageWorkspace(workspace=_make_backing()) as mw:
|
||||
result = mw.execute_command("echo hello")
|
||||
assert result.exit_code == 0
|
||||
assert "hello" in result.stdout
|
||||
assert result.command == "echo hello"
|
||||
assert result.timeout_occurred is False
|
||||
|
||||
|
||||
def test_execute_command_with_cwd():
|
||||
with MirageWorkspace(workspace=_make_backing()) as mw:
|
||||
mw.execute_command("echo data > /file.txt")
|
||||
result = mw.execute_command("cat file.txt", cwd="/")
|
||||
assert result.exit_code == 0, result.stderr
|
||||
assert "data" in result.stdout
|
||||
|
||||
|
||||
def test_execute_command_nonzero_exit():
|
||||
with MirageWorkspace(workspace=_make_backing()) as mw:
|
||||
result = mw.execute_command("cat /no/such/path")
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_file_upload_then_download(tmp_path: Path):
|
||||
src = tmp_path / "src.bin"
|
||||
src.write_bytes(b"abc 123")
|
||||
with MirageWorkspace(workspace=_make_backing()) as mw:
|
||||
up = mw.file_upload(src, "/uploaded.bin")
|
||||
assert up.success is True
|
||||
assert up.file_size == 7
|
||||
|
||||
cat = mw.execute_command("cat /uploaded.bin")
|
||||
assert cat.exit_code == 0
|
||||
assert "abc 123" in cat.stdout
|
||||
|
||||
out = tmp_path / "out.bin"
|
||||
dn = mw.file_download("/uploaded.bin", out)
|
||||
assert dn.success is True
|
||||
assert dn.file_size == 7
|
||||
assert out.read_bytes() == b"abc 123"
|
||||
|
||||
|
||||
def test_file_upload_into_nested_dir(tmp_path: Path):
|
||||
src = tmp_path / "src.txt"
|
||||
src.write_bytes(b"nested")
|
||||
with MirageWorkspace(workspace=_make_backing()) as mw:
|
||||
up = mw.file_upload(src, "/sub/dir/file.txt")
|
||||
assert up.success is True
|
||||
|
||||
cat = mw.execute_command("cat /sub/dir/file.txt")
|
||||
assert cat.exit_code == 0
|
||||
assert "nested" in cat.stdout
|
||||
|
||||
|
||||
def test_file_download_missing_source_returns_error(tmp_path: Path):
|
||||
out = tmp_path / "out.txt"
|
||||
with MirageWorkspace(workspace=_make_backing()) as mw:
|
||||
dn = mw.file_download("/does/not/exist.txt", out)
|
||||
assert dn.success is False
|
||||
assert dn.error
|
||||
assert out.exists() is False
|
||||
|
||||
|
||||
def test_context_manager_closes_backing_workspace():
|
||||
backing = _make_backing()
|
||||
with MirageWorkspace(workspace=backing) as mw:
|
||||
result = mw.execute_command("echo ctx")
|
||||
assert result.exit_code == 0
|
||||
assert backing._closed is True
|
||||
|
||||
|
||||
def test_git_methods_not_supported():
|
||||
with MirageWorkspace(workspace=_make_backing()) as mw:
|
||||
with pytest.raises(NotImplementedError):
|
||||
mw.git_changes("/")
|
||||
with pytest.raises(NotImplementedError):
|
||||
mw.git_diff("/some/path")
|
||||
@@ -0,0 +1,136 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic_ai import ToolReturn
|
||||
|
||||
from mirage import MountMode, RAMResource, Workspace
|
||||
from mirage.agents.pydantic_ai.backend import PydanticAIWorkspace
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parents[4] / "data"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
return Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(workspace):
|
||||
return PydanticAIWorkspace(workspace)
|
||||
|
||||
|
||||
def test_id(backend):
|
||||
assert backend.id == "mirage"
|
||||
|
||||
|
||||
def test_custom_id(workspace):
|
||||
b = PydanticAIWorkspace(workspace, sandbox_id="custom")
|
||||
assert b.id == "custom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_echo(backend):
|
||||
resp = await backend.aexecute("echo hello")
|
||||
assert resp.exit_code == 0
|
||||
assert "hello" in resp.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aexecute_failing_command(backend):
|
||||
resp = await backend.aexecute("cat /nonexistent")
|
||||
assert resp.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrite_and_aread(backend):
|
||||
result = await backend.awrite("/test.txt", "hello world")
|
||||
assert result.error is None
|
||||
|
||||
content = await backend.aread("/test.txt")
|
||||
assert "hello world" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_awrite_existing_file_errors(backend):
|
||||
await backend.awrite("/exists.txt", "first")
|
||||
result = await backend.awrite("/exists.txt", "second")
|
||||
assert result.error is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aedit(backend):
|
||||
await backend.awrite("/edit.txt", "foo bar baz")
|
||||
result = await backend.aedit("/edit.txt", "bar", "qux")
|
||||
assert result.error is None
|
||||
content = await backend.aread("/edit.txt")
|
||||
assert "qux" in content
|
||||
assert "bar" not in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_als_info(backend):
|
||||
await backend.awrite("/dir/a.txt", "a")
|
||||
await backend.awrite("/dir/b.txt", "b")
|
||||
entries = await backend.als_info("/dir")
|
||||
paths = [e["path"] for e in entries]
|
||||
assert len(paths) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agrep_raw(backend):
|
||||
await backend.awrite("/search.txt",
|
||||
"hello world\ngoodbye world\nhello again")
|
||||
result = await backend.agrep_raw("hello", path="/")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aglob_info(backend):
|
||||
await backend.awrite("/data/a.txt", "a")
|
||||
await backend.awrite("/data/b.py", "b")
|
||||
entries = await backend.aglob_info("*.txt", path="/data")
|
||||
paths = [e["path"] for e in entries]
|
||||
assert any("a.txt" in p for p in paths)
|
||||
assert not any("b.py" in p for p in paths)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_pipe(backend):
|
||||
await backend.awrite("/pipe.txt", "aaa\nbbb\nccc\naaa\n")
|
||||
resp = await backend.aexecute("cat /pipe.txt | sort | uniq | wc -l")
|
||||
assert resp.exit_code == 0
|
||||
assert "3" in resp.output
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes(backend):
|
||||
await backend.awrite("/bytes.txt", "binary content")
|
||||
data = await backend._aread_bytes("/bytes.txt")
|
||||
assert data == b"binary content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aread_pdf_returns_tool_return(backend, workspace):
|
||||
with open(DATA_DIR / "example.pdf", "rb") as f:
|
||||
pdf_bytes = f.read()
|
||||
await workspace.ops.write("/report.pdf", pdf_bytes)
|
||||
|
||||
result = await backend.aread("/report.pdf")
|
||||
assert isinstance(result, ToolReturn)
|
||||
assert "report.pdf" in result.return_value
|
||||
assert len(result.content) > 0
|
||||
@@ -0,0 +1,98 @@
|
||||
# ========= 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 dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.models.test import TestModel
|
||||
from pydantic_ai_backends import create_console_toolset
|
||||
|
||||
from mirage import MountMode, RAMResource, Workspace
|
||||
from mirage.agents.pydantic_ai.backend import PydanticAIWorkspace
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
backend: PydanticAIWorkspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace():
|
||||
return Workspace({"/": RAMResource()}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend(workspace):
|
||||
return PydanticAIWorkspace(workspace)
|
||||
|
||||
|
||||
def test_agent_write_and_read(backend):
|
||||
agent = Agent(
|
||||
TestModel(call_tools=["write_file", "read_file"]),
|
||||
deps_type=Deps,
|
||||
toolsets=[create_console_toolset()],
|
||||
)
|
||||
result = agent.run_sync("write a file", deps=Deps(backend=backend))
|
||||
assert result.output
|
||||
|
||||
|
||||
def test_agent_ls(backend, workspace):
|
||||
import asyncio
|
||||
asyncio.run(backend.awrite("/data/file.txt", "hello"))
|
||||
|
||||
agent = Agent(
|
||||
TestModel(call_tools=["ls"]),
|
||||
deps_type=Deps,
|
||||
toolsets=[create_console_toolset()],
|
||||
)
|
||||
result = agent.run_sync("list files in /data", deps=Deps(backend=backend))
|
||||
assert result.output
|
||||
|
||||
|
||||
def test_agent_edit(backend):
|
||||
import asyncio
|
||||
asyncio.run(backend.awrite("/code.py", "x = 1\ny = 2\n"))
|
||||
|
||||
agent = Agent(
|
||||
TestModel(call_tools=["edit_file"]),
|
||||
deps_type=Deps,
|
||||
toolsets=[create_console_toolset()],
|
||||
)
|
||||
result = agent.run_sync("change x to 42", deps=Deps(backend=backend))
|
||||
assert result.output
|
||||
|
||||
|
||||
def test_agent_grep(backend):
|
||||
import asyncio
|
||||
asyncio.run(backend.awrite("/hello.txt", "hello world\ngoodbye world\n"))
|
||||
|
||||
agent = Agent(
|
||||
TestModel(call_tools=["grep"]),
|
||||
deps_type=Deps,
|
||||
toolsets=[create_console_toolset()],
|
||||
)
|
||||
result = agent.run_sync("search for hello", deps=Deps(backend=backend))
|
||||
assert result.output
|
||||
|
||||
|
||||
def test_no_real_filesystem(backend, workspace):
|
||||
import asyncio
|
||||
asyncio.run(backend.awrite("/test.txt", "in-memory content"))
|
||||
|
||||
content = asyncio.run(workspace.ops.read("/test.txt"))
|
||||
assert content == b"in-memory content"
|
||||
|
||||
import os
|
||||
assert not os.path.exists("/test.txt")
|
||||
@@ -0,0 +1,34 @@
|
||||
from mirage.agents.io_text import decode, io_to_str
|
||||
from mirage.io.types import IOResult
|
||||
|
||||
|
||||
def test_decode_none_returns_empty():
|
||||
assert decode(None) == ""
|
||||
|
||||
|
||||
def test_decode_bytes():
|
||||
assert decode(b"hello") == "hello"
|
||||
|
||||
|
||||
def test_decode_invalid_utf8_replaces():
|
||||
assert decode(b"\xff") == "�"
|
||||
|
||||
|
||||
def test_io_to_str_stdout_only():
|
||||
assert io_to_str(IOResult(stdout=b"out")) == "out"
|
||||
|
||||
|
||||
def test_io_to_str_stderr_only():
|
||||
assert io_to_str(IOResult(stderr=b"err")) == "err"
|
||||
|
||||
|
||||
def test_io_to_str_combines_stdout_and_stderr():
|
||||
assert io_to_str(IOResult(stdout=b"out", stderr=b"err")) == "out\nerr"
|
||||
|
||||
|
||||
async def _stream():
|
||||
yield b"streamed"
|
||||
|
||||
|
||||
def test_io_to_str_ignores_unmaterialized_stream():
|
||||
assert io_to_str(IOResult(stdout=_stream())) == ""
|
||||
@@ -0,0 +1,98 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _BlockDeepagents:
|
||||
|
||||
def find_spec(self, name, path=None, target=None):
|
||||
if name == "deepagents" or name.startswith("deepagents."):
|
||||
raise ModuleNotFoundError(f"blocked import of {name}")
|
||||
return None
|
||||
|
||||
|
||||
def _evict_agents_and_deepagents(saved):
|
||||
for name in list(sys.modules):
|
||||
if (name == "deepagents" or name.startswith("deepagents.")
|
||||
or name == "mirage.agents"
|
||||
or name.startswith("mirage.agents.")):
|
||||
saved[name] = sys.modules.pop(name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deepagents_blocked():
|
||||
"""Simulate an environment where `deepagents` is not installed.
|
||||
|
||||
Yields:
|
||||
None: The fixture only manipulates import state; nothing is returned.
|
||||
"""
|
||||
blocker = _BlockDeepagents()
|
||||
sys.meta_path.insert(0, blocker)
|
||||
saved = {}
|
||||
_evict_agents_and_deepagents(saved)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sys.meta_path.remove(blocker)
|
||||
for name in list(sys.modules):
|
||||
if (name == "deepagents" or name.startswith("deepagents.")
|
||||
or name == "mirage.agents"
|
||||
or name.startswith("mirage.agents.")):
|
||||
del sys.modules[name]
|
||||
sys.modules.update(saved)
|
||||
|
||||
|
||||
def test_pydantic_ai_prompt_imports_without_deepagents(deepagents_blocked):
|
||||
mod = importlib.import_module("mirage.agents.pydantic_ai.prompt")
|
||||
assert isinstance(mod.MIRAGE_SYSTEM_PROMPT, str)
|
||||
assert callable(mod.build_system_prompt)
|
||||
assert mod.build_system_prompt() == mod.MIRAGE_SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_openai_agents_prompt_imports_without_deepagents(deepagents_blocked):
|
||||
mod = importlib.import_module("mirage.agents.openai_agents.prompt")
|
||||
assert isinstance(mod.MIRAGE_SYSTEM_PROMPT, str)
|
||||
assert callable(mod.build_system_prompt)
|
||||
|
||||
|
||||
def test_pydantic_ai_package_imports_without_deepagents(deepagents_blocked):
|
||||
mod = importlib.import_module("mirage.agents.pydantic_ai")
|
||||
assert isinstance(mod.MIRAGE_SYSTEM_PROMPT, str)
|
||||
assert mod.PydanticAIWorkspace is not None
|
||||
|
||||
|
||||
def test_openai_agents_package_imports_without_deepagents(deepagents_blocked):
|
||||
mod = importlib.import_module("mirage.agents.openai_agents")
|
||||
assert isinstance(mod.MIRAGE_SYSTEM_PROMPT, str)
|
||||
assert mod.MirageRunner is not None
|
||||
|
||||
|
||||
def test_langchain_full_import_fails_without_deepagents(deepagents_blocked):
|
||||
with pytest.raises(ModuleNotFoundError):
|
||||
importlib.import_module("mirage.agents.langchain")
|
||||
|
||||
|
||||
def test_all_prompts_share_same_content():
|
||||
from mirage.agents.langchain import prompt as lc
|
||||
from mirage.agents.openai_agents import prompt as oa
|
||||
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT
|
||||
from mirage.agents.pydantic_ai import prompt as pa
|
||||
|
||||
assert pa.MIRAGE_SYSTEM_PROMPT == MIRAGE_SYSTEM_PROMPT
|
||||
assert oa.MIRAGE_SYSTEM_PROMPT == MIRAGE_SYSTEM_PROMPT
|
||||
assert lc.MIRAGE_SYSTEM_PROMPT == MIRAGE_SYSTEM_PROMPT
|
||||
Vendored
+13
@@ -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. =========
|
||||
Vendored
+13
@@ -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. =========
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import time
|
||||
|
||||
from mirage.cache.file.entry import CacheEntry
|
||||
|
||||
|
||||
def test_cache_entry_creation():
|
||||
entry = CacheEntry(cached_at=int(time.time()), size=5)
|
||||
assert entry.fingerprint is None
|
||||
assert entry.size == 5
|
||||
|
||||
|
||||
def test_cache_entry_mutable():
|
||||
entry = CacheEntry(cached_at=100, size=1)
|
||||
entry.cached_at -= 20
|
||||
assert entry.cached_at == 80
|
||||
|
||||
|
||||
def test_expired_with_ttl():
|
||||
entry = CacheEntry(cached_at=1000, size=1, ttl=10)
|
||||
assert entry.expired is True
|
||||
|
||||
|
||||
def test_not_expired_without_ttl():
|
||||
entry = CacheEntry(cached_at=int(time.time()), size=1)
|
||||
assert entry.ttl is None
|
||||
assert entry.expired is False
|
||||
|
||||
|
||||
def test_not_expired_within_ttl():
|
||||
entry = CacheEntry(cached_at=int(time.time()), size=1, ttl=3600)
|
||||
assert entry.expired is False
|
||||
Vendored
+300
@@ -0,0 +1,300 @@
|
||||
# ========= 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.cache.file import io as cache_io
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
from mirage.io import CachableAsyncIterator, IOResult
|
||||
from mirage.observe.record import OpRecord
|
||||
|
||||
|
||||
def _read_record(path: str, fingerprint: str | None) -> OpRecord:
|
||||
return OpRecord(op="read",
|
||||
path=path,
|
||||
source="s3",
|
||||
bytes=0,
|
||||
timestamp=0,
|
||||
duration_ms=0,
|
||||
fingerprint=fingerprint)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
return RAMFileCacheStore()
|
||||
|
||||
|
||||
# ── cache population via apply_io ────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_caches_reads(cache):
|
||||
"""Command reads a file → apply_io stores it in cache."""
|
||||
io = IOResult(
|
||||
reads={"/data/file.txt": b"hello"},
|
||||
cache=["/data/file.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/data/file.txt") == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_caches_writes(cache):
|
||||
"""Command writes a file and marks it cacheable → stored in cache."""
|
||||
io = IOResult(
|
||||
writes={"/data/out.txt": b"output"},
|
||||
cache=["/data/out.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/data/out.txt") == b"output"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_reads_preferred_over_writes(cache):
|
||||
"""When both reads and writes exist for same path, read data wins."""
|
||||
io = IOResult(
|
||||
reads={"/f.txt": b"read-data"},
|
||||
writes={"/f.txt": b"write-data"},
|
||||
cache=["/f.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/f.txt") == b"read-data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_multiple_paths(cache):
|
||||
"""Multiple paths in cache list are all stored."""
|
||||
io = IOResult(
|
||||
reads={
|
||||
"/a.txt": b"aaa",
|
||||
"/b.txt": b"bbb"
|
||||
},
|
||||
cache=["/a.txt", "/b.txt"],
|
||||
)
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/a.txt") == b"aaa"
|
||||
assert await cache.get("/b.txt") == b"bbb"
|
||||
|
||||
|
||||
# ── backend fingerprint threading ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_sets_backend_fingerprint_from_records(cache):
|
||||
"""A read record with a backend fingerprint (ETag, cTag, sha256)
|
||||
stamps the cache entry, so ALWAYS-mode is_fresh can match it."""
|
||||
io = IOResult(reads={"/s3/f.txt": b"hello"}, cache=["/s3/f.txt"])
|
||||
records = [_read_record("/s3/f.txt", "etag-multipart-2")]
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
assert await cache.get("/s3/f.txt") == b"hello"
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-multipart-2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_exhausted_stream_uses_record_fingerprint(cache):
|
||||
"""An exhausted stream read carries its record fingerprint too."""
|
||||
stream = _make_stream(b"hello")
|
||||
assert await stream.drain() == b"hello"
|
||||
io = IOResult(reads={"/s3/f.txt": stream}, cache=["/s3/f.txt"])
|
||||
records = [_read_record("/s3/f.txt", "etag-multipart-2")]
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-multipart-2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_warm_reapply_preserves_fingerprint(cache):
|
||||
"""A warm read re-applies cache-served bytes with no backend read
|
||||
record; the entry's backend fingerprint must survive, not be
|
||||
replaced by the MD5-of-content default."""
|
||||
cold = IOResult(reads={"/s3/f.txt": b"hello"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache,
|
||||
cold,
|
||||
records=[_read_record("/s3/f.txt", "etag-3")])
|
||||
warm = IOResult(reads={"/s3/f.txt": b"hello"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache, warm, records=[])
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_changed_data_without_record_resets_entry(cache):
|
||||
"""New bytes with no backend fingerprint still replace the entry."""
|
||||
cold = IOResult(reads={"/s3/f.txt": b"old"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache,
|
||||
cold,
|
||||
records=[_read_record("/s3/f.txt", "etag-3")])
|
||||
fresh = IOResult(writes={"/s3/f.txt": b"new"}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache, fresh, records=[])
|
||||
assert await cache.get("/s3/f.txt") == b"new"
|
||||
assert not await cache.is_fresh("/s3/f.txt", "etag-3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_drain_uses_fingerprint_recorded_during_drain():
|
||||
"""Streaming backends set the record fingerprint lazily when the GET
|
||||
response arrives, i.e. during the background drain. The drained
|
||||
entry must pick it up."""
|
||||
cache = RAMFileCacheStore()
|
||||
records: list[OpRecord] = []
|
||||
|
||||
async def _gen():
|
||||
records.append(_read_record("/s3/f.txt", "etag-multipart-2"))
|
||||
yield b"hello"
|
||||
|
||||
stream = CachableAsyncIterator(_gen())
|
||||
io = IOResult(reads={"/s3/f.txt": stream}, cache=["/s3/f.txt"])
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/s3/f.txt") == b"hello"
|
||||
assert await cache.is_fresh("/s3/f.txt", "etag-multipart-2")
|
||||
|
||||
|
||||
# ── cache invalidation ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_write_without_cache_invalidates(cache):
|
||||
"""Write to a path NOT in cache list → invalidate (remove from cache)."""
|
||||
await cache.set("/f.txt", b"old")
|
||||
io = IOResult(writes={"/f.txt": b"new"})
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/f.txt") is None
|
||||
|
||||
|
||||
# ── edge cases ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_no_cache_no_data_skips(cache):
|
||||
"""Path in cache list but no data in reads/writes → skip, don't cache."""
|
||||
io = IOResult(cache=["/missing.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert await cache.get("/missing.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_empty_io(cache):
|
||||
"""Empty IOResult → no-op."""
|
||||
io = IOResult()
|
||||
await cache_io.apply_io(cache, io)
|
||||
|
||||
|
||||
# ── background drain ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_stream(data: bytes) -> CachableAsyncIterator:
|
||||
|
||||
async def _gen():
|
||||
yield data
|
||||
|
||||
return CachableAsyncIterator(_gen())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_duplicate_drain():
|
||||
"""If a drain is already running for a path, a second apply_io
|
||||
should not start another drain. The first drain finishes and
|
||||
caches the data; the second stream is ignored."""
|
||||
cache = RAMFileCacheStore()
|
||||
stream1 = _make_stream(b"first")
|
||||
stream2 = _make_stream(b"second")
|
||||
io1 = IOResult(reads={"/f.txt": stream1}, cache=["/f.txt"])
|
||||
await cache_io.apply_io(cache, io1)
|
||||
assert "/f.txt" in cache._drain_tasks
|
||||
io2 = IOResult(reads={"/f.txt": stream2}, cache=["/f.txt"])
|
||||
await cache_io.apply_io(cache, io2)
|
||||
assert len([k for k in cache._drain_tasks if k == "/f.txt"]) == 1
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/f.txt") == b"first"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_drain_if_already_cached():
|
||||
"""If the path is already in cache, don't start a drain even if
|
||||
apply_io receives an unconsumed stream for it. Existing cached
|
||||
data is preserved."""
|
||||
cache = RAMFileCacheStore()
|
||||
await cache.set("/f.txt", b"cached")
|
||||
stream = _make_stream(b"new")
|
||||
io = IOResult(reads={"/f.txt": stream}, cache=["/f.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert "/f.txt" not in cache._drain_tasks
|
||||
assert await cache.get("/f.txt") == b"cached"
|
||||
|
||||
|
||||
# ── max_drain_bytes (cancellable cache drain) ───────────────────────────
|
||||
|
||||
|
||||
def _make_chunked_stream(chunks: list[bytes]) -> CachableAsyncIterator:
|
||||
|
||||
async def _gen():
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
return CachableAsyncIterator(_gen())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_unbounded_when_threshold_none():
|
||||
"""Default behavior: full drain into cache regardless of size."""
|
||||
cache = RAMFileCacheStore() # max_drain_bytes=None
|
||||
chunks = [b"a" * 100 for _ in range(10)] # 1000 bytes total
|
||||
stream = _make_chunked_stream(chunks)
|
||||
io = IOResult(reads={"/big.txt": stream}, cache=["/big.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
await asyncio.sleep(0.05)
|
||||
cached = await cache.get("/big.txt")
|
||||
assert cached is not None and len(cached) == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_completes_below_threshold():
|
||||
"""Source is smaller than threshold → full drain, cache populated."""
|
||||
cache = RAMFileCacheStore(max_drain_bytes=10000)
|
||||
chunks = [b"x" * 100 for _ in range(5)] # 500 bytes total
|
||||
stream = _make_chunked_stream(chunks)
|
||||
io = IOResult(reads={"/small.txt": stream}, cache=["/small.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
await asyncio.sleep(0.05)
|
||||
cached = await cache.get("/small.txt")
|
||||
assert cached is not None and len(cached) == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_cancelled_above_threshold():
|
||||
"""Source exceeds threshold → drain stops, partial buffer NOT cached."""
|
||||
cache = RAMFileCacheStore(max_drain_bytes=300)
|
||||
chunks = [b"z" * 100 for _ in range(20)] # 2000 bytes total
|
||||
stream = _make_chunked_stream(chunks)
|
||||
io = IOResult(reads={"/huge.txt": stream}, cache=["/huge.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/huge.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_threshold_per_task_not_shared():
|
||||
"""Each drain task has its own counter, not a shared workspace pool."""
|
||||
cache = RAMFileCacheStore(max_drain_bytes=300)
|
||||
s1 = _make_chunked_stream([b"a" * 100, b"a" * 100]) # 200 < 300
|
||||
s2 = _make_chunked_stream([b"b" * 100, b"b" * 100]) # 200 < 300
|
||||
io1 = IOResult(reads={"/a.txt": s1}, cache=["/a.txt"])
|
||||
io2 = IOResult(reads={"/b.txt": s2}, cache=["/b.txt"])
|
||||
await cache_io.apply_io(cache, io1)
|
||||
await cache_io.apply_io(cache, io2)
|
||||
await asyncio.sleep(0.05)
|
||||
# Both fit individually under the per-task budget → both cached.
|
||||
assert await cache.get("/a.txt") is not None
|
||||
assert await cache.get("/b.txt") is not None
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
|
||||
|
||||
class TestGetSet:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"hello")
|
||||
result = await cache.get("/a")
|
||||
assert result == b"hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_miss(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
assert await cache.get("/missing") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_overwrites(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"first")
|
||||
await cache.set("/a", b"second")
|
||||
assert await cache.get("/a") == b"second"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_fingerprint(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
entry = cache._entries.get("/a")
|
||||
assert entry is not None
|
||||
assert entry.fingerprint is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_fingerprint(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", fingerprint="etag-123")
|
||||
entry = cache._entries["/a"]
|
||||
assert entry.fingerprint == "etag-123"
|
||||
|
||||
|
||||
class TestAdd:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_new_key(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
result = await cache.add("/a", b"data")
|
||||
assert result is True
|
||||
assert await cache.get("/a") == b"data"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_existing_key(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"first")
|
||||
result = await cache.add("/a", b"second")
|
||||
assert result is False
|
||||
assert await cache.get("/a") == b"first"
|
||||
|
||||
|
||||
class TestMulti:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_get(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
results = await cache.multi_get(["/a", "/missing", "/b"])
|
||||
assert results[0] == b"aaa"
|
||||
assert results[1] is None
|
||||
assert results[2] == b"bbb"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_set(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.multi_set([("/a", b"aaa"), ("/b", b"bbb")])
|
||||
assert await cache.get("/a") == b"aaa"
|
||||
assert await cache.get("/b") == b"bbb"
|
||||
|
||||
|
||||
class TestExistsRemoveClear:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
assert await cache.exists("/a") is False
|
||||
await cache.set("/a", b"data")
|
||||
assert await cache.exists("/a") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
await cache.remove("/a")
|
||||
assert await cache.get("/a") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_missing(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.remove("/missing")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_cancels_drain_task(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
|
||||
cancelled = False
|
||||
|
||||
async def slow_drain():
|
||||
nonlocal cancelled
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
|
||||
task = asyncio.create_task(slow_drain())
|
||||
cache._drain_tasks["/a"] = task
|
||||
await cache.set("/a", b"data")
|
||||
await asyncio.sleep(0)
|
||||
await cache.remove("/a")
|
||||
await asyncio.sleep(0)
|
||||
assert cancelled
|
||||
assert "/a" not in cache._drain_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
await cache.clear()
|
||||
assert await cache.get("/a") is None
|
||||
assert await cache.get("/b") is None
|
||||
assert cache.cache_size == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_concurrent_with_set(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
|
||||
async def do_clear():
|
||||
await cache.clear()
|
||||
|
||||
async def do_set():
|
||||
await cache.set("/b", b"bbb")
|
||||
|
||||
await asyncio.gather(do_clear(), do_set())
|
||||
actual_size = sum(e.size for e in cache._entries.values())
|
||||
assert cache._cache_size == actual_size
|
||||
|
||||
|
||||
class TestTTL:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_not_expired(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", ttl=60)
|
||||
assert await cache.get("/a") == b"data"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ttl_expired(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
with patch("mirage.cache.file.entry.time.time", return_value=1000.0):
|
||||
await cache.set("/a", b"data", ttl=10)
|
||||
with patch("mirage.cache.file.entry.time.time", return_value=1011.0):
|
||||
result = await cache.get("/a")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_ttl_never_expires(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
entry = cache._entries["/a"]
|
||||
assert entry.ttl is None
|
||||
assert entry.expired is False
|
||||
|
||||
|
||||
class TestFingerprint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_match(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", fingerprint="etag-1")
|
||||
assert await cache.is_fresh("/a", "etag-1") is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_mismatch(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data", fingerprint="etag-1")
|
||||
assert await cache.is_fresh("/a", "etag-2") is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_missing(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
assert await cache.is_fresh("/missing", "etag-1") is False
|
||||
|
||||
|
||||
class TestEviction:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lru_evicts_oldest(self):
|
||||
cache = RAMFileCacheStore(cache_limit=100)
|
||||
await cache.set("/a", b"x" * 60)
|
||||
await cache.set("/b", b"y" * 60)
|
||||
assert await cache.get("/a") is None
|
||||
assert await cache.get("/b") == b"y" * 60
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lru_access_refreshes(self):
|
||||
cache = RAMFileCacheStore(cache_limit=150)
|
||||
await cache.set("/a", b"x" * 50)
|
||||
await cache.set("/b", b"y" * 50)
|
||||
await cache.get("/a")
|
||||
await cache.set("/c", b"z" * 60)
|
||||
assert await cache.get("/a") is not None
|
||||
assert await cache.get("/b") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_size_tracking(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"x" * 100)
|
||||
assert cache.cache_size == 100
|
||||
await cache.set("/b", b"y" * 200)
|
||||
assert cache.cache_size == 300
|
||||
await cache.remove("/a")
|
||||
assert cache.cache_size == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_limit(self):
|
||||
cache = RAMFileCacheStore(cache_limit="1KB")
|
||||
assert cache.cache_limit == 1024
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eviction_does_not_corrupt_size(self):
|
||||
cache = RAMFileCacheStore(cache_limit=100)
|
||||
await cache.set("/a", b"x" * 60)
|
||||
|
||||
async def concurrent_set():
|
||||
await cache.set("/a", b"z" * 40)
|
||||
|
||||
async def trigger_eviction():
|
||||
await cache.set("/b", b"y" * 60)
|
||||
|
||||
await asyncio.gather(concurrent_set(), trigger_eviction())
|
||||
actual_size = sum(e.size for e in cache._entries.values())
|
||||
assert cache._cache_size == actual_size
|
||||
Vendored
+101
@@ -0,0 +1,101 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_stored_in_store_files():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/f.txt", b"hello")
|
||||
assert cache._store.files["/f.txt"] == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entry_stored_in_entries():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/f.txt", b"hello")
|
||||
entry = cache._entries["/f.txt"]
|
||||
assert entry.size == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_cleans_store():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/f.txt", b"data")
|
||||
assert "/f.txt" in cache._store.files
|
||||
await cache.remove("/f.txt")
|
||||
assert "/f.txt" not in cache._store.files
|
||||
assert "/f.txt" not in cache._entries
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_empties_store():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
await cache.clear()
|
||||
assert len(cache._store.files) == 0
|
||||
assert len(cache._entries) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_remove():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"data")
|
||||
await cache.remove("/a")
|
||||
assert "/a" not in cache._key_locks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_clear():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
await cache.set("/a", b"aaa")
|
||||
await cache.set("/b", b"bbb")
|
||||
await cache.clear()
|
||||
assert len(cache._key_locks) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_eviction():
|
||||
cache = RAMFileCacheStore(cache_limit=100)
|
||||
await cache.set("/a", b"x" * 60)
|
||||
await cache.set("/b", b"y" * 60)
|
||||
assert "/a" not in cache._key_locks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_task_cancelled_on_remove():
|
||||
cache = RAMFileCacheStore(cache_limit="1MB")
|
||||
|
||||
cancelled = False
|
||||
|
||||
async def slow():
|
||||
nonlocal cancelled
|
||||
try:
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
cancelled = True
|
||||
|
||||
task = asyncio.create_task(slow())
|
||||
cache._drain_tasks["/a"] = task
|
||||
await cache.set("/a", b"data")
|
||||
await asyncio.sleep(0)
|
||||
await cache.remove("/a")
|
||||
await asyncio.sleep(0)
|
||||
assert cancelled
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from mirage.cache.file import io as cache_io
|
||||
from mirage.cache.file.redis import RedisFileCacheStore
|
||||
from mirage.io import CachableAsyncIterator, IOResult
|
||||
from mirage.observe.record import OpRecord
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
pytestmark = pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def cache():
|
||||
c = RedisFileCacheStore(
|
||||
cache_limit="1MB",
|
||||
url=REDIS_URL,
|
||||
key_prefix="test:cache:",
|
||||
)
|
||||
await c.clear()
|
||||
yield c
|
||||
await c.clear()
|
||||
await c._store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_and_get(cache):
|
||||
await cache.set("/file.txt", b"hello")
|
||||
result = await cache.get("/file.txt")
|
||||
assert result == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_missing(cache):
|
||||
result = await cache.get("/nope")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove(cache):
|
||||
await cache.set("/file.txt", b"data")
|
||||
await cache.remove("/file.txt")
|
||||
assert await cache.get("/file.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exists(cache):
|
||||
assert await cache.exists("/file.txt") is False
|
||||
await cache.set("/file.txt", b"data")
|
||||
assert await cache.exists("/file.txt") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh(cache):
|
||||
await cache.set("/file.txt", b"data", fingerprint="abc123")
|
||||
assert await cache.is_fresh("/file.txt", "abc123") is True
|
||||
assert await cache.is_fresh("/file.txt", "different") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fresh_missing(cache):
|
||||
assert await cache.is_fresh("/nope", "abc") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(cache):
|
||||
await cache.set("/a.txt", b"a")
|
||||
await cache.set("/b.txt", b"b")
|
||||
await cache.clear()
|
||||
assert await cache.get("/a.txt") is None
|
||||
assert await cache.get("/b.txt") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_new(cache):
|
||||
result = await cache.add("/file.txt", b"data")
|
||||
assert result is True
|
||||
assert await cache.get("/file.txt") == b"data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_existing(cache):
|
||||
await cache.set("/file.txt", b"first")
|
||||
result = await cache.add("/file.txt", b"second")
|
||||
assert result is False
|
||||
assert await cache.get("/file.txt") == b"first"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_with_fingerprint(cache):
|
||||
await cache.set("/file.txt", b"data", fingerprint="fp1")
|
||||
assert await cache.is_fresh("/file.txt", "fp1") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_limit(cache):
|
||||
assert cache.cache_limit == 1 * 1024 * 1024
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_prefix_isolation():
|
||||
c1 = RedisFileCacheStore(url=REDIS_URL, key_prefix="test:cache:ns1:")
|
||||
c2 = RedisFileCacheStore(url=REDIS_URL, key_prefix="test:cache:ns2:")
|
||||
await c1.clear()
|
||||
await c2.clear()
|
||||
await c1.set("/shared", b"from-c1")
|
||||
assert await c2.get("/shared") is None
|
||||
assert await c1.get("/shared") == b"from-c1"
|
||||
await c1.clear()
|
||||
await c2.clear()
|
||||
await c1._store.close()
|
||||
await c2._store.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_io_drains_stream_into_cache(cache):
|
||||
"""An unexhausted stream must background-drain into the Redis cache
|
||||
like the RAM store does, carrying the record fingerprint."""
|
||||
|
||||
async def _gen():
|
||||
yield b"drained"
|
||||
|
||||
stream = CachableAsyncIterator(_gen())
|
||||
io = IOResult(reads={"/file.txt": stream}, cache=["/file.txt"])
|
||||
records = [
|
||||
OpRecord(op="read",
|
||||
path="/file.txt",
|
||||
source="s3",
|
||||
bytes=0,
|
||||
timestamp=0,
|
||||
duration_ms=0,
|
||||
fingerprint="etag-9")
|
||||
]
|
||||
await cache_io.apply_io(cache, io, records=records)
|
||||
tasks = list(cache._drain_tasks.values())
|
||||
assert tasks, "drain task must be registered"
|
||||
await asyncio.gather(*tasks)
|
||||
assert await cache.get("/file.txt") == b"drained"
|
||||
assert await cache.is_fresh("/file.txt", "etag-9") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_cancels_pending_drain(cache):
|
||||
started = asyncio.Event()
|
||||
|
||||
async def _gen():
|
||||
started.set()
|
||||
await asyncio.sleep(1)
|
||||
yield b"slow"
|
||||
|
||||
stream = CachableAsyncIterator(_gen())
|
||||
io = IOResult(reads={"/slow.txt": stream}, cache=["/slow.txt"])
|
||||
await cache_io.apply_io(cache, io)
|
||||
assert "/slow.txt" in cache._drain_tasks
|
||||
await started.wait()
|
||||
await cache.remove("/slow.txt")
|
||||
assert "/slow.txt" not in cache._drain_tasks
|
||||
await asyncio.sleep(0.05)
|
||||
assert await cache.get("/slow.txt") is None
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
# ========= 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.cache.file.utils import default_fingerprint, parse_limit
|
||||
|
||||
|
||||
def test_parse_limit_bytes():
|
||||
assert parse_limit(1024) == 1024
|
||||
|
||||
|
||||
def test_parse_limit_kb():
|
||||
assert parse_limit("1KB") == 1024
|
||||
|
||||
|
||||
def test_parse_limit_mb():
|
||||
assert parse_limit("512MB") == 536870912
|
||||
|
||||
|
||||
def test_parse_limit_gb():
|
||||
assert parse_limit("1GB") == 1073741824
|
||||
|
||||
|
||||
def test_parse_limit_lowercase():
|
||||
assert parse_limit("10mb") == 10485760
|
||||
|
||||
|
||||
def test_parse_limit_plain_string():
|
||||
assert parse_limit("1024") == 1024
|
||||
|
||||
|
||||
def test_default_fingerprint():
|
||||
fp = default_fingerprint(b"hello")
|
||||
assert isinstance(fp, str)
|
||||
assert len(fp) == 32
|
||||
|
||||
|
||||
def test_default_fingerprint_deterministic():
|
||||
assert default_fingerprint(b"same") == default_fingerprint(b"same")
|
||||
|
||||
|
||||
def test_default_fingerprint_different_data():
|
||||
assert default_fingerprint(b"a") != default_fingerprint(b"b")
|
||||
Vendored
+13
@@ -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. =========
|
||||
+79
@@ -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. =========
|
||||
|
||||
from mirage.cache.index import (IndexConfig, IndexEntry, ListResult,
|
||||
LookupResult, LookupStatus, RedisIndexConfig,
|
||||
ResourceType)
|
||||
from mirage.types import IndexType
|
||||
|
||||
|
||||
def test_index_entry_defaults():
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
assert entry.remote_time == ""
|
||||
assert entry.index_time == ""
|
||||
assert entry.vfs_name == ""
|
||||
assert entry.size is None
|
||||
|
||||
|
||||
def test_index_entry_with_size():
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file", size=1024)
|
||||
assert entry.size == 1024
|
||||
|
||||
|
||||
def test_lookup_result_not_found():
|
||||
result = LookupResult(status=LookupStatus.NOT_FOUND)
|
||||
assert result.entry is None
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
def test_lookup_result_with_entry():
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
result = LookupResult(entry=entry)
|
||||
assert result.entry is not None
|
||||
assert result.status is None
|
||||
|
||||
|
||||
def test_list_result_with_entries():
|
||||
result = ListResult(entries=["/a", "/b"])
|
||||
assert result.entries == ["/a", "/b"]
|
||||
assert result.status is None
|
||||
|
||||
|
||||
def test_list_result_expired():
|
||||
result = ListResult(status=LookupStatus.EXPIRED)
|
||||
assert result.entries is None
|
||||
|
||||
|
||||
def test_resource_type_enum():
|
||||
assert ResourceType.FILE == "file"
|
||||
assert ResourceType.FOLDER == "folder"
|
||||
|
||||
|
||||
def test_index_config_defaults():
|
||||
config = IndexConfig()
|
||||
assert config.ttl == 600
|
||||
assert config.type == IndexType.RAM
|
||||
|
||||
|
||||
def test_redis_index_config():
|
||||
config = RedisIndexConfig(ttl=300, key_prefix="s3:")
|
||||
assert config.type == IndexType.REDIS
|
||||
assert config.ttl == 300
|
||||
assert config.key_prefix == "s3:"
|
||||
assert config.url == "redis://localhost:6379/0"
|
||||
|
||||
|
||||
def test_redis_index_config_is_index_config():
|
||||
config = RedisIndexConfig()
|
||||
assert isinstance(config, IndexConfig)
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry, RAMIndexCacheStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return RAMIndexCacheStore(ttl=60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entries_stored_in_dict(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.put("/f.txt", entry)
|
||||
assert "/f.txt" in store._entries
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_children_stored_in_dict(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
assert "/dir" in store._children
|
||||
assert store._children["/dir"] == ["/dir/f.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expiry_stored_in_dict(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
assert "/dir" in store._expiry
|
||||
assert isinstance(store._expiry["/dir"], datetime)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_dir_evicts_child_entries(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
assert (await store.get("/dir/f.txt")).entry is not None
|
||||
await store.invalidate_dir("/dir")
|
||||
assert (await store.get("/dir/f.txt")).entry is None
|
||||
assert "/dir" not in store._children
|
||||
assert "/dir" not in store._expiry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_empties_all_dicts(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.put("/f.txt", entry)
|
||||
await store.set_dir("/dir", [("f.txt", entry)])
|
||||
await store.clear()
|
||||
assert len(store._entries) == 0
|
||||
assert len(store._children) == 0
|
||||
assert len(store._expiry) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_locks_cleaned_after_clear(store):
|
||||
entry = IndexEntry(id="1", name="f", resource_type="file")
|
||||
await store.put("/a", entry)
|
||||
await store.put("/b", entry)
|
||||
await store.clear()
|
||||
assert len(store._key_locks) == 0
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_preserves_insertion_order():
|
||||
store = RAMIndexCacheStore(ttl=60)
|
||||
entries = [
|
||||
("2026-05-03_zebra__id3.gdoc.json",
|
||||
IndexEntry(id="3", name="zebra", resource_type="gdocs/file")),
|
||||
("2026-05-02_apple__id2.gdoc.json",
|
||||
IndexEntry(id="2", name="apple", resource_type="gdocs/file")),
|
||||
("2026-05-01_mango__id1.gdoc.json",
|
||||
IndexEntry(id="1", name="mango", resource_type="gdocs/file")),
|
||||
]
|
||||
await store.set_dir("/gdocs/owned", entries)
|
||||
result = await store.list_dir("/gdocs/owned")
|
||||
assert result.entries == [
|
||||
"/gdocs/owned/2026-05-03_zebra__id3.gdoc.json",
|
||||
"/gdocs/owned/2026-05-02_apple__id2.gdoc.json",
|
||||
"/gdocs/owned/2026-05-01_mango__id1.gdoc.json",
|
||||
]
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from mirage.cache.index import IndexEntry, LookupStatus
|
||||
from mirage.cache.index.redis import RedisIndexCacheStore
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
pytestmark = pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def store():
|
||||
s = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="test:")
|
||||
await s.clear()
|
||||
yield s
|
||||
await s.clear()
|
||||
await s.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
def entry():
|
||||
return IndexEntry(
|
||||
id="file1",
|
||||
name="Test File",
|
||||
resource_type="text/plain",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="test_file.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_and_get(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.id == "file1"
|
||||
assert result.entry.name == "Test File"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_sets_index_time(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.index_time != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_preserves_existing_index_time(store):
|
||||
entry = IndexEntry(id="b",
|
||||
name="b.txt",
|
||||
resource_type="file",
|
||||
index_time="2026-01-01T00:00:00")
|
||||
await store.put("/data/b.txt", entry)
|
||||
result = await store.get("/data/b.txt")
|
||||
assert result.entry.index_time == "2026-01-01T00:00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_not_found(store):
|
||||
result = await store.get("/nonexistent/file.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
assert result.entry is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_not_found(store):
|
||||
result = await store.list_dir("/some_dir")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_and_list(store):
|
||||
entries = [
|
||||
("a.txt",
|
||||
IndexEntry(id="a",
|
||||
name="A",
|
||||
resource_type="text/plain",
|
||||
vfs_name="a.txt")),
|
||||
("b.txt",
|
||||
IndexEntry(id="b",
|
||||
name="B",
|
||||
resource_type="text/plain",
|
||||
vfs_name="b.txt")),
|
||||
]
|
||||
await store.set_dir("/mydir", entries)
|
||||
result = await store.list_dir("/mydir")
|
||||
assert result.status is None
|
||||
assert sorted(result.entries) == ["/mydir/a.txt", "/mydir/b.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_root(store):
|
||||
entries = [
|
||||
("top.txt",
|
||||
IndexEntry(id="t",
|
||||
name="T",
|
||||
resource_type="text/plain",
|
||||
vfs_name="top.txt")),
|
||||
]
|
||||
await store.set_dir("/", entries)
|
||||
result = await store.list_dir("/")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/top.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_entries_are_retrievable(store):
|
||||
entries = [("x.csv", IndexEntry(id="x1",
|
||||
name="x.csv",
|
||||
resource_type="file"))]
|
||||
await store.set_dir("/root", entries)
|
||||
result = await store.get("/root/x.csv")
|
||||
assert result.entry is not None
|
||||
assert result.entry.id == "x1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_expired(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt")),
|
||||
]
|
||||
past = datetime.now(timezone.utc) + timedelta(seconds=1)
|
||||
await store.set_dir("/dir", entries, expired_at=past)
|
||||
time.sleep(1.5)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status in (LookupStatus.NOT_FOUND, LookupStatus.EXPIRED)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_fresh(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt")),
|
||||
]
|
||||
future = datetime.now(timezone.utc) + timedelta(seconds=3600)
|
||||
await store.set_dir("/dir", entries, expired_at=future)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/dir/file.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_overwrites_previous(store):
|
||||
await store.set_dir("/d", [
|
||||
("old.txt", IndexEntry(id="o", name="old.txt", resource_type="file"))
|
||||
])
|
||||
await store.set_dir("/d", [
|
||||
("new.txt", IndexEntry(id="n", name="new.txt", resource_type="file"))
|
||||
])
|
||||
result = await store.list_dir("/d")
|
||||
assert result.entries is not None
|
||||
assert len(result.entries) == 1
|
||||
assert "new.txt" in result.entries[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_dir_evicts_child_entries(store, entry):
|
||||
await store.set_dir("/folder", [("test.txt", entry)])
|
||||
assert (await store.get("/folder/test.txt")).entry is not None
|
||||
await store.invalidate_dir("/folder")
|
||||
assert (await
|
||||
store.get("/folder/test.txt")).status == LookupStatus.NOT_FOUND
|
||||
assert (await store.list_dir("/folder")).status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(store, entry):
|
||||
await store.put("/folder/test.txt", entry)
|
||||
await store.set_dir("/folder", [("test.txt", entry)])
|
||||
await store.clear()
|
||||
result = await store.get("/folder/test.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
result = await store.list_dir("/folder")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_key_prefix_isolation():
|
||||
s1 = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="ns1:")
|
||||
s2 = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="ns2:")
|
||||
await s1.clear()
|
||||
await s2.clear()
|
||||
await s1.put("/shared", IndexEntry(id="a", name="a", resource_type="file"))
|
||||
assert (await s2.get("/shared")).status == LookupStatus.NOT_FOUND
|
||||
assert (await s1.get("/shared")).entry is not None
|
||||
await s1.clear()
|
||||
await s2.clear()
|
||||
await s1.close()
|
||||
await s2.close()
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.redis import RedisIndexCacheStore
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "")
|
||||
pytestmark = pytest.mark.skipif(not REDIS_URL, reason="REDIS_URL not set")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture()
|
||||
async def store():
|
||||
s = RedisIndexCacheStore(ttl=60, url=REDIS_URL, key_prefix="test:order:")
|
||||
await s.clear()
|
||||
yield s
|
||||
await s.clear()
|
||||
await s.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_preserves_insertion_order(store):
|
||||
entries = [
|
||||
("2026-05-03_zebra__id3.gdoc.json",
|
||||
IndexEntry(id="3", name="zebra", resource_type="gdocs/file")),
|
||||
("2026-05-02_apple__id2.gdoc.json",
|
||||
IndexEntry(id="2", name="apple", resource_type="gdocs/file")),
|
||||
("2026-05-01_mango__id1.gdoc.json",
|
||||
IndexEntry(id="1", name="mango", resource_type="gdocs/file")),
|
||||
]
|
||||
await store.set_dir("/gdocs/owned", entries)
|
||||
result = await store.list_dir("/gdocs/owned")
|
||||
assert result.entries == [
|
||||
"/gdocs/owned/2026-05-03_zebra__id3.gdoc.json",
|
||||
"/gdocs/owned/2026-05-02_apple__id2.gdoc.json",
|
||||
"/gdocs/owned/2026-05-01_mango__id1.gdoc.json",
|
||||
]
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
# ========= 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 datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import IndexEntry, LookupStatus, RAMIndexCacheStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store():
|
||||
return RAMIndexCacheStore(ttl=60)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entry():
|
||||
return IndexEntry(
|
||||
id="file1",
|
||||
name="Test File",
|
||||
resource_type="text/plain",
|
||||
remote_time="2026-04-01T00:00:00.000Z",
|
||||
vfs_name="test_file.txt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_and_get(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.id == "file1"
|
||||
assert result.entry.name == "Test File"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_sets_index_time(store, entry):
|
||||
await store.put("/folder/test_file.txt", entry)
|
||||
result = await store.get("/folder/test_file.txt")
|
||||
assert result.entry is not None
|
||||
assert result.entry.index_time != ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_not_found(store):
|
||||
result = await store.get("/nonexistent/file.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
assert result.entry is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_not_found(store):
|
||||
result = await store.list_dir("/some_dir")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_and_list(store):
|
||||
entries = [
|
||||
("a.txt",
|
||||
IndexEntry(
|
||||
id="a",
|
||||
name="A",
|
||||
resource_type="text/plain",
|
||||
vfs_name="a.txt",
|
||||
)),
|
||||
("b.txt",
|
||||
IndexEntry(
|
||||
id="b",
|
||||
name="B",
|
||||
resource_type="text/plain",
|
||||
vfs_name="b.txt",
|
||||
)),
|
||||
]
|
||||
await store.set_dir("/mydir", entries)
|
||||
result = await store.list_dir("/mydir")
|
||||
assert result.status is None
|
||||
assert sorted(result.entries) == ["/mydir/a.txt", "/mydir/b.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_dir_root(store):
|
||||
entries = [
|
||||
("top.txt",
|
||||
IndexEntry(
|
||||
id="t",
|
||||
name="T",
|
||||
resource_type="text/plain",
|
||||
vfs_name="top.txt",
|
||||
)),
|
||||
]
|
||||
await store.set_dir("/", entries)
|
||||
result = await store.list_dir("/")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/top.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_expired(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(
|
||||
id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt",
|
||||
)),
|
||||
]
|
||||
past = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
await store.set_dir("/dir", entries, expired_at=past)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status == LookupStatus.EXPIRED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dir_fresh(store):
|
||||
entries = [
|
||||
("file.txt",
|
||||
IndexEntry(
|
||||
id="f",
|
||||
name="F",
|
||||
resource_type="text/plain",
|
||||
vfs_name="file.txt",
|
||||
)),
|
||||
]
|
||||
future = datetime.now(timezone.utc) + timedelta(seconds=3600)
|
||||
await store.set_dir("/dir", entries, expired_at=future)
|
||||
result = await store.list_dir("/dir")
|
||||
assert result.status is None
|
||||
assert result.entries == ["/dir/file.txt"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear(store, entry):
|
||||
await store.put("/folder/test.txt", entry)
|
||||
await store.set_dir("/folder", [("test.txt", entry)])
|
||||
|
||||
await store.clear()
|
||||
result = await store.get("/folder/test.txt")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
result = await store.list_dir("/folder")
|
||||
assert result.status == LookupStatus.NOT_FOUND
|
||||
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
from mirage.cache.context import (active_cache_manager,
|
||||
invalidate_after_unlink,
|
||||
invalidate_after_write, push_cache_manager)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
class FakeManager:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.writes: list[str] = []
|
||||
self.unlinks: list[str] = []
|
||||
|
||||
async def invalidate_after_write(self, path: str) -> None:
|
||||
self.writes.append(path)
|
||||
|
||||
async def invalidate_after_unlink(self, path: str) -> None:
|
||||
self.unlinks.append(path)
|
||||
|
||||
|
||||
async def _delegates() -> FakeManager:
|
||||
manager = FakeManager()
|
||||
prev = push_cache_manager(manager)
|
||||
await invalidate_after_write("/a.txt")
|
||||
await invalidate_after_unlink("/b.txt")
|
||||
push_cache_manager(prev)
|
||||
return manager
|
||||
|
||||
|
||||
def test_delegates_to_active_manager():
|
||||
manager = _run(_delegates())
|
||||
assert manager.writes == ["/a.txt"]
|
||||
assert manager.unlinks == ["/b.txt"]
|
||||
|
||||
|
||||
async def _noop_without_manager() -> None:
|
||||
push_cache_manager(None)
|
||||
await invalidate_after_write("/a.txt")
|
||||
await invalidate_after_unlink("/b.txt")
|
||||
|
||||
|
||||
def test_noop_without_active_manager():
|
||||
_run(_noop_without_manager())
|
||||
|
||||
|
||||
async def _push_restores() -> tuple[object, object, object]:
|
||||
first = FakeManager()
|
||||
second = FakeManager()
|
||||
prev0 = push_cache_manager(first)
|
||||
prev1 = push_cache_manager(second)
|
||||
active = active_cache_manager()
|
||||
push_cache_manager(prev1)
|
||||
restored = active_cache_manager()
|
||||
push_cache_manager(prev0)
|
||||
return prev1, active, restored
|
||||
|
||||
|
||||
def test_push_returns_previous_manager():
|
||||
prev1, active, restored = _run(_push_restores())
|
||||
assert prev1 is not None
|
||||
assert active is not restored
|
||||
assert restored is prev1
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
# ========= 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.cache.index.config import IndexEntry
|
||||
|
||||
|
||||
def test_index_entry_extra_defaults_to_empty_dict():
|
||||
entry = IndexEntry(id="x", name="x", resource_type="t")
|
||||
assert entry.extra == {}
|
||||
|
||||
|
||||
def test_index_entry_extra_round_trip():
|
||||
entry = IndexEntry(
|
||||
id="F1",
|
||||
name="report",
|
||||
resource_type="slack/file",
|
||||
extra={
|
||||
"url": "https://files.slack.com/x",
|
||||
"mimetype": "application/pdf"
|
||||
},
|
||||
)
|
||||
assert entry.extra == {
|
||||
"url": "https://files.slack.com/x",
|
||||
"mimetype": "application/pdf",
|
||||
}
|
||||
|
||||
|
||||
def test_index_entry_extra_survives_json_round_trip():
|
||||
entry = IndexEntry(
|
||||
id="F1",
|
||||
name="report",
|
||||
resource_type="slack/file",
|
||||
extra={"url": "u"},
|
||||
)
|
||||
restored = IndexEntry.model_validate_json(entry.model_dump_json())
|
||||
assert restored.extra == {"url": "u"}
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.lock import KeyLockMixin
|
||||
|
||||
|
||||
class _Store(KeyLockMixin):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_keys_return_different_locks():
|
||||
store = _Store()
|
||||
assert store._lock_for("/a") is not store._lock_for("/b")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_key_returns_same_lock():
|
||||
store = _Store()
|
||||
assert store._lock_for("/a") is store._lock_for("/a")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_lock_removes_key():
|
||||
store = _Store()
|
||||
store._lock_for("/a")
|
||||
store._discard_lock("/a")
|
||||
assert "/a" not in store._key_locks
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_lock_missing_key_no_error():
|
||||
store = _Store()
|
||||
store._discard_lock("/nope")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_locks_removes_all():
|
||||
store = _Store()
|
||||
store._lock_for("/a")
|
||||
store._lock_for("/b")
|
||||
store._clear_locks()
|
||||
assert len(store._key_locks) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_different_keys_no_block():
|
||||
store = _Store()
|
||||
order = []
|
||||
|
||||
async def acquire(key, label):
|
||||
async with store._lock_for(key):
|
||||
order.append(f"{label}_start")
|
||||
await asyncio.sleep(0)
|
||||
order.append(f"{label}_end")
|
||||
|
||||
await asyncio.gather(acquire("/a", "a"), acquire("/b", "b"))
|
||||
assert "a_start" in order
|
||||
assert "b_start" in order
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_key_serialized():
|
||||
store = _Store()
|
||||
order = []
|
||||
|
||||
async def acquire(label):
|
||||
async with store._lock_for("/same"):
|
||||
order.append(f"{label}_start")
|
||||
await asyncio.sleep(0.01)
|
||||
order.append(f"{label}_end")
|
||||
|
||||
await asyncio.gather(acquire("first"), acquire("second"))
|
||||
assert order[0] == "first_start"
|
||||
assert order[1] == "first_end"
|
||||
assert order[2] == "second_start"
|
||||
assert order[3] == "second_end"
|
||||
Vendored
+155
@@ -0,0 +1,155 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
from mirage.cache.index.config import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.cache.manager import CacheManager
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _stores() -> tuple[RAMFileCacheStore, RAMIndexCacheStore]:
|
||||
return RAMFileCacheStore(), RAMIndexCacheStore(ttl=600)
|
||||
|
||||
|
||||
async def _seed(cache: RAMFileCacheStore, index: RAMIndexCacheStore) -> None:
|
||||
await cache.set("/data/arch/h.txt", b"two\n")
|
||||
await index.set_dir("/data/arch", [
|
||||
("h.txt", IndexEntry(id="h", name="h.txt", resource_type="file")),
|
||||
])
|
||||
|
||||
|
||||
async def _write_case() -> tuple[bool, bool]:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
await manager.invalidate_after_write("/arch/h.txt")
|
||||
cached = await cache.exists("/data/arch/h.txt")
|
||||
listing = await index.list_dir("/data/arch")
|
||||
return cached, listing.entries is not None
|
||||
|
||||
|
||||
def test_write_evicts_file_and_parent_listing():
|
||||
cached, listed = _run(_write_case())
|
||||
assert cached is False
|
||||
assert listed is False
|
||||
|
||||
|
||||
async def _unlink_case() -> tuple[bool, bool, object]:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
await manager.invalidate_after_unlink("/arch/h.txt")
|
||||
cached = await cache.exists("/data/arch/h.txt")
|
||||
listing = await index.list_dir("/data/arch")
|
||||
entry = await index.get("/data/arch/h.txt")
|
||||
return cached, listing.entries is not None, entry.entry
|
||||
|
||||
|
||||
def test_unlink_evicts_file_listing_and_entry():
|
||||
cached, listed, entry = _run(_unlink_case())
|
||||
assert cached is False
|
||||
assert listed is False
|
||||
assert entry is None
|
||||
|
||||
|
||||
async def _local_case() -> tuple[bool, bool]:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", False)
|
||||
await manager.invalidate_after_write("/arch/h.txt")
|
||||
cached = await cache.exists("/data/arch/h.txt")
|
||||
listing = await index.list_dir("/data/arch")
|
||||
return cached, listing.entries is not None
|
||||
|
||||
|
||||
def test_local_mount_keeps_file_cache_but_invalidates_index():
|
||||
cached, listed = _run(_local_case())
|
||||
assert cached is True
|
||||
assert listed is False
|
||||
|
||||
|
||||
async def _pathspec_case() -> bool:
|
||||
cache, index = _stores()
|
||||
await _seed(cache, index)
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
spec = PathSpec(resource_path=mount_key("/data/arch/h.txt", "/data/"),
|
||||
virtual="/data/arch/h.txt",
|
||||
directory="/data/arch")
|
||||
await manager.invalidate_after_write(spec)
|
||||
return await cache.exists("/data/arch/h.txt")
|
||||
|
||||
|
||||
def test_pathspec_input_maps_to_virtual_key():
|
||||
assert _run(_pathspec_case()) is False
|
||||
|
||||
|
||||
async def _cached_hit_case() -> bytes | None:
|
||||
cache, index = _stores()
|
||||
await cache.set("/data/x.txt", b"cached")
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
spec = PathSpec(resource_path=mount_key("/data/x.txt", "/data/"),
|
||||
virtual="/data/x.txt",
|
||||
directory="/data/")
|
||||
return await manager.cached_bytes(spec)
|
||||
|
||||
|
||||
def test_cached_bytes_returns_cached_value():
|
||||
assert _run(_cached_hit_case()) == b"cached"
|
||||
|
||||
|
||||
async def _cached_miss_case() -> bytes | None:
|
||||
cache, index = _stores()
|
||||
manager = CacheManager(cache, index, "/data/", True)
|
||||
spec = PathSpec(resource_path=mount_key("/data/x.txt", "/data/"),
|
||||
virtual="/data/x.txt",
|
||||
directory="/data/")
|
||||
return await manager.cached_bytes(spec)
|
||||
|
||||
|
||||
def test_cached_bytes_miss_returns_none():
|
||||
assert _run(_cached_miss_case()) is None
|
||||
|
||||
|
||||
async def _cached_local_case() -> bytes | None:
|
||||
cache, index = _stores()
|
||||
await cache.set("/data/x.txt", b"cached")
|
||||
manager = CacheManager(cache, index, "/data/", False)
|
||||
spec = PathSpec(resource_path=mount_key("/data/x.txt", "/data/"),
|
||||
virtual="/data/x.txt",
|
||||
directory="/data/")
|
||||
return await manager.cached_bytes(spec)
|
||||
|
||||
|
||||
def test_cached_bytes_local_mount_returns_none():
|
||||
assert _run(_cached_local_case()) is None
|
||||
|
||||
|
||||
async def _no_index_case() -> bool:
|
||||
cache, _ = _stores()
|
||||
await cache.set("/data/a.txt", b"x")
|
||||
manager = CacheManager(cache, None, "/data/", True)
|
||||
await manager.invalidate_after_write("/a.txt")
|
||||
return await cache.exists("/data/a.txt")
|
||||
|
||||
|
||||
def test_missing_index_is_tolerated():
|
||||
assert _run(_no_index_case()) is False
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.context import push_cache_manager
|
||||
from mirage.cache.file.ram import RAMFileCacheStore
|
||||
from mirage.cache.manager import CacheManager
|
||||
from mirage.cache.read_through import (cache_aware_read_bytes,
|
||||
cache_aware_read_stream,
|
||||
cached_prefix_bytes)
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
class _CountingBackend:
|
||||
|
||||
def __init__(self, data: bytes) -> None:
|
||||
self.data = data
|
||||
self.stream_calls = 0
|
||||
self.bytes_calls = 0
|
||||
|
||||
async def read_stream(self, accessor, path, *args, **kwargs):
|
||||
self.stream_calls += 1
|
||||
yield self.data
|
||||
|
||||
async def read_bytes(self, accessor, path, *args, **kwargs) -> bytes:
|
||||
self.bytes_calls += 1
|
||||
return self.data
|
||||
|
||||
|
||||
def _spec() -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key("/s3/a.txt", "/s3/"),
|
||||
virtual="/s3/a.txt",
|
||||
directory="/s3/")
|
||||
|
||||
|
||||
async def _warm_manager(data: bytes) -> CacheManager:
|
||||
cache = RAMFileCacheStore()
|
||||
await cache.set("/s3/a.txt", data)
|
||||
return CacheManager(cache, None, "/s3/", True)
|
||||
|
||||
|
||||
async def _drain(source) -> bytes:
|
||||
return b"".join([c async for c in source])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_warm_serves_cache_without_backend():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = await _warm_manager(b"payload")
|
||||
reader = cache_aware_read_bytes(backend.read_bytes)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await reader(None, _spec())
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.bytes_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_cold_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = CacheManager(RAMFileCacheStore(), None, "/s3/", True)
|
||||
reader = cache_aware_read_bytes(backend.read_bytes)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await reader(None, _spec())
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.bytes_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bytes_no_manager_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
reader = cache_aware_read_bytes(backend.read_bytes)
|
||||
out = await reader(None, _spec())
|
||||
assert out == b"payload"
|
||||
assert backend.bytes_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_warm_serves_cache_without_backend():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = await _warm_manager(b"payload")
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await _drain(reader(None, _spec()))
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_cold_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = CacheManager(RAMFileCacheStore(), None, "/s3/", True)
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await _drain(reader(None, _spec()))
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_no_manager_falls_through():
|
||||
backend = _CountingBackend(b"payload")
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
out = await _drain(reader(None, _spec()))
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_stream_captures_manager_before_drain():
|
||||
# The manager must be captured when the reader is called (inside the
|
||||
# mount's scope), not when the stream drains (after the scope is gone).
|
||||
backend = _CountingBackend(b"payload")
|
||||
manager = await _warm_manager(b"payload")
|
||||
reader = cache_aware_read_stream(backend.read_stream)
|
||||
prev = push_cache_manager(manager)
|
||||
source = reader(None, _spec())
|
||||
push_cache_manager(prev)
|
||||
out = await _drain(source)
|
||||
assert out == b"payload"
|
||||
assert backend.stream_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_prefix_bytes_slices_when_warm():
|
||||
manager = await _warm_manager(b"payload")
|
||||
prev = push_cache_manager(manager)
|
||||
try:
|
||||
out = await cached_prefix_bytes(_spec(), 4)
|
||||
whole = await cached_prefix_bytes(_spec(), None)
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert out == b"payl"
|
||||
assert whole == b"payload"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_prefix_bytes_miss_and_no_manager_return_none():
|
||||
miss_manager = CacheManager(RAMFileCacheStore(), None, "/s3/", True)
|
||||
prev = push_cache_manager(miss_manager)
|
||||
try:
|
||||
assert await cached_prefix_bytes(_spec(), 4) is None
|
||||
finally:
|
||||
push_cache_manager(prev)
|
||||
assert await cached_prefix_bytes(_spec(), 4) is None
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,84 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_for_health(url: str, timeout: float = 8.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
r = httpx.get(f"{url}/v1/health", timeout=0.3)
|
||||
if r.status_code == 200:
|
||||
return
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise RuntimeError(f"daemon at {url} did not start within {timeout}s")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daemon(tmp_path):
|
||||
"""Spin up a real daemon subprocess for the test, on a free port."""
|
||||
port = _free_port()
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
env = dict(os.environ)
|
||||
env["MIRAGE_DAEMON_URL"] = url
|
||||
env["MIRAGE_IDLE_GRACE_SECONDS"] = "60"
|
||||
env["MIRAGE_HOME"] = str(tmp_path)
|
||||
env["MIRAGE_VERSION_ROOT"] = str(tmp_path / "repos")
|
||||
env["MIRAGE_SNAPSHOT_ROOT"] = str(tmp_path)
|
||||
env.pop("MIRAGE_AUTH_TOKEN", None)
|
||||
env.pop("MIRAGE_TOKEN", None)
|
||||
log_file = tmp_path / "daemon.log"
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"mirage.server.daemon:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--log-level",
|
||||
"warning",
|
||||
],
|
||||
env=env,
|
||||
stdout=open(log_file, "wb"),
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
_wait_for_health(url)
|
||||
yield {"url": url, "env": env, "log": log_file}
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
@@ -0,0 +1,398 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
"""
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(CONFIG_YAML, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _run_cli(env: dict,
|
||||
*args: str,
|
||||
stdin: bytes | None = None,
|
||||
expect_exit: int = 0) -> dict | list:
|
||||
cmd = [sys.executable, "-m", "mirage.cli.main", *args]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if proc.returncode != expect_exit:
|
||||
raise AssertionError(
|
||||
f"exit={proc.returncode} (expected {expect_exit})\n"
|
||||
f"stdout: {proc.stdout.decode()}\nstderr: {proc.stderr.decode()}")
|
||||
if expect_exit != 0:
|
||||
return {}
|
||||
if not proc.stdout.strip():
|
||||
return {}
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_workspace_lifecycle(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg))
|
||||
wid = created["id"]
|
||||
assert wid.startswith("ws_")
|
||||
|
||||
listed = _run_cli(daemon["env"], "workspace", "list")
|
||||
assert any(w["id"] == wid for w in listed)
|
||||
|
||||
detail = _run_cli(daemon["env"], "workspace", "get", wid)
|
||||
assert detail["id"] == wid
|
||||
|
||||
deleted = _run_cli(daemon["env"], "workspace", "delete", wid)
|
||||
assert deleted["id"] == wid
|
||||
|
||||
|
||||
def test_workspace_create_with_explicit_id(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"custom-ws")
|
||||
assert created["id"] == "custom-ws"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "custom-ws")
|
||||
|
||||
|
||||
def test_session_lifecycle(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"sess-test-ws")
|
||||
wid = created["id"]
|
||||
sess = _run_cli(daemon["env"], "session", "create", wid, "--id", "agent_a")
|
||||
assert sess["session_id"] == "agent_a"
|
||||
|
||||
listed = _run_cli(daemon["env"], "session", "list", wid)
|
||||
assert any(s["session_id"] == "agent_a" for s in listed)
|
||||
|
||||
_run_cli(daemon["env"], "session", "delete", wid, "agent_a")
|
||||
_run_cli(daemon["env"], "workspace", "delete", wid)
|
||||
|
||||
|
||||
def test_execute_returns_json_io_result(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
created = _run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"exec-test")
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id",
|
||||
created["id"], "--command", "echo hello")
|
||||
assert result["kind"] == "io"
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"].startswith("hello")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "exec-test")
|
||||
|
||||
|
||||
def test_execute_background_returns_job_id(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id", "bg-test")
|
||||
submitted = _run_cli(daemon["env"], "execute", "--workspace_id", "bg-test",
|
||||
"--command", "echo bg", "--background")
|
||||
job_id = submitted["job_id"]
|
||||
assert job_id.startswith("job_")
|
||||
|
||||
waited = _run_cli(daemon["env"], "job", "wait", job_id)
|
||||
assert waited["status"] == "done"
|
||||
assert waited["result"]["stdout"].startswith("bg")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "bg-test")
|
||||
|
||||
|
||||
def test_execute_with_stdin_pipe(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"stdin-test")
|
||||
result = _run_cli(daemon["env"],
|
||||
"execute",
|
||||
"--workspace_id",
|
||||
"stdin-test",
|
||||
"--command",
|
||||
"wc -l",
|
||||
stdin=b"a\nb\nc\n")
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"].strip().startswith("3")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "stdin-test")
|
||||
|
||||
|
||||
def test_save_then_load_round_trip(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"save-test")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "save-test",
|
||||
"--command", "echo persisted > /report.txt")
|
||||
|
||||
tar_path = tmp_path / "snap.tar"
|
||||
saved = _run_cli(daemon["env"], "workspace", "snapshot", "save-test",
|
||||
str(tar_path))
|
||||
assert saved["size"] > 0
|
||||
assert tar_path.exists()
|
||||
|
||||
loaded = _run_cli(daemon["env"], "workspace", "load", str(tar_path),
|
||||
str(cfg), "--id", "loaded-ws")
|
||||
assert loaded["id"] == "loaded-ws"
|
||||
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id", "loaded-ws",
|
||||
"--command", "cat /report.txt")
|
||||
assert "persisted" in result["stdout"]
|
||||
_run_cli(daemon["env"], "workspace", "delete", "save-test")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "loaded-ws")
|
||||
|
||||
|
||||
def test_workspace_clone_round_trip(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"clone-src")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "clone-src",
|
||||
"--command", "echo source > /report.txt")
|
||||
|
||||
cloned = _run_cli(daemon["env"], "workspace", "clone", "clone-src", "--id",
|
||||
"clone-dst")
|
||||
assert cloned["id"] == "clone-dst"
|
||||
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id", "clone-dst",
|
||||
"--command", "cat /report.txt")
|
||||
assert "source" in result["stdout"]
|
||||
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "clone-dst",
|
||||
"--command", "echo clone > /report.txt")
|
||||
original = _run_cli(daemon["env"], "execute", "--workspace_id",
|
||||
"clone-src", "--command", "cat /report.txt")
|
||||
assert "source" in original["stdout"]
|
||||
|
||||
_run_cli(daemon["env"], "workspace", "delete", "clone-src")
|
||||
_run_cli(daemon["env"], "workspace", "delete", "clone-dst")
|
||||
|
||||
|
||||
def test_workspace_get_verbose_includes_internals(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"verbose-test")
|
||||
plain = _run_cli(daemon["env"], "workspace", "get", "verbose-test")
|
||||
assert plain["internals"] is None
|
||||
|
||||
verbose = _run_cli(daemon["env"], "workspace", "get", "verbose-test",
|
||||
"--verbose")
|
||||
assert verbose["internals"] is not None
|
||||
assert "cache_bytes" in verbose["internals"]
|
||||
_run_cli(daemon["env"], "workspace", "delete", "verbose-test")
|
||||
|
||||
|
||||
def test_unknown_workspace_returns_nonzero(daemon, tmp_path):
|
||||
_run_cli(daemon["env"],
|
||||
"workspace",
|
||||
"get",
|
||||
"ws_doesnotexist",
|
||||
expect_exit=2)
|
||||
|
||||
|
||||
def test_env_interpolation_uses_cli_environment(daemon, tmp_path):
|
||||
"""${VAR}s in the YAML must be resolved from the CLI's env, not the
|
||||
daemon's. The cross-mount workflow assumes the user sources their
|
||||
creds before running `mirage workspace create` -- the daemon
|
||||
process won't have those vars."""
|
||||
cfg = tmp_path / "interp.yaml"
|
||||
cfg.write_text(
|
||||
"mounts:\n"
|
||||
" /:\n"
|
||||
" resource: ram\n"
|
||||
" mode: ${MOUNT_MODE_FROM_ENV}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = {**daemon["env"], "MOUNT_MODE_FROM_ENV": "WRITE"}
|
||||
_run_cli(env, "workspace", "create", str(cfg), "--id", "interp-test")
|
||||
detail = _run_cli(env, "workspace", "get", "interp-test")
|
||||
assert any(m["mode"] == "write" for m in detail["mounts"])
|
||||
_run_cli(env, "workspace", "delete", "interp-test")
|
||||
|
||||
|
||||
def test_daemon_status_running(daemon, tmp_path):
|
||||
out = _run_cli(daemon["env"], "daemon", "status")
|
||||
assert out["running"] is True
|
||||
assert out["pid"] is not None
|
||||
assert out["workspaces"] == 0
|
||||
|
||||
|
||||
def test_daemon_stop_then_status_not_running(daemon, tmp_path):
|
||||
out = _run_cli(daemon["env"], "daemon", "stop")
|
||||
assert out["stopped"] is True
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
out = _run_cli(daemon["env"], "daemon", "status", expect_exit=1)
|
||||
assert out == {} or out.get("running") is False
|
||||
|
||||
|
||||
def test_provision_returns_dry_run_result(daemon, tmp_path):
|
||||
"""`mirage provision` hits /execute with provision=True and returns
|
||||
a {"kind": "provision", ...} payload instead of actually running."""
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"provision-test")
|
||||
result = _run_cli(
|
||||
daemon["env"],
|
||||
"provision",
|
||||
"--workspace_id",
|
||||
"provision-test",
|
||||
"--command",
|
||||
"echo would-not-run",
|
||||
)
|
||||
assert result["kind"] == "provision"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "provision-test")
|
||||
|
||||
|
||||
def test_execute_propagates_inner_exit_code(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"exit-test")
|
||||
|
||||
ok = _run_cli(daemon["env"], "execute", "-w", "exit-test", "-c", "echo ok")
|
||||
assert ok["exit_code"] == 0
|
||||
|
||||
_run_cli(daemon["env"],
|
||||
"execute",
|
||||
"-w",
|
||||
"exit-test",
|
||||
"-c",
|
||||
"false",
|
||||
expect_exit=1)
|
||||
|
||||
bg = _run_cli(daemon["env"], "execute", "-w", "exit-test", "-c", "false",
|
||||
"--background")
|
||||
job_id = bg["job_id"]
|
||||
_run_cli(daemon["env"], "job", "wait", job_id, expect_exit=1)
|
||||
|
||||
_run_cli(daemon["env"], "workspace", "delete", "exit-test")
|
||||
|
||||
|
||||
def test_execute_subshell_cwd_does_not_leak(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"subshell")
|
||||
_run_cli(daemon["env"], "execute", "-w", "subshell", "-c", "mkdir /sub")
|
||||
inside = _run_cli(daemon["env"], "execute", "-w", "subshell", "-c",
|
||||
"(cd /sub && pwd)")
|
||||
assert inside["stdout"].strip() == "/sub"
|
||||
after = _run_cli(daemon["env"], "execute", "-w", "subshell", "-c", "pwd")
|
||||
assert after["stdout"].strip() == "/"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "subshell")
|
||||
|
||||
|
||||
def test_execute_env_prefix_does_not_leak(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id", "envpref")
|
||||
inside = _run_cli(daemon["env"], "execute", "-w", "envpref", "-c",
|
||||
"(export FOO=bar; printenv FOO)")
|
||||
assert inside["stdout"].strip() == "bar"
|
||||
after = _run_cli(daemon["env"], "execute", "-w", "envpref", "-c",
|
||||
"printenv FOO || echo absent")
|
||||
assert after["stdout"].strip() == "absent"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "envpref")
|
||||
|
||||
|
||||
def test_execute_background_then_cancel(daemon, tmp_path):
|
||||
cfg = _write_config(tmp_path)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"cancel-test")
|
||||
submitted = _run_cli(daemon["env"], "execute", "-w", "cancel-test", "-c",
|
||||
"sleep 30", "--background")
|
||||
job_id = submitted["job_id"]
|
||||
_run_cli(daemon["env"], "job", "cancel", job_id)
|
||||
waited = _run_cli(daemon["env"], "job", "wait", job_id, expect_exit=2)
|
||||
assert waited == {}
|
||||
_run_cli(daemon["env"], "workspace", "delete", "cancel-test")
|
||||
|
||||
|
||||
def test_missing_env_var_fails_fast_before_daemon_call(daemon, tmp_path):
|
||||
cfg = tmp_path / "missing.yaml"
|
||||
cfg.write_text(
|
||||
"mounts:\n"
|
||||
" /:\n"
|
||||
" resource: ram\n"
|
||||
" mode: ${THIS_VAR_IS_NOT_SET_ANYWHERE}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = {
|
||||
k: v
|
||||
for k, v in daemon["env"].items()
|
||||
if k != "THIS_VAR_IS_NOT_SET_ANYWHERE"
|
||||
}
|
||||
_run_cli(env, "workspace", "create", str(cfg), expect_exit=2)
|
||||
|
||||
|
||||
SAFEGUARD_TRUNCATE_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
command_safeguards:
|
||||
cat:
|
||||
max_lines: 2
|
||||
on_exceed: truncate
|
||||
"""
|
||||
|
||||
SAFEGUARD_ERROR_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
command_safeguards:
|
||||
cat:
|
||||
max_lines: 2
|
||||
on_exceed: error
|
||||
"""
|
||||
|
||||
_SEED_5_LINES = "printf '1\\n2\\n3\\n4\\n5\\n' > /f.txt"
|
||||
|
||||
|
||||
def _write_named(tmp_path: Path, name: str, text: str) -> Path:
|
||||
p = tmp_path / name
|
||||
p.write_text(text, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_execute_safeguard_truncates_output(daemon, tmp_path):
|
||||
cfg = _write_named(tmp_path, "sg_trunc.yaml", SAFEGUARD_TRUNCATE_YAML)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id",
|
||||
"sg-trunc")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "sg-trunc",
|
||||
"--command", _SEED_5_LINES)
|
||||
result = _run_cli(daemon["env"], "execute", "--workspace_id", "sg-trunc",
|
||||
"--command", "cat /f.txt")
|
||||
assert result["exit_code"] == 0
|
||||
assert result["stdout"] == "1\n2\n"
|
||||
_run_cli(daemon["env"], "workspace", "delete", "sg-trunc")
|
||||
|
||||
|
||||
def test_execute_safeguard_error_exits_1(daemon, tmp_path):
|
||||
cfg = _write_named(tmp_path, "sg_err.yaml", SAFEGUARD_ERROR_YAML)
|
||||
_run_cli(daemon["env"], "workspace", "create", str(cfg), "--id", "sg-err")
|
||||
_run_cli(daemon["env"], "execute", "--workspace_id", "sg-err", "--command",
|
||||
_SEED_5_LINES)
|
||||
_run_cli(daemon["env"],
|
||||
"execute",
|
||||
"--workspace_id",
|
||||
"sg-err",
|
||||
"--command",
|
||||
"cat /f.txt",
|
||||
expect_exit=1)
|
||||
_run_cli(daemon["env"], "workspace", "delete", "sg-err")
|
||||
@@ -0,0 +1,105 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cli.client import DaemonClient
|
||||
from mirage.cli.settings import DaemonSettings
|
||||
from mirage.server.daemon_config import DaemonConfigError
|
||||
from mirage.server.env import ENV_HOME
|
||||
|
||||
|
||||
def test_spawn_daemon_uses_mirage_home_for_log_and_token(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
spawned = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda *args, **kwargs: spawned.append(kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert spawned, "daemon process must be spawned"
|
||||
assert (tmp_path / "daemon.log").exists()
|
||||
assert (tmp_path / "auth_token").exists()
|
||||
assert client.settings.auth_token
|
||||
|
||||
|
||||
def test_spawn_daemon_rejects_bad_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\ntypo_key = "x"\n')
|
||||
spawned = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda *args, **kwargs: spawned.append(kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
with pytest.raises(DaemonConfigError, match="typo_key"):
|
||||
client._spawn_daemon()
|
||||
assert not spawned
|
||||
|
||||
|
||||
class _FakePopen:
|
||||
|
||||
def __init__(self, sink, cmd, **kwargs):
|
||||
sink.append(cmd)
|
||||
|
||||
|
||||
def test_spawn_port_config_beats_url(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
(tmp_path / "config.toml").write_text("[daemon]\nport = 9100\n")
|
||||
cmds = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda cmd, **kwargs: _FakePopen(cmds, cmd, **kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert "9100" in cmds[0]
|
||||
|
||||
|
||||
def test_spawn_port_env_beats_config(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.setenv("MIRAGE_DAEMON_PORT", "9200")
|
||||
(tmp_path / "config.toml").write_text("[daemon]\nport = 9100\n")
|
||||
cmds = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda cmd, **kwargs: _FakePopen(cmds, cmd, **kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert "9200" in cmds[0]
|
||||
|
||||
|
||||
def test_spawn_port_falls_back_to_url(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
cmds = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda cmd, **kwargs: _FakePopen(cmds, cmd, **kwargs))
|
||||
with DaemonClient(DaemonSettings(url="http://127.0.0.1:9331")) as client:
|
||||
client._spawn_daemon()
|
||||
assert "9331" in cmds[0]
|
||||
|
||||
|
||||
def test_spawn_respects_config_auth_mode(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_AUTH_MODE", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\nauth_mode = "token"\n')
|
||||
spawned = []
|
||||
monkeypatch.setattr("mirage.cli.client.subprocess.Popen",
|
||||
lambda *args, **kwargs: spawned.append(kwargs))
|
||||
with DaemonClient(DaemonSettings()) as client:
|
||||
client._spawn_daemon()
|
||||
assert "MIRAGE_AUTH_MODE" not in spawned[0]["env"]
|
||||
@@ -0,0 +1,105 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from mirage.cli.config import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _isolate_home(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("MIRAGE_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_PID_FILE", raising=False)
|
||||
|
||||
|
||||
def test_config_set_then_get(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["set", "version_root", "/data/repos"])
|
||||
assert r.exit_code == 0
|
||||
r = runner.invoke(app, ["get", "version_root"])
|
||||
assert r.exit_code == 0
|
||||
assert "/data/repos" in r.stdout
|
||||
|
||||
|
||||
def test_config_list(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
runner.invoke(app, ["set", "url", "http://a:1"])
|
||||
r = runner.invoke(app, ["list"])
|
||||
assert r.exit_code == 0
|
||||
assert "url" in r.stdout and "http://a:1" in r.stdout
|
||||
|
||||
|
||||
def test_config_unset(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
runner.invoke(app, ["set", "socket", "/tmp/s.sock"])
|
||||
runner.invoke(app, ["unset", "socket"])
|
||||
r = runner.invoke(app, ["get", "socket"])
|
||||
assert r.exit_code != 0
|
||||
|
||||
|
||||
def test_config_get_unset_exits_nonzero(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["get", "pid_file"])
|
||||
assert r.exit_code != 0
|
||||
|
||||
|
||||
def test_config_set_rejects_unknown_key(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["set", "MIRAGE_HOME", "/x"])
|
||||
assert r.exit_code == 2
|
||||
|
||||
|
||||
def test_config_set_unknown_key_message_is_clean(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
r = runner.invoke(app, ["set", "nope", "/x"])
|
||||
assert r.exit_code == 2
|
||||
assert "unknown config key" in r.output
|
||||
assert not r.output.strip().startswith("'")
|
||||
|
||||
|
||||
def test_config_list_malformed_toml_fails_cleanly(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
(tmp_path / "config.toml").write_text("[daemon\nnot toml")
|
||||
r = runner.invoke(app, ["list"])
|
||||
assert r.exit_code == 2
|
||||
assert "malformed" in r.output
|
||||
assert "Traceback" not in r.output
|
||||
|
||||
|
||||
def test_config_list_warns_on_unknown_keys(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
(tmp_path / "config.toml").write_text('[daemon]\ntypo_key = "x"\n')
|
||||
r = runner.invoke(app, ["list"])
|
||||
assert r.exit_code == 0
|
||||
assert "typo_key" in r.output
|
||||
assert "unknown" in r.output.lower()
|
||||
|
||||
|
||||
def test_config_list_resolved_shows_origin(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("MIRAGE_VERSION_ROOT", "/env/repos")
|
||||
r = runner.invoke(app, ["list", "--resolved"])
|
||||
assert r.exit_code == 0
|
||||
assert "/env/repos" in r.output
|
||||
assert "MIRAGE_VERSION_ROOT" in r.output
|
||||
|
||||
|
||||
def test_config_list_resolved_masks_auth_token(monkeypatch, tmp_path):
|
||||
_isolate_home(monkeypatch, tmp_path)
|
||||
monkeypatch.setenv("MIRAGE_TOKEN", "supersecret")
|
||||
r = runner.invoke(app, ["list", "--resolved"])
|
||||
assert r.exit_code == 0
|
||||
assert "supersecret" not in r.output
|
||||
assert "***" in r.output
|
||||
@@ -0,0 +1,160 @@
|
||||
from mirage.cli.output import exit_code_from_response
|
||||
|
||||
|
||||
def test_io_zero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_io_nonzero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 1
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 42,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 42
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": 127,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 127
|
||||
|
||||
|
||||
def test_clamp_high():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": 300}) == 255
|
||||
|
||||
|
||||
def test_clamp_negative():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": -1}) == 0
|
||||
|
||||
|
||||
def test_truncate_float():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": 1.9}) == 1
|
||||
|
||||
|
||||
def test_nan_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": float("nan")
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_inf_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"exit_code": float("inf")
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_bool_returns_zero():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": True}) == 0
|
||||
|
||||
|
||||
def test_bg_submission():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_abc",
|
||||
"workspace_id": "ws",
|
||||
"submitted_at": 0,
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_provision_kind():
|
||||
assert exit_code_from_response({"kind": "provision", "detail": "ok"}) == 0
|
||||
|
||||
|
||||
def test_raw_kind():
|
||||
assert exit_code_from_response({"kind": "raw", "value": "hi"}) == 0
|
||||
|
||||
|
||||
def test_job_detail_done():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "done",
|
||||
"result": {
|
||||
"kind": "io",
|
||||
"exit_code": 7,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
"error": None,
|
||||
}) == 7
|
||||
|
||||
|
||||
def test_job_pending_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_job_running_returns_zero():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "running",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_job_failed_no_result_returns_two():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "failed",
|
||||
"result": None,
|
||||
"error": "boom",
|
||||
}) == 2
|
||||
|
||||
|
||||
def test_job_canceled_no_result_returns_two():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "canceled",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}) == 2
|
||||
|
||||
|
||||
def test_job_failed_with_result_prefers_inner():
|
||||
assert exit_code_from_response({
|
||||
"job_id": "job_x",
|
||||
"status": "failed",
|
||||
"result": {
|
||||
"kind": "io",
|
||||
"exit_code": 9,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
"error": None,
|
||||
}) == 9
|
||||
|
||||
|
||||
def test_non_dict_inputs():
|
||||
assert exit_code_from_response(None) == 0
|
||||
assert exit_code_from_response("string") == 0
|
||||
assert exit_code_from_response(42) == 0
|
||||
assert exit_code_from_response([1, 2, 3]) == 0
|
||||
|
||||
|
||||
def test_io_missing_exit_code():
|
||||
assert exit_code_from_response({
|
||||
"kind": "io",
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}) == 0
|
||||
|
||||
|
||||
def test_io_non_numeric_exit_code():
|
||||
assert exit_code_from_response({"kind": "io", "exit_code": "one"}) == 0
|
||||
@@ -0,0 +1,102 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from mirage.cli import session as session_cli
|
||||
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
status_code = 201
|
||||
content = b'{"session_id": "agent", "cwd": "/"}'
|
||||
|
||||
def json(self) -> dict:
|
||||
return {"session_id": "agent", "cwd": "/"}
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.last_body: dict | None = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def ensure_running(self, allow_spawn: bool = False) -> None:
|
||||
return None
|
||||
|
||||
def request(self, method: str, path: str, json: dict | None = None):
|
||||
self.last_body = json
|
||||
return _FakeResponse()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched_client(fake: _FakeClient):
|
||||
real = session_cli.make_client
|
||||
session_cli.make_client = lambda: fake
|
||||
try:
|
||||
yield fake
|
||||
finally:
|
||||
session_cli.make_client = real
|
||||
|
||||
|
||||
def test_session_create_passes_mount_flags():
|
||||
fake = _FakeClient()
|
||||
with _patched_client(fake):
|
||||
result = CliRunner().invoke(
|
||||
session_cli.app,
|
||||
[
|
||||
"create", "demo", "--id", "agent", "-m", "/s3", "--mount",
|
||||
"/slack"
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert fake.last_body == {
|
||||
"session_id": "agent",
|
||||
"allowed_mounts": ["/s3", "/slack"],
|
||||
}
|
||||
|
||||
|
||||
def test_session_create_no_mount_flag_omits_field():
|
||||
fake = _FakeClient()
|
||||
with _patched_client(fake):
|
||||
result = CliRunner().invoke(
|
||||
session_cli.app,
|
||||
["create", "demo", "--id", "agent"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert fake.last_body == {"session_id": "agent"}
|
||||
|
||||
|
||||
def test_session_create_without_id_or_mount_sends_empty_body():
|
||||
fake = _FakeClient()
|
||||
with _patched_client(fake):
|
||||
result = CliRunner().invoke(session_cli.app, ["create", "demo"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert fake.last_body == {}
|
||||
|
||||
|
||||
def test_session_create_help_lists_mount_flag():
|
||||
result = CliRunner().invoke(session_cli.app, ["create", "--help"])
|
||||
assert result.exit_code == 0
|
||||
plain = _ANSI_RE.sub("", result.output)
|
||||
assert "--mount" in plain
|
||||
@@ -0,0 +1,203 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cli.settings import (DEFAULT_DAEMON_URL, config_path, get_config,
|
||||
list_config, load_daemon_settings,
|
||||
resolved_config, set_config, unset_config)
|
||||
from mirage.server.daemon_config import DaemonConfigError
|
||||
from mirage.server.env import ENV_HOME
|
||||
|
||||
|
||||
def test_load_daemon_settings_falls_back_to_token_file(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
token_file = tmp_path / "auth_token"
|
||||
token_file.write_text("file-token\n")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == "file-token"
|
||||
|
||||
|
||||
def test_load_daemon_settings_env_wins_over_file(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MIRAGE_TOKEN", "from-env")
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
token_file = tmp_path / "auth_token"
|
||||
token_file.write_text("from-file")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == "from-env"
|
||||
|
||||
|
||||
def test_load_daemon_settings_config_wins_over_file(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text('[daemon]\nauth_token = "from-config"\n')
|
||||
token_file = tmp_path / "auth_token"
|
||||
token_file.write_text("from-file")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == "from-config"
|
||||
|
||||
|
||||
def test_load_daemon_settings_no_sources_yields_empty_token(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
settings = load_daemon_settings(path=config_file)
|
||||
assert settings.auth_token == ""
|
||||
|
||||
|
||||
def test_config_path_follows_mirage_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
assert config_path() == tmp_path / "config.toml"
|
||||
|
||||
|
||||
def test_load_daemon_settings_reads_config_under_mirage_home(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
(tmp_path /
|
||||
"config.toml").write_text('[daemon]\nurl = "http://127.0.0.1:9999"\n')
|
||||
settings = load_daemon_settings()
|
||||
assert settings.url == "http://127.0.0.1:9999"
|
||||
|
||||
|
||||
def test_set_config_creates_file(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("version_root", "/data/repos", path=p)
|
||||
assert get_config("version_root", path=p) == "/data/repos"
|
||||
assert '[daemon]' in p.read_text()
|
||||
|
||||
|
||||
def test_set_config_updates_existing_key(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("url", "http://a:1", path=p)
|
||||
set_config("url", "http://b:2", path=p)
|
||||
assert get_config("url", path=p) == "http://b:2"
|
||||
assert p.read_text().count("url =") == 1
|
||||
|
||||
|
||||
def test_set_config_preserves_comments_and_other_keys(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text('[daemon]\n# keep me\nurl = "http://a:1"\n')
|
||||
set_config("pid_file", "/tmp/x.pid", path=p)
|
||||
text = p.read_text()
|
||||
assert "# keep me" in text
|
||||
assert 'url = "http://a:1"' in text
|
||||
assert get_config("pid_file", path=p) == "/tmp/x.pid"
|
||||
|
||||
|
||||
def test_unset_config_removes_key(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("socket", "/tmp/s.sock", path=p)
|
||||
unset_config("socket", path=p)
|
||||
assert get_config("socket", path=p) is None
|
||||
|
||||
|
||||
def test_list_config_returns_written_keys(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("url", "http://a:1", path=p)
|
||||
set_config("snapshot_root", "/snaps", path=p)
|
||||
assert list_config(path=p) == {
|
||||
"url": "http://a:1",
|
||||
"snapshot_root": "/snaps"
|
||||
}
|
||||
|
||||
|
||||
def test_set_config_rejects_unknown_key(tmp_path):
|
||||
with pytest.raises(DaemonConfigError, match="unknown config key"):
|
||||
set_config("MIRAGE_HOME", "/x", path=tmp_path / "config.toml")
|
||||
|
||||
|
||||
def test_numeric_key_written_bare(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("idle_grace_seconds", "45", path=p)
|
||||
assert "idle_grace_seconds = 45" in p.read_text()
|
||||
|
||||
|
||||
def test_unset_config_accepts_unknown_key(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text('[daemon]\ntypo_key = "x"\nurl = "http://a:1"\n')
|
||||
unset_config("typo_key", path=p)
|
||||
assert "typo_key" not in p.read_text()
|
||||
assert 'url = "http://a:1"' in p.read_text()
|
||||
|
||||
|
||||
def test_set_config_chmods_0600(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
set_config("auth_token", "s3cret", path=p)
|
||||
assert (p.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_unset_config_chmods_0600(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text('[daemon]\nurl = "http://a:1"\nsocket = "/tmp/s"\n')
|
||||
unset_config("socket", path=p)
|
||||
assert (p.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
|
||||
def test_load_daemon_settings_missing_explicit_path_returns_defaults(
|
||||
tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("MIRAGE_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
settings = load_daemon_settings(path=tmp_path / "nope.toml")
|
||||
assert settings.url == DEFAULT_DAEMON_URL
|
||||
|
||||
|
||||
def test_resolved_config_reports_origins(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.setenv("MIRAGE_VERSION_ROOT", "/env/repos")
|
||||
monkeypatch.delenv("MIRAGE_SNAPSHOT_ROOT", raising=False)
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_URL", raising=False)
|
||||
(tmp_path / "config.toml").write_text(
|
||||
'[daemon]\nversion_root = "/file/repos"\nurl = "http://f:1"\n')
|
||||
resolved = resolved_config()
|
||||
assert resolved["version_root"] == ("/env/repos",
|
||||
"env MIRAGE_VERSION_ROOT")
|
||||
assert resolved["url"] == ("http://f:1", "file")
|
||||
assert resolved["snapshot_root"] == (str(tmp_path / "snapshots"),
|
||||
"default")
|
||||
|
||||
|
||||
def test_resolved_config_defaults_when_nothing_set(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
for name in ("MIRAGE_DAEMON_URL", "MIRAGE_TOKEN", "MIRAGE_PID_FILE",
|
||||
"MIRAGE_VERSION_ROOT", "MIRAGE_SNAPSHOT_ROOT",
|
||||
"MIRAGE_IDLE_GRACE_SECONDS"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
resolved = resolved_config()
|
||||
assert resolved["url"] == (DEFAULT_DAEMON_URL, "default")
|
||||
assert resolved["pid_file"] == (str(tmp_path / "daemon.pid"), "default")
|
||||
assert resolved["idle_grace_seconds"] == ("30", "default")
|
||||
|
||||
|
||||
def test_resolved_config_includes_port(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv(ENV_HOME, str(tmp_path))
|
||||
monkeypatch.delenv("MIRAGE_DAEMON_PORT", raising=False)
|
||||
resolved = resolved_config()
|
||||
assert resolved["port"] == ("8765", "default")
|
||||
monkeypatch.setenv("MIRAGE_DAEMON_PORT", "9100")
|
||||
assert resolved_config()["port"] == ("9100", "env MIRAGE_DAEMON_PORT")
|
||||
@@ -0,0 +1,202 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_YAML = """\
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
"""
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(CONFIG_YAML, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _run(env: dict, *args: str, expect_exit: int = 0) -> dict | list:
|
||||
cmd = [sys.executable, "-m", "mirage.cli.main", *args]
|
||||
proc = subprocess.run(cmd, env=env, capture_output=True, timeout=30)
|
||||
if proc.returncode != expect_exit:
|
||||
raise AssertionError(
|
||||
f"exit={proc.returncode} (expected {expect_exit})\n"
|
||||
f"stdout: {proc.stdout.decode()}\nstderr: {proc.stderr.decode()}")
|
||||
if expect_exit != 0 or not proc.stdout.strip():
|
||||
return {}
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_commit_log_checkout_clone(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo v1 > /notes.txt")
|
||||
v1 = _run(env, "workspace", "commit", wid, "-m", "first")["version"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo v2 > /notes.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "second")
|
||||
|
||||
log = _run(env, "workspace", "log", wid)
|
||||
assert [e["message"] for e in log] == ["second", "first"]
|
||||
|
||||
_run(env, "workspace", "checkout", wid, v1)
|
||||
reverted = _run(env, "execute", "-w", wid, "-c", "cat /notes.txt")
|
||||
assert reverted["stdout"] == "v1\n"
|
||||
|
||||
clone = _run(env, "workspace", "clone", wid, "--at", v1)
|
||||
assert clone["id"] != wid
|
||||
|
||||
|
||||
def test_log_empty_before_commit(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
assert _run(env, "workspace", "log", wid) == []
|
||||
|
||||
|
||||
def test_diff_versions_and_live(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
v1 = _run(env, "workspace", "commit", wid, "-m", "first")["version"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo new > /b.txt")
|
||||
v2 = _run(env, "workspace", "commit", wid, "-m", "second")["version"]
|
||||
|
||||
by_version = _run(env, "workspace", "diff", wid, v1, v2)
|
||||
assert by_version["modified"] == ["a.txt"]
|
||||
assert by_version["added"] == ["b.txt"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo three > /a.txt")
|
||||
live = _run(env, "workspace", "diff", wid)
|
||||
assert live["modified"] == ["a.txt"]
|
||||
|
||||
|
||||
def test_branch_diverges_and_guards_commit(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-b", "exp", "-m", "on exp")
|
||||
|
||||
exp_log = _run(env, "workspace", "log", wid, "-b", "exp")
|
||||
main_log = _run(env, "workspace", "log", wid, "-b", "main")
|
||||
assert [e["message"] for e in exp_log] == ["on exp", "first"]
|
||||
assert [e["message"] for e in main_log] == ["first"]
|
||||
|
||||
_run(env,
|
||||
"workspace",
|
||||
"commit",
|
||||
wid,
|
||||
"-b",
|
||||
"ghost",
|
||||
"-m",
|
||||
"x",
|
||||
expect_exit=2)
|
||||
|
||||
|
||||
def test_diff_includes_deleted(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /b.txt")
|
||||
v1 = _run(env, "workspace", "commit", wid, "-m", "first")["version"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "rm /b.txt")
|
||||
v2 = _run(env, "workspace", "commit", wid, "-m", "second")["version"]
|
||||
|
||||
changes = _run(env, "workspace", "diff", wid, v1, v2)
|
||||
assert changes["deleted"] == ["b.txt"]
|
||||
|
||||
|
||||
def test_clone_live_and_explicit_id(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
_run(env, "execute", "-w", wid, "-c", "echo hello > /a.txt")
|
||||
|
||||
auto = _run(env, "workspace", "clone", wid)
|
||||
assert auto["id"] != wid
|
||||
|
||||
named = _run(env, "workspace", "clone", wid, "--id", "myclone")
|
||||
assert named["id"] == "myclone"
|
||||
got = _run(env, "execute", "-w", "myclone", "-c", "cat /a.txt")
|
||||
assert got["stdout"] == "hello\n"
|
||||
|
||||
|
||||
def test_branch_from_non_main(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-b", "exp", "-m", "on exp")
|
||||
|
||||
_run(env, "workspace", "branch", wid, "exp2", "--from", "exp")
|
||||
log = _run(env, "workspace", "log", wid, "-b", "exp2")
|
||||
assert [e["message"] for e in log] == ["on exp", "first"]
|
||||
|
||||
|
||||
def test_checkout_by_branch_name(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
_run(env, "execute", "-w", wid, "-c", "echo two > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-b", "exp", "-m", "on exp")
|
||||
|
||||
_run(env, "workspace", "checkout", wid, "main")
|
||||
on_main = _run(env, "execute", "-w", wid, "-c", "cat /a.txt")
|
||||
assert on_main["stdout"] == "one\n"
|
||||
|
||||
_run(env, "workspace", "checkout", wid, "exp")
|
||||
on_exp = _run(env, "execute", "-w", wid, "-c", "cat /a.txt")
|
||||
assert on_exp["stdout"] == "two\n"
|
||||
|
||||
|
||||
def test_error_paths_exit_2(daemon, tmp_path):
|
||||
env = daemon["env"]
|
||||
cfg = _write_config(tmp_path)
|
||||
wid = _run(env, "workspace", "create", str(cfg))["id"]
|
||||
_run(env, "execute", "-w", wid, "-c", "echo one > /a.txt")
|
||||
_run(env, "workspace", "commit", wid, "-m", "first")
|
||||
_run(env, "workspace", "branch", wid, "exp")
|
||||
|
||||
_run(env, "workspace", "branch", wid, "exp", expect_exit=2)
|
||||
_run(env, "workspace", "commit", "ghost-ws", "-m", "x", expect_exit=2)
|
||||
_run(env, "workspace", "checkout", wid, "nope", expect_exit=2)
|
||||
_run(env, "workspace", "diff", wid, "nope", "nope2", expect_exit=2)
|
||||
@@ -0,0 +1,25 @@
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.chroma.find import _default_name, _expr_texts
|
||||
|
||||
|
||||
@pytest.mark.parametrize("texts", [
|
||||
("!", "-name", "x"),
|
||||
("(", "-name", "a", "-o", "-name", "b", ")"),
|
||||
("-name", "x"),
|
||||
("-not", "-name", "x"),
|
||||
])
|
||||
def test_expr_texts_preserves_expression(texts):
|
||||
assert _expr_texts(texts) == texts
|
||||
|
||||
|
||||
def test_expr_texts_strips_bare_leading_name():
|
||||
assert _expr_texts(("foo", )) == ()
|
||||
assert _expr_texts(()) == ()
|
||||
|
||||
|
||||
def test_default_name_only_for_bare_word():
|
||||
assert _default_name(None, ("foo", )) == "foo"
|
||||
assert _default_name(None, ("!", "-name", "x")) is None
|
||||
assert _default_name(None, ("(", "-name", "a")) is None
|
||||
assert _default_name("given", ("foo", )) == "given"
|
||||
@@ -0,0 +1,24 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend():
|
||||
b = RAMResource()
|
||||
b.accessor.store.dirs.add("/tmp")
|
||||
return b
|
||||
@@ -0,0 +1,91 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.stream import collect_bytes
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
|
||||
def seed_text_command_fixture(files: FakeFiles) -> str:
|
||||
root = "/Volumes/main/default/agent_files/root"
|
||||
seed_directory(files, root)
|
||||
seed_file(files, f"{root}/words.txt", b"beta\nalpha\nalpha\n")
|
||||
seed_file(files, f"{root}/more.txt", b"delta\n")
|
||||
seed_file(files, f"{root}/table.csv", b"name,score\nann,2\nbob,3\n")
|
||||
seed_file(files, f"{root}/table_extra.csv", b"name,score\ncam,5\n")
|
||||
seed_file(files, f"{root}/data.json", b'{"name": "mirage"}\n')
|
||||
seed_file(files, f"{root}/data2.json", b'{"name": "agent"}\n')
|
||||
seed_file(files, f"{root}/events.jsonl", b'{"name": "first"}\n')
|
||||
seed_file(files, f"{root}/events_extra.jsonl", b'{"name": "second"}\n')
|
||||
seed_file(files, f"{root}/old.txt", b"same\nold\n")
|
||||
seed_file(files, f"{root}/new.txt", b"same\nnew\n")
|
||||
return root
|
||||
|
||||
|
||||
async def materialize(source) -> bytes:
|
||||
if source is None:
|
||||
return b""
|
||||
return await collect_bytes(source)
|
||||
|
||||
|
||||
class IndexTrackingReader:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.seen_indexes: list[RAMIndexCacheStore | None] = []
|
||||
|
||||
async def read_bytes(self,
|
||||
accessor,
|
||||
path,
|
||||
index=None,
|
||||
*args,
|
||||
**kwargs) -> bytes:
|
||||
self.seen_indexes.append(index)
|
||||
original = path.virtual if isinstance(path, PathSpec) else path
|
||||
if str(original).endswith(".json"):
|
||||
return b'{"name": "mirage"}\n'
|
||||
if str(original).endswith(".csv"):
|
||||
return b"name,score\nann,2\n"
|
||||
return b"beta\nalpha\nalpha\n"
|
||||
|
||||
async def read_stream(self, accessor, path, index=None, *args, **kwargs):
|
||||
self.seen_indexes.append(index)
|
||||
yield await self.read_bytes(accessor, path, index, *args, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def databricks_text_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_text_command_fixture(files)
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def databricks_text_workspace(databricks_text_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(databricks_text_files)},
|
||||
mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def expected_index() -> RAMIndexCacheStore:
|
||||
return RAMIndexCacheStore(ttl=600)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def materialize_output():
|
||||
return materialize
|
||||
@@ -0,0 +1,25 @@
|
||||
# ========= 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_awk(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"awk '{print $1}' /dbx/table.csv")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"name,score\nann,2\nbob,3\n"
|
||||
@@ -0,0 +1,186 @@
|
||||
import time
|
||||
from functools import partial
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.databricks_volume import DatabricksVolumeAccessor
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.commands.builtin.generic.find import parse_find_args, walk_find
|
||||
from mirage.commands.builtin.generic.ls import ls as generic_ls
|
||||
from mirage.commands.builtin.generic.tree import tree as generic_tree
|
||||
from mirage.core.databricks_volume.path import backend_path
|
||||
from mirage.core.databricks_volume.readdir import readdir
|
||||
from mirage.core.databricks_volume.stat import stat
|
||||
from mirage.resource.databricks_volume import DatabricksVolumeConfig
|
||||
from mirage.types import LsSortBy, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
from tests.core.databricks_volume.conftest import (FakeClient, FakeFiles,
|
||||
directory_entry, file_entry)
|
||||
|
||||
MODIFIED_MS = 1_700_000_000_000
|
||||
|
||||
|
||||
def is_dir_name(_child: str) -> bool | None:
|
||||
# Databricks readdir returns slash-less paths, so classification always
|
||||
# falls back to stat.
|
||||
return None
|
||||
|
||||
|
||||
FROZEN_NOW_S = 1_700_000_000
|
||||
DAY_S = 86_400
|
||||
AGES_DAYS = (1, 2, 3, 10, 20)
|
||||
|
||||
|
||||
def _rig(
|
||||
) -> tuple[DatabricksVolumeAccessor, FakeFiles, RAMIndexCacheStore, str]:
|
||||
config = DatabricksVolumeConfig(
|
||||
catalog="main",
|
||||
schema="default",
|
||||
volume="agent_files",
|
||||
root_path="/root",
|
||||
token="secret",
|
||||
)
|
||||
files = FakeFiles()
|
||||
accessor = DatabricksVolumeAccessor(config, FakeClient(files))
|
||||
index = RAMIndexCacheStore(ttl=600)
|
||||
return accessor, files, index, backend_path(config, "/")
|
||||
|
||||
|
||||
def _seed_flat(files: FakeFiles, root: str, count: int = 5) -> None:
|
||||
files.directories[f"{root}/sub"] = [
|
||||
file_entry(f"{root}/sub/f{i}.txt",
|
||||
size=i + 1,
|
||||
modified=MODIFIED_MS + i * 1000) for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
def _ls_readdir(accessor: DatabricksVolumeAccessor):
|
||||
return partial(readdir, accessor)
|
||||
|
||||
|
||||
def _ls_stat(accessor: DatabricksVolumeAccessor):
|
||||
return partial(stat, accessor)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("sort_by,long", [
|
||||
(LsSortBy.NAME, False),
|
||||
(LsSortBy.NAME, True),
|
||||
(LsSortBy.TIME, False),
|
||||
(LsSortBy.SIZE, False),
|
||||
])
|
||||
async def test_ls_lists_once_without_per_entry_metadata(sort_by, long):
|
||||
accessor, files, index, root = _rig()
|
||||
_seed_flat(files, root, count=5)
|
||||
path = PathSpec.from_str_path("/volume/sub",
|
||||
mount_key("/volume/sub", "/volume"))
|
||||
await generic_ls([path],
|
||||
readdir=_ls_readdir(accessor),
|
||||
stat=_ls_stat(accessor),
|
||||
long=long,
|
||||
sort_by=sort_by,
|
||||
index=index)
|
||||
assert files.list_directory_calls == [f"{root}/sub"]
|
||||
assert files.get_metadata_calls == []
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_recursive_one_list_per_directory():
|
||||
accessor, files, index, root = _rig()
|
||||
files.directories[f"{root}/sub"] = [
|
||||
file_entry(f"{root}/sub/a.txt", size=1, modified=MODIFIED_MS),
|
||||
directory_entry(f"{root}/sub/inner"),
|
||||
]
|
||||
files.directories[f"{root}/sub/inner"] = [
|
||||
file_entry(f"{root}/sub/inner/b.txt", size=2, modified=MODIFIED_MS),
|
||||
]
|
||||
path = PathSpec.from_str_path("/volume/sub",
|
||||
mount_key("/volume/sub", "/volume"))
|
||||
await generic_ls([path],
|
||||
readdir=_ls_readdir(accessor),
|
||||
stat=_ls_stat(accessor),
|
||||
recursive=True,
|
||||
index=index)
|
||||
assert sorted(
|
||||
files.list_directory_calls) == [f"{root}/sub", f"{root}/sub/inner"]
|
||||
assert files.get_metadata_calls == []
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_one_list_per_directory_without_metadata():
|
||||
accessor, files, index, root = _rig()
|
||||
files.directories[f"{root}/sub"] = [
|
||||
file_entry(f"{root}/sub/a.txt", size=1, modified=MODIFIED_MS),
|
||||
directory_entry(f"{root}/sub/inner"),
|
||||
]
|
||||
files.directories[f"{root}/sub/inner"] = [
|
||||
file_entry(f"{root}/sub/inner/b.txt", size=2, modified=MODIFIED_MS),
|
||||
]
|
||||
path = PathSpec.from_str_path("/volume/sub",
|
||||
mount_key("/volume/sub", "/volume"))
|
||||
await generic_tree(path,
|
||||
readdir=_ls_readdir(accessor),
|
||||
stat=_ls_stat(accessor),
|
||||
index=index)
|
||||
assert sorted(
|
||||
files.list_directory_calls) == [f"{root}/sub", f"{root}/sub/inner"]
|
||||
assert files.get_metadata_calls == []
|
||||
assert files.get_directory_metadata_calls == []
|
||||
|
||||
|
||||
async def _run_find(accessor, index, *, type=None, size=None, mtime=None):
|
||||
args = parse_find_args((), type=type, size=size, mtime=mtime)
|
||||
path = PathSpec.from_str_path("/volume/sub",
|
||||
mount_key("/volume/sub", "/volume"))
|
||||
return await walk_find(path,
|
||||
readdir=_ls_readdir(accessor),
|
||||
stat=_ls_stat(accessor),
|
||||
is_dir_name=is_dir_name,
|
||||
index=index,
|
||||
args=args)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_does_not_stat_children():
|
||||
accessor, files, index, root = _rig()
|
||||
_seed_flat(files, root, count=5)
|
||||
files.directory_metadata.add(f"{root}/sub")
|
||||
results = await _run_find(accessor, index, type="f")
|
||||
assert len(results) == 5
|
||||
assert files.list_directory_calls == [f"{root}/sub"]
|
||||
child_metadata = [c for c in files.get_metadata_calls if "/sub/" in c]
|
||||
assert child_metadata == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_size_reads_size_from_index():
|
||||
accessor, files, index, root = _rig()
|
||||
_seed_flat(files, root, count=5)
|
||||
files.directory_metadata.add(f"{root}/sub")
|
||||
results = await _run_find(accessor, index, type="f", size="+3c")
|
||||
assert sorted(r.rsplit("/", 1)[-1]
|
||||
for r in results) == ["f3.txt", "f4.txt"]
|
||||
assert files.list_directory_calls == [f"{root}/sub"]
|
||||
child_metadata = [c for c in files.get_metadata_calls if "/sub/" in c]
|
||||
assert child_metadata == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_mtime_reads_modified_from_index(monkeypatch):
|
||||
monkeypatch.setattr(time, "time", lambda: float(FROZEN_NOW_S))
|
||||
accessor, files, index, root = _rig()
|
||||
files.directories[f"{root}/sub"] = [
|
||||
file_entry(f"{root}/sub/f{i}.txt",
|
||||
size=i + 1,
|
||||
modified=(FROZEN_NOW_S - age * DAY_S) * 1000)
|
||||
for i, age in enumerate(AGES_DAYS)
|
||||
]
|
||||
files.directory_metadata.add(f"{root}/sub")
|
||||
results = await _run_find(accessor, index, mtime="-5")
|
||||
assert sorted(r.rsplit("/", 1)[-1]
|
||||
for r in results) == ["f0.txt", "f1.txt", "f2.txt"]
|
||||
assert files.list_directory_calls == [f"{root}/sub"]
|
||||
child_metadata = [c for c in files.get_metadata_calls if "/sub/" in c]
|
||||
assert child_metadata == []
|
||||
@@ -0,0 +1,40 @@
|
||||
# ========= 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_single_file(databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("cat /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"beta\nalpha\nalpha\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_multiple_files_concatenated(databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"cat /dbx/words.txt /dbx/more.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"beta\nalpha\nalpha\ndelta\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_n_numbers_lines(databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("cat -n /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b" 1\tbeta\n 2\talpha\n 3\talpha\n"
|
||||
@@ -0,0 +1,123 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
seed_file(files, f"{ROOT}/src.txt", b"hello")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_file_preserves_bytes(write_ws, dbx_files):
|
||||
io = await write_ws.execute("cp /dbx/src.txt /dbx/dst.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert dbx_files.downloads[f"{ROOT}/dst.txt"] == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_directory_without_recursive_fails(write_ws, dbx_files):
|
||||
seed_directory(dbx_files, f"{ROOT}/d")
|
||||
|
||||
io = await write_ws.execute("cp /dbx/d /dbx/d2")
|
||||
|
||||
assert io.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_writes_are_mount_relative(write_ws):
|
||||
io = await write_ws.execute("cp /dbx/src.txt /dbx/dst.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
for key in io.writes:
|
||||
assert key.startswith("/dbx/")
|
||||
assert not key.startswith("/dbx/dbx/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_read_only_mount_rejected(read_ws, dbx_files):
|
||||
io = await read_ws.execute("cp /dbx/src.txt /dbx/dst.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"read-only" in io.stderr
|
||||
assert f"{ROOT}/dst.txt" not in dbx_files.downloads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_onto_same_path_errors_and_preserves_file(
|
||||
write_ws, dbx_files):
|
||||
io = await write_ws.execute("cp /dbx/src.txt /dbx/src.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"are the same file" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/src.txt"] == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_multiple_sources_require_directory(write_ws, dbx_files):
|
||||
seed_file(dbx_files, f"{ROOT}/a.txt", b"AAA")
|
||||
seed_file(dbx_files, f"{ROOT}/b.txt", b"BBB")
|
||||
seed_file(dbx_files, f"{ROOT}/target.txt", b"target")
|
||||
|
||||
io = await write_ws.execute("cp /dbx/a.txt /dbx/b.txt /dbx/target.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"not a directory" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/a.txt"] == b"AAA"
|
||||
assert dbx_files.downloads[f"{ROOT}/b.txt"] == b"BBB"
|
||||
assert dbx_files.downloads[f"{ROOT}/target.txt"] == b"target"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_missing_source_reports_cannot_stat(write_ws, dbx_files):
|
||||
io = await write_ws.execute("cp /dbx/missing /dbx/missing")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"cannot stat" in io.stderr
|
||||
assert b"are the same file" not in io.stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cp_recursive_into_itself_errors_and_preserves_tree(
|
||||
write_ws, dbx_files):
|
||||
seed_directory(dbx_files, f"{ROOT}/d")
|
||||
seed_file(dbx_files, f"{ROOT}/d/a.txt", b"aaa")
|
||||
|
||||
io = await write_ws.execute("cp -r /dbx/d /dbx/d")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"into itself" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/d/a.txt"] == b"aaa"
|
||||
assert f"{ROOT}/d/d" not in dbx_files.directory_metadata
|
||||
@@ -0,0 +1,87 @@
|
||||
# ========= 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 import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
def _run(ws, cmd):
|
||||
|
||||
async def _inner():
|
||||
io = await ws.execute(cmd)
|
||||
return await io.stdout_str(), await io.stderr_str(), io.exit_code
|
||||
|
||||
return asyncio.run(_inner())
|
||||
|
||||
|
||||
def _ws_with_dbx_tree():
|
||||
files = FakeFiles()
|
||||
files.create_directory(ROOT)
|
||||
files.create_directory(f"{ROOT}/tree")
|
||||
files.create_directory(f"{ROOT}/tree/sub")
|
||||
seed_file(files, f"{ROOT}/tree/a.txt", b"aaa\n")
|
||||
seed_file(files, f"{ROOT}/tree/sub/b.txt", b"bbb\n")
|
||||
dbx = make_resource(files)
|
||||
ram = RAMResource()
|
||||
ws = Workspace(
|
||||
{
|
||||
"/dbx": (dbx, MountMode.WRITE),
|
||||
"/m": (ram, MountMode.WRITE),
|
||||
}, )
|
||||
return ws, files, ram
|
||||
|
||||
|
||||
def test_cp_recursive_databricks_to_ram():
|
||||
ws, _files, ram = _ws_with_dbx_tree()
|
||||
_out, _err, code = _run(ws, "cp -r /dbx/tree /m/copied")
|
||||
assert code == 0
|
||||
assert ram._store.files["/copied/a.txt"] == b"aaa\n"
|
||||
assert ram._store.files["/copied/sub/b.txt"] == b"bbb\n"
|
||||
|
||||
|
||||
def test_cp_recursive_ram_to_databricks_creates_dirs():
|
||||
ws, files, ram = _ws_with_dbx_tree()
|
||||
ram._store.dirs.update({"/src", "/src/sub"})
|
||||
ram._store.files["/src/x.txt"] = b"xxx\n"
|
||||
ram._store.files["/src/sub/y.txt"] = b"yyy\n"
|
||||
_out, _err, code = _run(ws, "cp -r /m/src /dbx/put")
|
||||
assert code == 0
|
||||
assert files.downloads[f"{ROOT}/put/x.txt"] == b"xxx\n"
|
||||
assert files.downloads[f"{ROOT}/put/sub/y.txt"] == b"yyy\n"
|
||||
assert f"{ROOT}/put/sub" in files.directory_metadata
|
||||
|
||||
|
||||
def test_mv_recursive_databricks_to_ram_removes_source():
|
||||
ws, files, ram = _ws_with_dbx_tree()
|
||||
_out, _err, code = _run(ws, "mv /dbx/tree /m/moved")
|
||||
assert code == 0
|
||||
assert ram._store.files["/moved/a.txt"] == b"aaa\n"
|
||||
assert ram._store.files["/moved/sub/b.txt"] == b"bbb\n"
|
||||
assert f"{ROOT}/tree/a.txt" not in files.downloads
|
||||
assert f"{ROOT}/tree" not in files.directory_metadata
|
||||
|
||||
|
||||
def test_single_file_missing_parent_is_posix_error():
|
||||
# Option B / POSIX: a single-file copy never creates the destination's
|
||||
# parent directory; it errors like coreutils instead of mkdir -p.
|
||||
ws, _files, ram = _ws_with_dbx_tree()
|
||||
ram._store.files["/lone.txt"] = b"z\n"
|
||||
_out, _err, code = _run(ws, "cp /m/lone.txt /dbx/nope/deep/lone.txt")
|
||||
assert code == 1
|
||||
@@ -0,0 +1,25 @@
|
||||
# ========= 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_cut(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("cut -d , -f 2 /dbx/table.csv"
|
||||
)
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"score\n2\n3\n"
|
||||
@@ -0,0 +1,37 @@
|
||||
# ========= 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_diff(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"diff -u /dbx/old.txt /dbx/new.txt")
|
||||
|
||||
assert io.exit_code == 1
|
||||
assert b"-old" in io.stdout
|
||||
assert b"+new" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_diff_resolves_glob(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"diff /dbx/old*.txt /dbx/new*.txt")
|
||||
|
||||
assert io.exit_code == 1
|
||||
assert b"old" in io.stdout
|
||||
assert b"new" in io.stdout
|
||||
@@ -0,0 +1,68 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
seed_file(files, f"{ROOT}/words.txt", b"beta\nalpha\nalpha\n")
|
||||
seed_file(files, f"{ROOT}/notes.md", b"note\n")
|
||||
files.create_directory(f"{ROOT}/sub")
|
||||
seed_file(files, f"{ROOT}/sub/inner.txt", b"gamma\nalpha\n")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_name_glob(ws):
|
||||
io = await ws.execute("find /dbx/ -name '*.txt'")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "words.txt" in out
|
||||
assert "inner.txt" in out
|
||||
assert "notes.md" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_d(ws):
|
||||
io = await ws.execute("find /dbx/ -type d")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "sub" in out
|
||||
assert "words.txt" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_maxdepth(ws):
|
||||
io = await ws.execute("find /dbx/ -maxdepth 1")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "words.txt" in out
|
||||
assert "inner.txt" not in out
|
||||
@@ -0,0 +1,77 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
seed_file(files, f"{ROOT}/words.txt", b"beta\nalpha\nalpha\n")
|
||||
files.create_directory(f"{ROOT}/sub")
|
||||
seed_file(files, f"{ROOT}/sub/inner.txt", b"gamma\nalpha\n")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_single_file(ws):
|
||||
io = await ws.execute("grep alpha /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"alpha\nalpha\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_line_numbers(ws):
|
||||
io = await ws.execute("grep -n alpha /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"2:alpha\n3:alpha\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_count_only(ws):
|
||||
io = await ws.execute("grep -c alpha /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"2\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_recursive(ws):
|
||||
io = await ws.execute("grep -r alpha /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "words.txt" in out
|
||||
assert "inner.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_no_match_exits_nonzero(ws):
|
||||
io = await ws.execute("grep zeta /dbx/words.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
@@ -0,0 +1,34 @@
|
||||
# ========= 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_c_positive_prefix(databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("head -c 4 /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"beta"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_c_negative_all_but_last(databricks_text_workspace):
|
||||
# GNU `head -c -N` emits all but the last N bytes. words.txt is 17 bytes,
|
||||
# so -c -6 yields the first 11. A negative -c must not take the prefix
|
||||
# fast path (range read 0..-6 is meaningless).
|
||||
io = await databricks_text_workspace.execute("head -c -6 /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"beta\nalpha\n"
|
||||
@@ -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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_jq(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("jq -r .name /dbx/data.json")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"mirage\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_jq_resolves_glob(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("jq -r .name /dbx/*.json")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"mirage\nagent\n"
|
||||
@@ -0,0 +1,81 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
seed_file(files, f"{ROOT}/words.txt", b"beta\nalpha\nalpha\n")
|
||||
seed_file(files, f"{ROOT}/.hidden", b"h\n")
|
||||
files.create_directory(f"{ROOT}/sub")
|
||||
seed_file(files, f"{ROOT}/sub/inner.txt", b"gamma\nalpha\n")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_lists_entries(ws):
|
||||
io = await ws.execute("ls /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "words.txt" in out
|
||||
assert "sub" in out
|
||||
assert ".hidden" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_a_includes_hidden(ws):
|
||||
io = await ws.execute("ls -a /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert ".hidden" in io.stdout.decode()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_long_includes_size(ws):
|
||||
io = await ws.execute("ls -l /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "words.txt" in out
|
||||
assert "17" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_recursive_descends_subdirs(ws):
|
||||
io = await ws.execute("ls -R /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert "inner.txt" in io.stdout.decode()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_missing_path_warns(ws):
|
||||
io = await ws.execute("ls /dbx/missing")
|
||||
|
||||
assert io.exit_code != 0
|
||||
@@ -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 pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_creates_directory(write_ws, dbx_files):
|
||||
io = await write_ws.execute("mkdir /dbx/newdir")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert f"{ROOT}/newdir" in dbx_files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_parent_missing_fails(write_ws):
|
||||
io = await write_ws.execute("mkdir /dbx/a/b")
|
||||
|
||||
assert io.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_parents_creates_chain(write_ws, dbx_files):
|
||||
io = await write_ws.execute("mkdir -p /dbx/a/b/c")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert f"{ROOT}/a/b/c" in dbx_files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_writes_are_mount_relative(write_ws):
|
||||
io = await write_ws.execute("mkdir /dbx/newdir")
|
||||
|
||||
assert io.exit_code == 0
|
||||
for key in io.writes:
|
||||
assert key.startswith("/dbx/")
|
||||
assert not key.startswith("/dbx/dbx/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_read_only_mount_rejected(read_ws, dbx_files):
|
||||
io = await read_ws.execute("mkdir /dbx/newdir")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"read-only" in io.stderr
|
||||
assert dbx_files.create_directory_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ops_mkdir(write_ws, dbx_files):
|
||||
await write_ws.ops.mkdir("/dbx/opdir")
|
||||
|
||||
assert f"{ROOT}/opdir" in dbx_files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ops_mkdir_read_only_rejected(read_ws):
|
||||
with pytest.raises(PermissionError):
|
||||
await read_ws.ops.mkdir("/dbx/opdir")
|
||||
@@ -0,0 +1,156 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
seed_file(files, f"{ROOT}/src.txt", b"data")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_file_moves_bytes(write_ws, dbx_files):
|
||||
io = await write_ws.execute("mv /dbx/src.txt /dbx/dst.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert dbx_files.downloads[f"{ROOT}/dst.txt"] == b"data"
|
||||
assert f"{ROOT}/src.txt" not in dbx_files.downloads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_writes_both_parents_mount_relative(write_ws):
|
||||
io = await write_ws.execute("mv /dbx/src.txt /dbx/dst.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert "/dbx/src.txt" in io.writes
|
||||
assert "/dbx/dst.txt" in io.writes
|
||||
for key in io.writes:
|
||||
assert not key.startswith("/dbx/dbx/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_no_clobber_skips_existing(write_ws, dbx_files):
|
||||
seed_file(dbx_files, f"{ROOT}/dst.txt", b"keep")
|
||||
|
||||
io = await write_ws.execute("mv -n /dbx/src.txt /dbx/dst.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert dbx_files.downloads[f"{ROOT}/dst.txt"] == b"keep"
|
||||
assert dbx_files.downloads[f"{ROOT}/src.txt"] == b"data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_read_only_mount_rejected(read_ws, dbx_files):
|
||||
io = await read_ws.execute("mv /dbx/src.txt /dbx/dst.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"read-only" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/src.txt"] == b"data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ops_rename(write_ws, dbx_files):
|
||||
await write_ws.ops.rename("/dbx/src.txt", "/dbx/renamed.txt")
|
||||
|
||||
assert dbx_files.downloads[f"{ROOT}/renamed.txt"] == b"data"
|
||||
assert f"{ROOT}/src.txt" not in dbx_files.downloads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_onto_same_path_errors_and_preserves_file(
|
||||
write_ws, dbx_files):
|
||||
io = await write_ws.execute("mv /dbx/src.txt /dbx/src.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"are the same file" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/src.txt"] == b"data"
|
||||
assert f"{ROOT}/src.txt" not in dbx_files.delete_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_into_dir_where_file_already_lives_errors_and_preserves_file(
|
||||
write_ws, dbx_files):
|
||||
io = await write_ws.execute("mv /dbx/src.txt /dbx/")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"are the same file" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/src.txt"] == b"data"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ops_rename_onto_same_path_is_noop(write_ws, dbx_files):
|
||||
await write_ws.ops.rename("/dbx/src.txt", "/dbx/src.txt")
|
||||
|
||||
assert dbx_files.downloads[f"{ROOT}/src.txt"] == b"data"
|
||||
assert f"{ROOT}/src.txt" not in dbx_files.delete_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_multiple_sources_require_directory(write_ws, dbx_files):
|
||||
seed_file(dbx_files, f"{ROOT}/a.txt", b"AAA")
|
||||
seed_file(dbx_files, f"{ROOT}/b.txt", b"BBB")
|
||||
seed_file(dbx_files, f"{ROOT}/target.txt", b"target")
|
||||
|
||||
io = await write_ws.execute("mv /dbx/a.txt /dbx/b.txt /dbx/target.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"not a directory" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/a.txt"] == b"AAA"
|
||||
assert dbx_files.downloads[f"{ROOT}/b.txt"] == b"BBB"
|
||||
assert dbx_files.downloads[f"{ROOT}/target.txt"] == b"target"
|
||||
assert f"{ROOT}/a.txt" not in dbx_files.delete_calls
|
||||
assert f"{ROOT}/b.txt" not in dbx_files.delete_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_missing_source_reports_cannot_stat(write_ws, dbx_files):
|
||||
io = await write_ws.execute("mv /dbx/missing /dbx/missing")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"cannot stat" in io.stderr
|
||||
assert b"are the same file" not in io.stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mv_into_itself_errors_and_preserves_source(write_ws, dbx_files):
|
||||
seed_directory(dbx_files, f"{ROOT}/d")
|
||||
seed_file(dbx_files, f"{ROOT}/d/a.txt", b"aaa")
|
||||
|
||||
io = await write_ws.execute("mv /dbx/d /dbx/d")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"subdirectory of itself" in io.stderr
|
||||
assert dbx_files.downloads[f"{ROOT}/d/a.txt"] == b"aaa"
|
||||
assert f"{ROOT}/d" in dbx_files.directory_metadata
|
||||
assert f"{ROOT}/d/a.txt" not in dbx_files.delete_calls
|
||||
@@ -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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_nl(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("nl /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert b" 1\tbeta\n" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_nl_resolves_glob(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("nl /dbx/*.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert b" 1\tdelta\n" in io.stdout
|
||||
@@ -0,0 +1,41 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.databricks_volume import COMMANDS
|
||||
|
||||
TEXT_COMMANDS = {
|
||||
"awk",
|
||||
"cut",
|
||||
"diff",
|
||||
"jq",
|
||||
"nl",
|
||||
"sed",
|
||||
"sort",
|
||||
"tr",
|
||||
"uniq",
|
||||
"wc",
|
||||
}
|
||||
|
||||
|
||||
def test_databricks_volume_text_commands_registered_read_only():
|
||||
registered = [
|
||||
registered for command in COMMANDS
|
||||
for registered in command._registered_commands
|
||||
]
|
||||
names = {command.name for command in registered}
|
||||
assert TEXT_COMMANDS <= names
|
||||
for command in registered:
|
||||
if command.name in TEXT_COMMANDS:
|
||||
assert command.resource == "databricks_volume"
|
||||
assert not command.write
|
||||
@@ -0,0 +1,61 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
seed_file(files, f"{ROOT}/words.txt", b"beta\nalpha\nalpha\n")
|
||||
files.create_directory(f"{ROOT}/sub")
|
||||
seed_file(files, f"{ROOT}/sub/inner.txt", b"gamma\nalpha\n")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_searches_path_recursively(ws):
|
||||
io = await ws.execute("rg alpha /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "words.txt" in out
|
||||
assert "inner.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_count_only(ws):
|
||||
io = await ws.execute("rg -c alpha /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert b"2" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_no_match_exits_nonzero(ws):
|
||||
io = await ws.execute("rg zeta /dbx/words.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
@@ -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 pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
files.create_directory(f"{ROOT}/d")
|
||||
seed_file(files, f"{ROOT}/d/a.txt", b"a")
|
||||
files.create_directory(f"{ROOT}/d/sub")
|
||||
seed_file(files, f"{ROOT}/d/sub/b.txt", b"b")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def write_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def read_ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_recursive_removes_tree(write_ws, dbx_files):
|
||||
io = await write_ws.execute("rm -r /dbx/d")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert f"{ROOT}/d" not in dbx_files.directory_metadata
|
||||
assert f"{ROOT}/d/sub/b.txt" not in dbx_files.downloads
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_recursive_writes_are_mount_relative(write_ws):
|
||||
io = await write_ws.execute("rm -r /dbx/d")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.writes
|
||||
for key in io.writes:
|
||||
assert key.startswith("/dbx/")
|
||||
assert not key.startswith("/dbx/dbx/")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_rm_on_directory_fails(write_ws, dbx_files):
|
||||
io = await write_ws.execute("rm /dbx/d")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert f"{ROOT}/d" in dbx_files.directory_metadata
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_force_missing_succeeds(write_ws):
|
||||
io = await write_ws.execute("rm -f /dbx/missing.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_missing_fails(write_ws):
|
||||
io = await write_ws.execute("rm /dbx/missing.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_recursive_read_only_rejected(read_ws, dbx_files):
|
||||
io = await read_ws.execute("rm -r /dbx/d")
|
||||
|
||||
assert io.exit_code != 0
|
||||
assert b"read-only" in io.stderr
|
||||
assert f"{ROOT}/d" in dbx_files.directory_metadata
|
||||
@@ -0,0 +1,60 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.databricks_volume import COMMANDS
|
||||
|
||||
|
||||
def _sed_command():
|
||||
for cmd in COMMANDS:
|
||||
for rc in cmd._registered_commands:
|
||||
if rc.name == "sed":
|
||||
return cmd
|
||||
raise LookupError("sed not registered for databricks_volume")
|
||||
|
||||
|
||||
sed_command = _sed_command()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_sed(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"sed s/alpha/ALPHA/g /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"beta\nALPHA\nALPHA\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_sed_resolves_glob(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"sed s/delta/DELTA/ /dbx/*.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert b"DELTA\n" in io.stdout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_databricks_volume_sed_in_place_writes_back(
|
||||
databricks_text_workspace):
|
||||
# The volume has a write op, so -i edits in place through the shared
|
||||
# builder (the old bespoke wrapper refused it unconditionally, #382).
|
||||
io = await databricks_text_workspace.execute(
|
||||
"sed -i s/alpha/ALPHA/g /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.writes.get("/dbx/words.txt") == b"beta\nALPHA\nALPHA\n"
|
||||
@@ -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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_sort(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("sort /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"alpha\nalpha\nbeta\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_sort_resolves_glob(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("sort /dbx/*.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert b"alpha\nalpha\nbeta\n" in io.stdout
|
||||
@@ -0,0 +1,41 @@
|
||||
# ========= 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_default_format(databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("stat /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "name=words.txt" in out
|
||||
assert "size=17" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_custom_format(databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"stat -c '%n %s' /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().strip() == "/dbx/words.txt 17"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_missing_file_fails(databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("stat /dbx/missing.txt")
|
||||
|
||||
assert io.exit_code != 0
|
||||
@@ -0,0 +1,25 @@
|
||||
# ========= 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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_tr(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute(
|
||||
"cat /dbx/words.txt | tr a-z A-Z")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"BETA\nALPHA\nALPHA\n"
|
||||
@@ -0,0 +1,69 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from tests.resource.databricks_volume.test_databricks_volume import (
|
||||
FakeFiles, make_resource, seed_directory, seed_file)
|
||||
|
||||
ROOT = "/Volumes/main/default/agent_files/root"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dbx_files() -> FakeFiles:
|
||||
files = FakeFiles()
|
||||
seed_directory(files, ROOT)
|
||||
seed_file(files, f"{ROOT}/words.txt", b"beta\nalpha\nalpha\n")
|
||||
files.create_directory(f"{ROOT}/sub")
|
||||
seed_file(files, f"{ROOT}/sub/inner.txt", b"gamma\nalpha\n")
|
||||
files.create_directory(f"{ROOT}/sub/deep")
|
||||
seed_file(files, f"{ROOT}/sub/deep/leaf.txt", b"leaf\n")
|
||||
return files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(dbx_files: FakeFiles) -> Workspace:
|
||||
return Workspace({"/dbx/": make_resource(dbx_files)}, mode=MountMode.READ)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_lists_nested_entries(ws):
|
||||
io = await ws.execute("tree /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "words.txt" in out
|
||||
assert "inner.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_max_depth(ws):
|
||||
io = await ws.execute("tree -L 1 /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "sub" in out
|
||||
assert "inner.txt" in out
|
||||
assert "leaf.txt" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_dirs_only(ws):
|
||||
io = await ws.execute("tree -d /dbx/")
|
||||
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "sub" in out
|
||||
assert "words.txt" not in out
|
||||
@@ -0,0 +1,24 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_uniq(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("uniq /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"beta\nalpha\n"
|
||||
@@ -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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_wc(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("wc -l /dbx/words.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"3 /dbx/words.txt\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_execute_databricks_volume_wc_resolves_glob(
|
||||
databricks_text_workspace):
|
||||
io = await databricks_text_workspace.execute("wc -l /dbx/*.txt")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert b"/dbx/more.txt" in io.stdout
|
||||
@@ -0,0 +1,60 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def document(document_id: str, name: str, slug: str, size: int = 12) -> dict:
|
||||
return {
|
||||
"id": document_id,
|
||||
"name": name,
|
||||
"doc_metadata": [{
|
||||
"name": "slug",
|
||||
"value": slug
|
||||
}],
|
||||
"enabled": True,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
"tokens": 4,
|
||||
"data_source_type": "upload_file",
|
||||
"data_source_detail_dict": {
|
||||
"upload_file": {
|
||||
"size": size
|
||||
}
|
||||
},
|
||||
"created_at": 1716282000,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dify_accessor() -> SimpleNamespace:
|
||||
return SimpleNamespace(config=SimpleNamespace(slug_metadata_name="slug"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dify_index() -> RAMIndexCacheStore:
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def knowledge_root() -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key("/knowledge", "/knowledge"),
|
||||
virtual="/knowledge",
|
||||
directory="/knowledge")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guide_path() -> PathSpec:
|
||||
return PathSpec.from_str_path(
|
||||
"/knowledge/guides/quickstart.md",
|
||||
mount_key("/knowledge/guides/quickstart.md", "/knowledge"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guides_path() -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key("/knowledge/guides", "/knowledge"),
|
||||
virtual="/knowledge/guides",
|
||||
directory="/knowledge/guides")
|
||||
@@ -0,0 +1,76 @@
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.dify.cat import make_cat
|
||||
from mirage.commands.builtin.generic_bind import CommandIO, with_read_cache
|
||||
from mirage.core.dify import read, tree
|
||||
from mirage.core.dify.read import read_bytes as _read_bytes
|
||||
from mirage.core.dify.read import read_stream as _read_stream
|
||||
from mirage.core.dify.readdir import readdir as _readdir
|
||||
from mirage.core.dify.stat import stat as _stat
|
||||
from mirage.io.types import materialize
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import document
|
||||
|
||||
cat = make_cat(
|
||||
with_read_cache(
|
||||
CommandIO(
|
||||
readdir=_readdir,
|
||||
read_bytes=_read_bytes,
|
||||
read_stream=_read_stream,
|
||||
stat=_stat,
|
||||
is_mounted=lambda a: True,
|
||||
local=False,
|
||||
)))
|
||||
|
||||
|
||||
async def list_basic_documents(config):
|
||||
return [
|
||||
document("doc-1", "Guide", "guides/quickstart.md"),
|
||||
document("doc-2", "Readme", "README.md"),
|
||||
]
|
||||
|
||||
|
||||
async def iter_basic_pages(config, document_id):
|
||||
if document_id == "doc-1":
|
||||
yield [{"content": "alpha\nbeta"}, {"content": "gamma"}]
|
||||
else:
|
||||
yield [{"content": "readme"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_reads_stream_and_records_cache(monkeypatch, dify_accessor,
|
||||
dify_index, guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
monkeypatch.setattr(read, "iter_segment_pages", iter_basic_pages)
|
||||
|
||||
stdout, io = await cat(dify_accessor, [guide_path], index=dify_index)
|
||||
|
||||
assert await materialize(stdout) == b"alpha\nbeta\ngamma"
|
||||
assert guide_path.mount_path in io.reads
|
||||
assert io.cache == [guide_path.mount_path]
|
||||
|
||||
|
||||
async def get_two_doc_segments(config, document_id):
|
||||
if document_id == "doc-1":
|
||||
return [{"content": "alpha\nbeta"}, {"content": "gamma"}]
|
||||
return [{"content": "readme"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_multifile_caches_materialized_bytes_per_file(
|
||||
monkeypatch, dify_accessor, dify_index, guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
monkeypatch.setattr(read, "get_document_segments", get_two_doc_segments)
|
||||
|
||||
readme_path = PathSpec.from_str_path(
|
||||
"/knowledge/README.md", mount_key("/knowledge/README.md",
|
||||
"/knowledge"))
|
||||
stdout, io = await cat(dify_accessor, [guide_path, readme_path],
|
||||
index=dify_index)
|
||||
|
||||
assert io.reads[guide_path.mount_path] == b"alpha\nbeta\ngamma"
|
||||
assert io.reads[readme_path.mount_path] == b"readme"
|
||||
assert all(isinstance(v, bytes) for v in io.reads.values())
|
||||
assert await materialize(stdout) == b"alpha\nbeta\ngammareadme"
|
||||
@@ -0,0 +1,98 @@
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.dify.find import find
|
||||
from mirage.core.dify import tree
|
||||
from mirage.io.types import materialize
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
from .conftest import document
|
||||
|
||||
|
||||
async def list_basic_documents(config):
|
||||
return [
|
||||
document("doc-1", "Guide", "guides/quickstart.md"),
|
||||
document("doc-2", "Readme", "README.md"),
|
||||
]
|
||||
|
||||
|
||||
async def list_nested_documents(config):
|
||||
return [
|
||||
document("doc-1", "Guide", "guides/quickstart.md"),
|
||||
document("doc-2", "Guide 2", "guides/deep/note.md"),
|
||||
document("doc-3", "Readme", "README.md"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matches_name_pattern(monkeypatch, dify_accessor,
|
||||
dify_index, knowledge_root):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
|
||||
stdout, _ = await find(dify_accessor, [knowledge_root],
|
||||
"quick*.md",
|
||||
index=dify_index)
|
||||
|
||||
assert await materialize(stdout) == b"/knowledge/guides/quickstart.md\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_handles_file_missing_and_maxdepth(monkeypatch,
|
||||
dify_accessor,
|
||||
dify_index,
|
||||
knowledge_root,
|
||||
guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_basic_documents)
|
||||
|
||||
file_stdout, file_io = await find(dify_accessor, [guide_path],
|
||||
index=dify_index)
|
||||
assert await materialize(file_stdout
|
||||
) == b"/knowledge/guides/quickstart.md\n"
|
||||
assert file_io.exit_code == 0
|
||||
|
||||
maxdepth_stdout, maxdepth_io = await find(dify_accessor, [knowledge_root],
|
||||
maxdepth="0",
|
||||
index=dify_index)
|
||||
assert await materialize(maxdepth_stdout) == b"/knowledge\n"
|
||||
assert maxdepth_io.exit_code == 0
|
||||
|
||||
missing = PathSpec.from_str_path(
|
||||
"/knowledge/missing.md",
|
||||
mount_key("/knowledge/missing.md", "/knowledge"))
|
||||
missing_stdout, missing_io = await find(dify_accessor, [missing],
|
||||
index=dify_index)
|
||||
assert await materialize(missing_stdout) == b""
|
||||
assert missing_io.stderr is not None
|
||||
assert b"/knowledge/missing.md" in missing_io.stderr
|
||||
assert missing_io.exit_code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_uses_cwd_when_path_missing(monkeypatch, dify_accessor,
|
||||
dify_index, guides_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_nested_documents)
|
||||
|
||||
stdout, io = await find(dify_accessor, [],
|
||||
"quick*.md",
|
||||
cwd=guides_path,
|
||||
index=dify_index)
|
||||
|
||||
assert await materialize(stdout) == b"/knowledge/guides/quickstart.md\n"
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_resolves_glob_patterns(monkeypatch, dify_accessor,
|
||||
dify_index):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_nested_documents)
|
||||
path = PathSpec(resource_path=mount_key("/knowledge/guides/*.md",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/*.md",
|
||||
directory="/knowledge/guides",
|
||||
pattern="*.md",
|
||||
resolved=False)
|
||||
|
||||
stdout, io = await find(dify_accessor, [path], index=dify_index)
|
||||
|
||||
assert await materialize(stdout) == b"/knowledge/guides/quickstart.md\n"
|
||||
assert io.exit_code == 0
|
||||
@@ -0,0 +1,108 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.cache.index import RAMIndexCacheStore
|
||||
from mirage.io.types import materialize
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def document(document_id: str, name: str, slug: str | None = None) -> dict:
|
||||
metadata = []
|
||||
if slug is not None:
|
||||
metadata = [{"name": "slug", "value": slug}]
|
||||
return {
|
||||
"id": document_id,
|
||||
"name": name,
|
||||
"doc_metadata": metadata,
|
||||
"enabled": True,
|
||||
"indexing_status": "completed",
|
||||
"archived": False,
|
||||
"tokens": 4,
|
||||
"data_source_type": "upload_file",
|
||||
"data_source_detail_dict": {
|
||||
"upload_file": {
|
||||
"size": 12
|
||||
}
|
||||
},
|
||||
"created_at": 1716282000,
|
||||
}
|
||||
|
||||
|
||||
def accessor() -> SimpleNamespace:
|
||||
return SimpleNamespace(config=SimpleNamespace(slug_metadata_name="slug"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_command_resolves_globs_and_passes_multiple_documents(
|
||||
monkeypatch):
|
||||
from mirage.commands.builtin.dify import search as command_search
|
||||
from mirage.core.dify import search, tree
|
||||
|
||||
calls: list[tuple[list[PathSpec], dict]] = []
|
||||
|
||||
async def list_documents(config):
|
||||
return [
|
||||
document("doc-1", "API", "guides/api.md"),
|
||||
document("doc-2", "Auth", "guides/auth.md"),
|
||||
]
|
||||
|
||||
async def search_segments(accessor, query, paths, index, **kwargs):
|
||||
calls.append((paths, kwargs))
|
||||
return b"api\nauth"
|
||||
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
monkeypatch.setattr(search, "search_segments", search_segments)
|
||||
|
||||
stdout, io = await command_search(
|
||||
accessor(),
|
||||
[
|
||||
PathSpec(resource_path=mount_key("/knowledge/guides/*.md",
|
||||
"/knowledge"),
|
||||
virtual="/knowledge/guides/*.md",
|
||||
directory="/knowledge/guides",
|
||||
pattern="*.md",
|
||||
resolved=False)
|
||||
],
|
||||
"login",
|
||||
index=RAMIndexCacheStore(),
|
||||
)
|
||||
|
||||
assert await materialize(stdout) == b"api\nauth"
|
||||
assert io.reads == {}
|
||||
assert io.cache == []
|
||||
assert [path.virtual for path in calls[0][0]] == [
|
||||
"/knowledge/guides/api.md",
|
||||
"/knowledge/guides/auth.md",
|
||||
]
|
||||
assert calls[0][1]["mount_prefix"] == "/knowledge"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_command_root_searches_whole_dataset(monkeypatch):
|
||||
from mirage.commands.builtin.dify import search as command_search
|
||||
from mirage.core.dify import search
|
||||
|
||||
calls: list[tuple[list[PathSpec], dict]] = []
|
||||
|
||||
async def search_segments(accessor, query, paths, index, **kwargs):
|
||||
calls.append((paths, kwargs))
|
||||
return b"dataset"
|
||||
|
||||
monkeypatch.setattr(search, "search_segments", search_segments)
|
||||
root = PathSpec(resource_path=mount_key("/knowledge", "/knowledge"),
|
||||
virtual="/knowledge",
|
||||
directory="/knowledge")
|
||||
|
||||
stdout, _ = await command_search(accessor(), [root],
|
||||
"anything",
|
||||
index=RAMIndexCacheStore())
|
||||
|
||||
assert await materialize(stdout) == b"dataset"
|
||||
assert calls == [([], {
|
||||
"method": "semantic",
|
||||
"top_k": 10,
|
||||
"threshold": 0.0,
|
||||
"mount_prefix": "/knowledge"
|
||||
})]
|
||||
@@ -0,0 +1,53 @@
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.dify import COMMANDS
|
||||
from mirage.core.dify import read, tree
|
||||
from mirage.io.types import materialize
|
||||
|
||||
from .conftest import document
|
||||
|
||||
|
||||
def _sed_command():
|
||||
for cmd in COMMANDS:
|
||||
for rc in cmd._registered_commands:
|
||||
if rc.name == "sed":
|
||||
return cmd
|
||||
raise LookupError("sed not registered for dify")
|
||||
|
||||
|
||||
sed = _sed_command()
|
||||
|
||||
|
||||
async def list_documents(config):
|
||||
return [document("doc-1", "Guide", "guides/quickstart.md")]
|
||||
|
||||
|
||||
async def get_segments(config, document_id):
|
||||
return [{"content": "alpha beta"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_transforms_dify_document(monkeypatch, dify_accessor,
|
||||
dify_index, guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
monkeypatch.setattr(read, "get_document_segments", get_segments)
|
||||
|
||||
stdout, io = await sed(dify_accessor, [guide_path],
|
||||
"s/alpha/gamma/",
|
||||
index=dify_index)
|
||||
|
||||
assert await materialize(stdout) == b"gamma beta"
|
||||
assert io.exit_code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sed_rejects_in_place(monkeypatch, dify_accessor, dify_index,
|
||||
guide_path):
|
||||
monkeypatch.setattr(tree, "list_all_documents", list_documents)
|
||||
|
||||
with pytest.raises(PermissionError,
|
||||
match="-i not supported on this backend"):
|
||||
await sed(dify_accessor, [guide_path],
|
||||
"s/alpha/gamma/",
|
||||
i=True,
|
||||
index=dify_index)
|
||||
@@ -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,76 @@
|
||||
# ========= 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 unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.discord import DiscordAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.commands.builtin.discord import COMMANDS
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
GUILDS = [{"id": "G1", "name": "myguild"}]
|
||||
|
||||
GUILD_DIR = "/myguild__G1"
|
||||
|
||||
|
||||
def _find_command():
|
||||
for fn in COMMANDS:
|
||||
for rc in getattr(fn, "_registered_commands", []):
|
||||
if rc.name == "find" and rc.filetype is None:
|
||||
return fn
|
||||
raise AssertionError("factory find not registered for discord")
|
||||
|
||||
|
||||
def _spec(virtual: str) -> PathSpec:
|
||||
return PathSpec(virtual=virtual,
|
||||
directory=virtual,
|
||||
resource_path=virtual.strip("/"))
|
||||
|
||||
|
||||
async def _run(paths, *texts: str, **flags) -> list[str]:
|
||||
accessor = DiscordAccessor(DiscordConfig(token="t"))
|
||||
find = _find_command()
|
||||
with patch("mirage.core.discord.readdir.list_guilds",
|
||||
new_callable=AsyncMock,
|
||||
return_value=GUILDS):
|
||||
stdout, _io = await find(accessor,
|
||||
paths,
|
||||
*texts,
|
||||
index=RAMIndexCacheStore(),
|
||||
**flags)
|
||||
data = stdout if isinstance(stdout, bytes) else b""
|
||||
return data.decode().splitlines()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_walk_lists_guild_tree():
|
||||
lines = await _run([_spec("/")], maxdepth="2")
|
||||
assert GUILD_DIR in lines
|
||||
assert f"{GUILD_DIR}/channels" in lines
|
||||
assert f"{GUILD_DIR}/members" in lines
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_pattern_is_honored():
|
||||
lines = await _run([_spec("/")], maxdepth="2", path="*channels*")
|
||||
assert lines == [f"{GUILD_DIR}/channels"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_is_honored_dirs_count_as_zero():
|
||||
lines = await _run([_spec("/")], maxdepth="2", size="+0c")
|
||||
assert lines == []
|
||||
@@ -0,0 +1,252 @@
|
||||
# ========= 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 unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.discord.grep import grep
|
||||
from mirage.commands.builtin.discord.rg import rg
|
||||
from mirage.types import FileStat, FileType, PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _concrete_paths(n: int = 7):
|
||||
paths = []
|
||||
for d in range(1, n + 1):
|
||||
original = (
|
||||
f"/discord/myguild/channels/general/2026-01-{d:02d}/chat.jsonl")
|
||||
paths.append(
|
||||
PathSpec(
|
||||
resource_path=mount_key(original, "/discord"),
|
||||
virtual=original,
|
||||
directory=original,
|
||||
))
|
||||
return paths
|
||||
|
||||
|
||||
def _fake_index(channel_id: str = "ch_456", guild_id: str = "g_123"):
|
||||
idx = AsyncMock()
|
||||
|
||||
async def _get(virtual_key):
|
||||
result = AsyncMock()
|
||||
if virtual_key.endswith("/myguild/channels/general"):
|
||||
result.entry = type("E", (), {"id": channel_id})
|
||||
elif virtual_key.endswith("/myguild"):
|
||||
result.entry = type("E", (), {"id": guild_id})
|
||||
else:
|
||||
result.entry = None
|
||||
return result
|
||||
|
||||
idx.get.side_effect = _get
|
||||
return idx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_grep_with_many_concrete_paths_uses_native_search():
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
fake_msgs = [{
|
||||
"content": "hello world",
|
||||
"channel_id": "ch_456",
|
||||
"author": {
|
||||
"username": "alice"
|
||||
},
|
||||
"timestamp": "2026-01-15T12:34:56.000000+00:00",
|
||||
"id": "1"
|
||||
}]
|
||||
fake_channels = [{"id": "ch_456", "name": "general"}]
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.grep.search_guild",
|
||||
new=AsyncMock(return_value=fake_msgs),
|
||||
) as fake_search, patch(
|
||||
"mirage.commands.builtin.discord.grep.list_channels",
|
||||
new=AsyncMock(return_value=fake_channels),
|
||||
):
|
||||
out, io = await grep(accessor,
|
||||
_concrete_paths(7),
|
||||
"hello",
|
||||
index=_fake_index())
|
||||
assert fake_search.await_count == 1
|
||||
assert io.exit_code == 0
|
||||
assert b"hello" in out
|
||||
assert out.endswith(b"\n")
|
||||
assert (b"/discord/myguild/channels/general__ch_456/"
|
||||
b"2026-01-15/chat.jsonl:") in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_grep_falls_back_when_native_raises():
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
paths = [
|
||||
PathSpec(resource_path=mount_key(
|
||||
"/discord/myguild/channels/general/*.jsonl", "/discord"),
|
||||
virtual="/discord/myguild/channels/general/*.jsonl",
|
||||
directory="/discord/myguild/channels/general/",
|
||||
pattern="*.jsonl"),
|
||||
]
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.grep.search_guild",
|
||||
new=AsyncMock(side_effect=RuntimeError("rate limited")),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.grep.resolve_glob",
|
||||
new=AsyncMock(return_value=paths),
|
||||
) as fake_resolve, patch(
|
||||
"mirage.commands.builtin.discord.grep.discord_read",
|
||||
new=AsyncMock(return_value=b""),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.grep._stat",
|
||||
new=AsyncMock(return_value=FileStat(name="2026-04-10.jsonl",
|
||||
type=FileType.TEXT)),
|
||||
):
|
||||
out, io = await grep(accessor, paths, "hello", index=_fake_index())
|
||||
assert fake_resolve.await_count == 1
|
||||
assert io.exit_code in (0, 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_grep_native_empty_does_not_trigger_fallback():
|
||||
"""search_guild returning [] is a legit no-match — don't double-scan."""
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.grep.search_guild",
|
||||
new=AsyncMock(return_value=[]),
|
||||
) as fake_search, patch(
|
||||
"mirage.commands.builtin.discord.grep.list_channels",
|
||||
new=AsyncMock(return_value=[]),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.grep.discord_read",
|
||||
new=AsyncMock(return_value=b""),
|
||||
) as fake_read:
|
||||
out, io = await grep(accessor,
|
||||
_concrete_paths(7),
|
||||
"missing",
|
||||
index=_fake_index())
|
||||
assert fake_search.await_count == 1
|
||||
assert fake_read.await_count == 0
|
||||
assert io.exit_code == 1
|
||||
assert out == b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_grep_multi_pattern_skips_native_search():
|
||||
"""grep -e a -e b must bypass the native search push-down.
|
||||
|
||||
The push-down passes a single newline-joined pattern to the native
|
||||
search, which treats it as one literal and matches nothing. Multiple
|
||||
-e patterns must fall through to the generic grep instead.
|
||||
"""
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
paths = [
|
||||
PathSpec(resource_path=mount_key(
|
||||
"/discord/myguild/channels/general/*.jsonl", "/discord"),
|
||||
virtual="/discord/myguild/channels/general/*.jsonl",
|
||||
directory="/discord/myguild/channels/general/",
|
||||
pattern="*.jsonl"),
|
||||
]
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.grep.search_guild",
|
||||
new=AsyncMock(return_value=[]),
|
||||
) as fake_search, patch(
|
||||
"mirage.commands.builtin.discord.grep.resolve_glob",
|
||||
new=AsyncMock(return_value=paths),
|
||||
) as fake_resolve, patch(
|
||||
"mirage.commands.builtin.discord.grep.discord_read",
|
||||
new=AsyncMock(return_value=b""),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.grep._stat",
|
||||
new=AsyncMock(return_value=FileStat(name="2026-04-10.jsonl",
|
||||
type=FileType.TEXT)),
|
||||
):
|
||||
_, io = await grep(accessor,
|
||||
paths,
|
||||
e=["ada", "ben"],
|
||||
index=_fake_index())
|
||||
assert fake_search.await_count == 0
|
||||
assert fake_resolve.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_rg_with_many_concrete_paths_uses_native_search():
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
fake_msgs = [{
|
||||
"content": "hello rg",
|
||||
"channel_id": "ch_456",
|
||||
"author": {
|
||||
"username": "bob"
|
||||
},
|
||||
"timestamp": "2026-01-15T08:00:00.000000+00:00",
|
||||
"id": "2"
|
||||
}]
|
||||
fake_channels = [{"id": "ch_456", "name": "general"}]
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.rg.search_guild",
|
||||
new=AsyncMock(return_value=fake_msgs),
|
||||
) as fake_search, patch(
|
||||
"mirage.commands.builtin.discord.rg.list_channels",
|
||||
new=AsyncMock(return_value=fake_channels),
|
||||
):
|
||||
out, io = await rg(accessor,
|
||||
_concrete_paths(7),
|
||||
"hello",
|
||||
index=_fake_index())
|
||||
assert fake_search.await_count == 1
|
||||
assert io.exit_code == 0
|
||||
assert b"hello" in out
|
||||
assert out.endswith(b"\n")
|
||||
assert (b"/discord/myguild/channels/general__ch_456/"
|
||||
b"2026-01-15/chat.jsonl:") in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_rg_multi_pattern_skips_native_search():
|
||||
"""rg -e a -e b must bypass the native search push-down.
|
||||
|
||||
Like grep, the push-down passes a single newline-joined pattern to the
|
||||
native search, which matches nothing. Multiple -e patterns must fall
|
||||
through to the generic rg instead.
|
||||
"""
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
paths = [
|
||||
PathSpec(resource_path=mount_key(
|
||||
"/discord/myguild/channels/general/*.jsonl", "/discord"),
|
||||
virtual="/discord/myguild/channels/general/*.jsonl",
|
||||
directory="/discord/myguild/channels/general/",
|
||||
pattern="*.jsonl"),
|
||||
]
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.rg.search_guild",
|
||||
new=AsyncMock(return_value=[]),
|
||||
) as fake_search, patch(
|
||||
"mirage.commands.builtin.discord.rg.resolve_glob",
|
||||
new=AsyncMock(return_value=paths),
|
||||
) as fake_resolve, patch(
|
||||
"mirage.commands.builtin.discord.rg.discord_read",
|
||||
new=AsyncMock(return_value=b""),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.rg._stat",
|
||||
new=AsyncMock(return_value=FileStat(name="2026-04-10.jsonl",
|
||||
type=FileType.TEXT)),
|
||||
):
|
||||
_, io = await rg(accessor,
|
||||
paths,
|
||||
e=["ada", "ben"],
|
||||
index=_fake_index())
|
||||
assert fake_search.await_count == 0
|
||||
assert fake_resolve.await_count == 1
|
||||
@@ -0,0 +1,232 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.discord import DiscordAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.commands.builtin.discord import COMMANDS
|
||||
from mirage.commands.builtin.discord.grep import grep
|
||||
from mirage.commands.builtin.discord.head import head
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
GUILD_PATH = "TestGuild"
|
||||
CHANNEL_PATH = "TestGuild/channels/general"
|
||||
DATE_DIR_PATH = "TestGuild/channels/general/2024-01-15"
|
||||
FILE_PATH = "TestGuild/channels/general/2024-01-15/chat.jsonl"
|
||||
ABS_FILE = "/" + FILE_PATH
|
||||
ABS_CHANNEL = "/" + CHANNEL_PATH
|
||||
ABS_DATE_DIR = "/" + DATE_DIR_PATH
|
||||
|
||||
|
||||
def _glob_result(path: str) -> list[PathSpec]:
|
||||
return [
|
||||
PathSpec(resource_path=(path).strip("/"),
|
||||
virtual=path,
|
||||
directory="/",
|
||||
resolved=True)
|
||||
]
|
||||
|
||||
|
||||
FAKE_JSONL = (
|
||||
b'{"id":"1","content":"hello world","author":{"username":"alice"}}\n'
|
||||
b'{"id":"2","content":"goodbye moon","author":{"username":"bob"}}\n'
|
||||
b'{"id":"3","content":"hello again","author":{"username":"alice"}}\n')
|
||||
|
||||
|
||||
def _make_glob(path: str, resolved: bool = True) -> list[PathSpec]:
|
||||
return [
|
||||
PathSpec(resource_path=(path).strip("/"),
|
||||
virtual=path,
|
||||
directory="/",
|
||||
resolved=resolved)
|
||||
]
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _find_command():
|
||||
for fn in COMMANDS:
|
||||
for rc in getattr(fn, "_registered_commands", []):
|
||||
if rc.name == "find" and rc.filetype is None:
|
||||
return fn
|
||||
raise AssertionError("factory find not registered for discord")
|
||||
|
||||
|
||||
def _make_index() -> RAMIndexCacheStore:
|
||||
index = RAMIndexCacheStore(ttl=600)
|
||||
_run(
|
||||
index.put(
|
||||
"/" + GUILD_PATH,
|
||||
IndexEntry(id="G1",
|
||||
name="TestGuild",
|
||||
resource_type="discord/guild",
|
||||
vfs_name="TestGuild")))
|
||||
_run(
|
||||
index.put(
|
||||
"/" + CHANNEL_PATH,
|
||||
IndexEntry(id="C1",
|
||||
name="general",
|
||||
resource_type="discord/channel",
|
||||
vfs_name="general")))
|
||||
_run(
|
||||
index.put(
|
||||
"/" + DATE_DIR_PATH,
|
||||
IndexEntry(id="C1:2024-01-15",
|
||||
name="2024-01-15",
|
||||
resource_type="discord/history",
|
||||
vfs_name="2024-01-15")))
|
||||
_run(
|
||||
index.set_dir("/" + CHANNEL_PATH, [
|
||||
("2024-01-15",
|
||||
IndexEntry(id="C1:2024-01-15",
|
||||
name="2024-01-15",
|
||||
resource_type="discord/history",
|
||||
vfs_name="2024-01-15")),
|
||||
]))
|
||||
_run(
|
||||
index.put(
|
||||
"/" + FILE_PATH,
|
||||
IndexEntry(id="C1:2024-01-15:chat",
|
||||
name="chat.jsonl",
|
||||
resource_type="discord/chat_jsonl",
|
||||
vfs_name="chat.jsonl")))
|
||||
_run(
|
||||
index.set_dir("/" + DATE_DIR_PATH, [
|
||||
("chat.jsonl",
|
||||
IndexEntry(id="C1:2024-01-15:chat",
|
||||
name="chat.jsonl",
|
||||
resource_type="discord/chat_jsonl",
|
||||
vfs_name="chat.jsonl")),
|
||||
]))
|
||||
return index
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
config = DiscordConfig(token="test-token")
|
||||
return DiscordAccessor(config=config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return _make_index()
|
||||
|
||||
|
||||
async def _collect(stream) -> bytes:
|
||||
if isinstance(stream, bytes):
|
||||
return stream
|
||||
chunks = []
|
||||
async for chunk in stream:
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head(accessor):
|
||||
with (
|
||||
patch("mirage.commands.builtin.discord.head.resolve_glob",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_glob_result(ABS_FILE)),
|
||||
patch("mirage.commands.builtin.discord.head.discord_read",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_JSONL),
|
||||
):
|
||||
stream, io = await head(accessor, _make_glob(ABS_FILE), n="1")
|
||||
data = await _collect(stream)
|
||||
assert b"hello world" in data
|
||||
assert b"goodbye moon" not in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_default(accessor):
|
||||
with (
|
||||
patch("mirage.commands.builtin.discord.head.resolve_glob",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_glob_result(ABS_FILE)),
|
||||
patch("mirage.commands.builtin.discord.head.discord_read",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_JSONL),
|
||||
):
|
||||
stream, io = await head(accessor, _make_glob(ABS_FILE))
|
||||
data = await _collect(stream)
|
||||
assert b"hello world" in data
|
||||
assert b"goodbye moon" in data
|
||||
assert b"hello again" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep(accessor):
|
||||
with (
|
||||
patch("mirage.commands.builtin.discord.grep.resolve_glob",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_glob_result(ABS_FILE)),
|
||||
patch("mirage.commands.builtin.discord.grep.discord_read",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_JSONL),
|
||||
):
|
||||
stream, io = await grep(accessor, _make_glob(ABS_FILE), "hello")
|
||||
data = await _collect(stream)
|
||||
assert b"hello world" in data
|
||||
assert b"hello again" in data
|
||||
assert b"goodbye" not in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_invert(accessor):
|
||||
with (
|
||||
patch("mirage.commands.builtin.discord.grep.resolve_glob",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_glob_result(ABS_FILE)),
|
||||
patch("mirage.commands.builtin.discord.grep.discord_read",
|
||||
new_callable=AsyncMock,
|
||||
return_value=FAKE_JSONL),
|
||||
):
|
||||
stream, io = await grep(accessor,
|
||||
_make_glob(ABS_FILE),
|
||||
"hello",
|
||||
v=True)
|
||||
data = await _collect(stream)
|
||||
assert b"goodbye moon" in data
|
||||
assert b"hello world" not in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find(accessor, index):
|
||||
find = _find_command()
|
||||
stream, io = await find(accessor,
|
||||
_make_glob(ABS_CHANNEL, resolved=False),
|
||||
index=index)
|
||||
data = await _collect(stream)
|
||||
assert b"2024-01-15" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_with_name(accessor, index):
|
||||
find = _find_command()
|
||||
stream, io = await find(
|
||||
accessor,
|
||||
_make_glob(ABS_CHANNEL, resolved=False),
|
||||
name="chat.jsonl",
|
||||
index=index,
|
||||
)
|
||||
data = await _collect(stream)
|
||||
assert b"chat.jsonl" in data
|
||||
@@ -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. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.commands.builtin.discord.grep import grep
|
||||
from mirage.commands.builtin.discord.rg import rg
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
def _path(path: str) -> PathSpec:
|
||||
return PathSpec(resource_path=mount_key(path, "/discord"),
|
||||
virtual=path,
|
||||
directory=path)
|
||||
|
||||
|
||||
def _fake_index():
|
||||
idx = AsyncMock()
|
||||
|
||||
async def _get(virtual_key):
|
||||
result = AsyncMock()
|
||||
if virtual_key.endswith("/myguild/channels/general"):
|
||||
result.entry = type("E", (), {"id": "C1", "remote_time": ""})
|
||||
elif virtual_key.endswith("/myguild"):
|
||||
result.entry = type("E", (), {"id": "G1"})
|
||||
else:
|
||||
result.entry = None
|
||||
return result
|
||||
|
||||
idx.get.side_effect = _get
|
||||
return idx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_emits_token_hint_on_forbidden():
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
paths = [_path("/discord/myguild/channels/general/2026-01-01/chat.jsonl")]
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.grep.search_guild",
|
||||
new=AsyncMock(side_effect=RuntimeError("403 Forbidden")),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.grep.resolve_glob",
|
||||
new=AsyncMock(return_value=paths),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.grep.discord_read",
|
||||
new=AsyncMock(return_value=b""),
|
||||
):
|
||||
_out, io = await grep(accessor,
|
||||
paths,
|
||||
"hi",
|
||||
index=_fake_index(),
|
||||
args_l=True)
|
||||
stderr = (io.stderr or b"").decode()
|
||||
assert "push-down failed" in stderr
|
||||
assert "READ_MESSAGE_HISTORY" in stderr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rg_emits_warning_on_rate_limit():
|
||||
accessor = AsyncMock()
|
||||
accessor.config = AsyncMock()
|
||||
paths = [_path("/discord/myguild/channels/general/2026-01-01/chat.jsonl")]
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.rg.search_guild",
|
||||
new=AsyncMock(side_effect=RuntimeError("rate limited 429")),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.rg.resolve_glob",
|
||||
new=AsyncMock(return_value=paths),
|
||||
), patch(
|
||||
"mirage.commands.builtin.discord.rg.generic_rg",
|
||||
new=AsyncMock(return_value=(b"", IOResult(exit_code=1))),
|
||||
):
|
||||
_out, io = await rg(accessor, paths, "hi", index=_fake_index())
|
||||
stderr = (io.stderr or b"").decode()
|
||||
assert "push-down failed" in stderr
|
||||
# 429 doesn't trigger the perm hint; should still warn
|
||||
assert "READ_MESSAGE_HISTORY" not in stderr
|
||||
@@ -0,0 +1,69 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.discord import DiscordAccessor
|
||||
from mirage.commands.builtin.discord.discord_add_reaction import \
|
||||
discord_add_reaction
|
||||
from mirage.commands.builtin.discord.discord_send_message import \
|
||||
discord_send_message
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return DiscordAccessor(config=DiscordConfig(token="test-bot-token"), )
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message(accessor):
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.discord_send_message"
|
||||
".send_message",
|
||||
new_callable=AsyncMock,
|
||||
return_value={
|
||||
"id": "msg1",
|
||||
"content": "hello"
|
||||
},
|
||||
) as mock_send:
|
||||
stream, io_result = await discord_send_message(accessor, [],
|
||||
channel_id="C001",
|
||||
text="hello")
|
||||
|
||||
mock_send.assert_called_once_with(accessor.config, "C001", "hello", None)
|
||||
out = json.loads(stream)
|
||||
assert out["id"] == "msg1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_reaction(accessor):
|
||||
with patch(
|
||||
"mirage.commands.builtin.discord.discord_add_reaction"
|
||||
".add_reaction",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
) as mock_react:
|
||||
stream, io_result = await discord_add_reaction(accessor, [],
|
||||
channel_id="C001",
|
||||
message_id="msg1",
|
||||
reaction="thumbsup")
|
||||
|
||||
mock_react.assert_called_once_with(accessor.config, "C001", "msg1",
|
||||
"thumbsup")
|
||||
out = json.loads(stream)
|
||||
assert out["ok"] is True
|
||||
@@ -0,0 +1,84 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_basic(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello\nworld\n")
|
||||
io = await workspace.execute("cat /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"hello\nworld\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_n_single_digit_alignment(workspace):
|
||||
await workspace.ops.write("/f.txt", b"a\nb\n")
|
||||
io = await workspace.execute("cat -n /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b" 1\ta\n 2\tb\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_n_multidigit_alignment(workspace):
|
||||
body = b"".join(f"line{i}\n".encode() for i in range(1, 13))
|
||||
await workspace.ops.write("/big.txt", body)
|
||||
io = await workspace.execute("cat -n /big.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
lines = io.stdout.split(b"\n")
|
||||
assert lines[0] == b" 1\tline1"
|
||||
assert lines[8] == b" 9\tline9"
|
||||
assert lines[9] == b" 10\tline10"
|
||||
assert lines[11] == b" 12\tline12"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_preserves_no_trailing_newline(workspace):
|
||||
await workspace.ops.write("/partial.txt", b"hello")
|
||||
io = await workspace.execute("cat /partial.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_n_preserves_no_trailing_newline(workspace):
|
||||
await workspace.ops.write("/partial.txt", b"hello")
|
||||
io = await workspace.execute("cat -n /partial.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b" 1\thello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_empty_file(workspace):
|
||||
await workspace.ops.write("/empty.txt", b"")
|
||||
io = await workspace.execute("cat /empty.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cat_only_newlines(workspace):
|
||||
await workspace.ops.write("/nl.txt", b"\n\n\n")
|
||||
io = await workspace.execute("cat -n /nl.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b" 1\t\n 2\t\n 3\t\n"
|
||||
@@ -0,0 +1,91 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_single_file(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello")
|
||||
io = await workspace.execute("du /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().strip() == "5\t/f.txt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_directory_collapses(workspace):
|
||||
await workspace.ops.mkdir("/dir")
|
||||
await workspace.ops.write("/dir/a.txt", b"aaa")
|
||||
await workspace.ops.write("/dir/b.txt", b"bb")
|
||||
io = await workspace.execute("du /dir", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().strip() == "5\t/dir"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_a_lists_files(workspace):
|
||||
await workspace.ops.mkdir("/dir")
|
||||
await workspace.ops.write("/dir/a.txt", b"aaa")
|
||||
await workspace.ops.write("/dir/b.txt", b"bb")
|
||||
io = await workspace.execute("du -a /dir", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "a.txt" in out
|
||||
assert "b.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_s_summary(workspace):
|
||||
await workspace.ops.mkdir("/dir")
|
||||
await workspace.ops.mkdir("/dir/sub")
|
||||
await workspace.ops.write("/dir/a.txt", b"hello")
|
||||
await workspace.ops.write("/dir/sub/b.txt", b"world")
|
||||
io = await workspace.execute("du -s /dir", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
lines = io.stdout.decode().strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_c_total(workspace):
|
||||
await workspace.ops.write("/a.txt", b"hello")
|
||||
await workspace.ops.write("/b.txt", b"world")
|
||||
io = await workspace.execute("du -c /a.txt /b.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
lines = io.stdout.decode().strip().splitlines()
|
||||
assert lines[-1] == "10\ttotal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_h_human(workspace):
|
||||
await workspace.ops.write("/big.txt", b"x" * 2048)
|
||||
io = await workspace.execute("du -h /big.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
size_str = io.stdout.decode().strip().split("\t")[0]
|
||||
assert size_str.endswith("K")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_missing_operand_errors(workspace):
|
||||
io = await workspace.execute("du", session_id="default")
|
||||
assert io.exit_code != 0
|
||||
assert b"missing operand" in (io.stderr or b"")
|
||||
@@ -0,0 +1,83 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_name_glob(workspace):
|
||||
await workspace.ops.write("/hello.txt", b"hi")
|
||||
await workspace.ops.write("/world.py", b"hi")
|
||||
io = await workspace.execute("find / -name '*.txt'", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "hello.txt" in out
|
||||
assert "world.py" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_f(workspace):
|
||||
await workspace.ops.mkdir("/sub")
|
||||
await workspace.ops.write("/a.txt", b"a")
|
||||
await workspace.ops.write("/sub/b.txt", b"b")
|
||||
io = await workspace.execute("find / -type f", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "/a.txt" in out
|
||||
assert "/sub/b.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_type_d(workspace):
|
||||
await workspace.ops.mkdir("/sub")
|
||||
await workspace.ops.write("/a.txt", b"a")
|
||||
io = await workspace.execute("find / -type d", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "/sub" in out
|
||||
assert "/a.txt" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_size_lower_bound(workspace):
|
||||
await workspace.ops.write("/big.txt", b"x" * 1000)
|
||||
await workspace.ops.write("/small.txt", b"x")
|
||||
io = await workspace.execute("find / -size +500c -type f",
|
||||
session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "big.txt" in out
|
||||
assert "small.txt" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_maxdepth(workspace):
|
||||
await workspace.ops.mkdir("/sub")
|
||||
await workspace.ops.mkdir("/sub/deep")
|
||||
await workspace.ops.write("/a.txt", b"a")
|
||||
await workspace.ops.write("/sub/deep/c.txt", b"c")
|
||||
io = await workspace.execute("find / -maxdepth 1 -type f",
|
||||
session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "/a.txt" in out
|
||||
assert "/sub/deep/c.txt" not in out
|
||||
@@ -0,0 +1,75 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_default_n_10(workspace):
|
||||
body = b"".join(f"line{i}\n".encode() for i in range(1, 15))
|
||||
await workspace.ops.write("/f.txt", body)
|
||||
io = await workspace.execute("head /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
lines = io.stdout.decode().splitlines()
|
||||
assert len(lines) == 10
|
||||
assert lines[0] == "line1"
|
||||
assert lines[9] == "line10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_n_explicit(workspace):
|
||||
await workspace.ops.write("/f.txt", b"a\nb\nc\nd\n")
|
||||
io = await workspace.execute("head -n 2 /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"a\nb\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_c_bytes(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello world")
|
||||
io = await workspace.execute("head -c 5 /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_negative_n_excludes_last(workspace):
|
||||
await workspace.ops.write("/f.txt", b"a\nb\nc\nd\n")
|
||||
io = await workspace.execute("head -n -1 /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"a\nb\nc\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_no_trailing_newline(workspace):
|
||||
await workspace.ops.write("/partial.txt", b"hello")
|
||||
io = await workspace.execute("head /partial.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_head_empty_file(workspace):
|
||||
await workspace.ops.write("/empty.txt", b"")
|
||||
io = await workspace.execute("head /empty.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b""
|
||||
@@ -0,0 +1,90 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_lists_files(workspace):
|
||||
await workspace.ops.write("/a.txt", b"a")
|
||||
await workspace.ops.write("/b.txt", b"b")
|
||||
io = await workspace.execute("ls /", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
names = set(io.stdout.decode().strip().split("\n"))
|
||||
assert "a.txt" in names
|
||||
assert "b.txt" in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_a_shows_dotfiles(workspace):
|
||||
await workspace.ops.write("/.hidden", b"h")
|
||||
await workspace.ops.write("/visible.txt", b"v")
|
||||
io = await workspace.execute("ls -a /", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
names = set(io.stdout.decode().strip().split("\n"))
|
||||
assert ".hidden" in names
|
||||
assert "visible.txt" in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_l_long_format_includes_size(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello")
|
||||
io = await workspace.execute("ls -l /", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "f.txt" in out
|
||||
assert "5" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_F_classify_marks_dirs(workspace):
|
||||
await workspace.ops.mkdir("/sub")
|
||||
await workspace.ops.write("/sub/a.txt", b"a")
|
||||
io = await workspace.execute("ls -F /", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert "sub/" in io.stdout.decode()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_R_recursive(workspace):
|
||||
await workspace.ops.mkdir("/sub")
|
||||
await workspace.ops.write("/sub/a.txt", b"a")
|
||||
io = await workspace.execute("ls -R /", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "sub" in out
|
||||
assert "a.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_d_lists_dir_itself(workspace):
|
||||
await workspace.ops.mkdir("/sub")
|
||||
io = await workspace.execute("ls -d /sub", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert "sub" in io.stdout.decode()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ls_missing_path_returns_exit_1(workspace):
|
||||
io = await workspace.execute("ls /nope", session_id="default")
|
||||
assert io.exit_code == 1
|
||||
assert b"nope" in (io.stderr or b"")
|
||||
@@ -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
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_v_terminates_verbose_output(workspace):
|
||||
await workspace.ops.write("/a.txt", b"a")
|
||||
|
||||
io = await workspace.execute("rm -v /a.txt", session_id="default")
|
||||
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"removed '/a.txt'\n"
|
||||
@@ -0,0 +1,83 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_default_n_10(workspace):
|
||||
body = b"\n".join(f"line{i}".encode() for i in range(1, 21)) + b"\n"
|
||||
await workspace.ops.write("/f.txt", body)
|
||||
io = await workspace.execute("tail /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
expected = b"\n".join(f"line{i}".encode() for i in range(11, 21)) + b"\n"
|
||||
assert io.stdout == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_n_explicit(workspace):
|
||||
await workspace.ops.write("/f.txt", b"a\nb\nc\nd\ne\n")
|
||||
io = await workspace.execute("tail -n 3 /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"c\nd\ne\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_c_bytes(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello world")
|
||||
io = await workspace.execute("tail -c 5 /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_plus_n_streams_from_line(workspace):
|
||||
await workspace.ops.write("/f.txt", b"a\nb\nc\nd\ne\n")
|
||||
io = await workspace.execute("tail -n +3 /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"c\nd\ne\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_no_trailing_newline(workspace):
|
||||
await workspace.ops.write("/partial.txt", b"hello")
|
||||
io = await workspace.execute("tail /partial.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b"hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_empty_file(workspace):
|
||||
await workspace.ops.write("/empty.txt", b"")
|
||||
io = await workspace.execute("tail /empty.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout == b""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tail_multi_file_emits_headers(workspace):
|
||||
await workspace.ops.write("/a.txt", b"x\ny\n")
|
||||
await workspace.ops.write("/b.txt", b"z\n")
|
||||
io = await workspace.execute("tail /a.txt /b.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert b"==> /a.txt <==" in io.stdout
|
||||
assert b"==> /b.txt <==" in io.stdout
|
||||
@@ -0,0 +1,61 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_basic(workspace):
|
||||
await workspace.ops.mkdir("/d1")
|
||||
await workspace.ops.write("/d1/a.txt", b"a")
|
||||
await workspace.ops.write("/d1/b.txt", b"b")
|
||||
io = await workspace.execute("tree /d1", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "a.txt" in out
|
||||
assert "b.txt" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_L_max_depth(workspace):
|
||||
await workspace.ops.mkdir("/d1")
|
||||
await workspace.ops.mkdir("/d1/sub")
|
||||
await workspace.ops.mkdir("/d1/sub/deep")
|
||||
await workspace.ops.write("/d1/sub/deep/file.txt", b"d")
|
||||
io = await workspace.execute("tree -L 1 /d1", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "sub" in out
|
||||
assert "deep" in out
|
||||
assert "file.txt" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tree_d_dirs_only(workspace):
|
||||
await workspace.ops.mkdir("/d1")
|
||||
await workspace.ops.mkdir("/d1/sub")
|
||||
await workspace.ops.write("/d1/file.txt", b"x")
|
||||
io = await workspace.execute("tree -d /d1", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
out = io.stdout.decode()
|
||||
assert "sub" in out
|
||||
assert "file.txt" not in out
|
||||
@@ -0,0 +1,96 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage import DiskResource, MountMode, Workspace
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace(tmp_path):
|
||||
return Workspace({"/": DiskResource(root=str(tmp_path))},
|
||||
mode=MountMode.WRITE)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_default(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello world\nfoo bar\n")
|
||||
io = await workspace.execute("wc /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
parts = io.stdout.decode().rstrip("\n").split()
|
||||
assert parts[0] == "2"
|
||||
assert parts[1] == "4"
|
||||
assert parts[2] == "20"
|
||||
assert parts[3] == "/f.txt"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_l(workspace):
|
||||
await workspace.ops.write("/f.txt", b"a\nb\nc\n")
|
||||
io = await workspace.execute("wc -l /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().split()[0] == "3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_w(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello world\nfoo\n")
|
||||
io = await workspace.execute("wc -w /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().split()[0] == "3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_c(workspace):
|
||||
await workspace.ops.write("/f.txt", b"hello\n")
|
||||
io = await workspace.execute("wc -c /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().split()[0] == "6"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_m_multibyte(workspace):
|
||||
await workspace.ops.write("/f.txt", "café".encode())
|
||||
io = await workspace.execute("wc -m /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().split()[0] == "4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_L(workspace):
|
||||
await workspace.ops.write("/f.txt", b"short\na much longer line\nmed\n")
|
||||
io = await workspace.execute("wc -L /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
assert io.stdout.decode().split()[0] == str(len("a much longer line"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_empty_file(workspace):
|
||||
await workspace.ops.write("/f.txt", b"")
|
||||
io = await workspace.execute("wc /f.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
parts = io.stdout.decode().split()
|
||||
assert parts[:3] == ["0", "0", "0"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wc_multi_file_emits_total(workspace):
|
||||
await workspace.ops.write("/a.txt", b"hello\n")
|
||||
await workspace.ops.write("/b.txt", b"world\nfoo\n")
|
||||
io = await workspace.execute("wc /a.txt /b.txt", session_id="default")
|
||||
assert io.exit_code == 0
|
||||
lines = io.stdout.decode().splitlines()
|
||||
assert any(line.endswith("/a.txt") for line in lines)
|
||||
assert any(line.endswith("/b.txt") for line in lines)
|
||||
assert lines[-1].endswith("total")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user