Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,43 @@ jobs:
name: cli-smoke
path: cli-smoke.xml

test-windows-platform:
needs: changes
# The full Windows suites run only on main. Keep these path, checksum and
# CLI lifecycle regressions covered before merge without that suite's cost.
if: needs.changes.outputs.code == 'true' && github.ref_name != 'main'
name: Windows path and checksum regressions (Python 3.12)
runs-on: windows-latest
timeout-minutes: 15
env:
UV_PYTHON: "3.12"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install dependencies
run: |
pip install uv
uv venv
uv pip install -e ".[dev,pdf]"
- name: Run platform regressions
run: >-
uv run pytest
tests/cli/test_wiki_command.py
tests/indexing/test_note_content_promotion_guard.py
tests/mcp/test_tool_bm_delete.py
tests/mcp/test_tool_project_management.py
test-int/mcp/test_project_management_integration.py
-q --no-cov --junitxml=windows-platform.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-platform
path: windows-platform.xml

test-sqlite-unit:
needs: changes
if: needs.changes.outputs.code == 'true'
Expand Down
4 changes: 4 additions & 0 deletions src/basic_memory/mcp/tools/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ async def bm_delete(

source = identifier if identifier is not None else path
assert source is not None
# Directory deletes strip whitespace at the API boundary. Do it before routing too:
# Windows resolves ' / ' outside the project before the root-delete guard can run.
if identifier is None:
source = source.strip()
# A note is resolved by identifier; a directory is a path that must stay inside.
kind = "identifier" if identifier is not None else "path"
async with open_project_target(
Expand Down
3 changes: 2 additions & 1 deletion test-int/mcp/test_project_management_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ async def test_create_project_duplicate_name(mcp_server, app, test_project, tmp_
"command": "create",
},
)
assert f"already exists at {original_path}, not {other_path}." in str(refused.value)
expected_paths = f"already exists at {original_path}, not {other_path}.".replace("\\", "/")
assert expected_paths in str(refused.value).replace("\\", "/")

same = await client.call_tool(
"bm_project",
Expand Down
7 changes: 7 additions & 0 deletions tests/cli/test_wiki_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import pytest
from typer.testing import CliRunner

from basic_memory import db
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.commands.wiki import (
WikiCommandReport,
WikiProjectReport,
Expand Down Expand Up @@ -194,7 +196,12 @@ def test_wiki_rebuild_rejects_recorded_index_failures():
_require_successful_index(result)


# The CLI opens its own sessions; reset shared Postgres state before it reads projects.
@pytest.mark.usefixtures("engine_factory")
def test_wiki_rebuild_builds_local_navigation(config_home, config_manager):
# Each CLI invocation closes its engine. Start with the persistent database,
# rather than the SQLite fixture's in-memory engine that the first call would lose.
run_with_cleanup(db.shutdown_db())
note = config_home / "guides" / "start.md"
note.parent.mkdir()
note.write_text(
Expand Down
3 changes: 2 additions & 1 deletion tests/indexing/test_note_content_promotion_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,8 @@ async def test_local_filesystem_confirms_restored_older_version(
)
note_path = Path(file_service.base_path) / sample_entity.file_path
note_path.parent.mkdir(parents=True, exist_ok=True)
note_path.write_text(FIRST_ACCEPTED, encoding="utf-8")
# Confirmation hashes file bytes, so Windows must not translate LF to CRLF here.
note_path.write_bytes(FIRST_ACCEPTED.encode("utf-8"))

reader = local_promotion_confirmation_reader(file_service)
assert reader is file_service
Expand Down
2 changes: 1 addition & 1 deletion tests/mcp/test_tool_bm_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ async def test_invalid_arguments(client, test_project, arguments, message):


@pytest.mark.asyncio
@pytest.mark.parametrize("path", [" ", " / ", "\t"])
@pytest.mark.parametrize("path", [" ", " / ", "\t", "\t/\t"])
async def test_a_blank_path_never_deletes_the_project(client, test_project, path):
"""The API reads a blank directory as the project root; bm_delete refuses it."""
await bm_write(identifier="notes/keep.md", content="# Keep", project=test_project.name)
Expand Down
12 changes: 7 additions & 5 deletions tests/mcp/test_tool_project_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,12 @@ async def test_create_memory_project_refuses_same_name_different_path(app, tmp_p
project_path=str(requested_root),
)

assert str(refused.value) == (
# Project paths are serialized with '/', while requested paths may use '\\' on Windows.
assert str(refused.value).replace("\\", "/") == (
f"ALREADY_EXISTS: Project 'Path Mismatch Project' already exists at "
f"{existing_root}, not {requested_root}.\n"
f"{existing_root.as_posix()}, not {requested_root.as_posix()}.\n"
f"Try instead: another name, or create_memory_project(project_name="
f"'Path Mismatch Project', project_path='{existing_root}')"
f"'Path Mismatch Project', project_path='{existing_root.as_posix()}')"
)
mock_index.assert_not_awaited()

Expand All @@ -236,8 +237,9 @@ async def test_create_memory_project_refuses_different_path_as_json(app, tmp_pat

payload = json.loads(str(refused.value))
assert payload["error"] == "ALREADY_EXISTS"
assert payload["message"] == (
f"Project 'Path Mismatch JSON' already exists at {existing_root}, not {requested_root}."
assert payload["message"].replace("\\", "/") == (
f"Project 'Path Mismatch JSON' already exists at "
f"{existing_root.as_posix()}, not {requested_root.as_posix()}."
)


Expand Down
Loading