mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix: Windows test failures and add Windows CI support (#273)
Signed-off-by: Drew Cain <groksrc@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,11 +14,12 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: [ "3.12" ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -35,10 +36,18 @@ jobs:
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Install just
|
||||
- name: Install just (Linux/macOS)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
|
||||
|
||||
- name: Install just (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
# Install just using Chocolatey (pre-installed on GitHub Actions Windows runners)
|
||||
choco install just --yes
|
||||
shell: pwsh
|
||||
|
||||
- name: Create virtual env
|
||||
run: |
|
||||
uv venv
|
||||
@@ -54,4 +63,4 @@ jobs:
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv pip install pytest pytest-cov
|
||||
just test
|
||||
just test
|
||||
@@ -150,7 +150,7 @@ def create_overall_summary_exhibit(data, output_dir):
|
||||
for category, count in sorted(summary['categories'].items()):
|
||||
summary_content += f"- **{category.replace('_', ' ').title()}:** {count} files\n"
|
||||
|
||||
summary_content += f"""
|
||||
summary_content += """
|
||||
|
||||
## Contributor Summary
|
||||
"""
|
||||
@@ -158,7 +158,7 @@ def create_overall_summary_exhibit(data, output_dir):
|
||||
for contrib in summary['top_contributors']:
|
||||
summary_content += f"- **{contrib['name']}** ({contrib['email']}): {contrib['file_count']} files, {contrib['commit_count']} commits\n"
|
||||
|
||||
summary_content += f"""
|
||||
summary_content += """
|
||||
|
||||
## Legal Significance
|
||||
This inventory represents the complete codebase of Basic Memory as licensed from Basic Machines LLC to Basic Memory LLC under the copyright license agreement dated [DATE].
|
||||
|
||||
@@ -16,13 +16,12 @@ Usage:
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import csv
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Set, Optional, Tuple
|
||||
from typing import Dict, List
|
||||
import argparse
|
||||
import hashlib
|
||||
|
||||
@@ -419,7 +418,7 @@ def main():
|
||||
|
||||
# Print summary
|
||||
stats = generator.get_summary_statistics()
|
||||
print(f"\n=== Legal File Inventory Complete ===")
|
||||
print("\n=== Legal File Inventory Complete ===")
|
||||
print(f"Repository: {stats.get('repository_path', 'Unknown')}")
|
||||
print(f"Total files inventoried: {stats.get('total_files', 0)}")
|
||||
print(f"Total contributors identified: {stats.get('total_contributors', 0)}")
|
||||
@@ -427,7 +426,7 @@ def main():
|
||||
|
||||
# Show top contributors
|
||||
if 'contributor_file_counts' in stats:
|
||||
print(f"\nTop 5 contributors by files modified:")
|
||||
print("\nTop 5 contributors by files modified:")
|
||||
sorted_contributors = sorted(
|
||||
stats['contributor_file_counts'].items(),
|
||||
key=lambda x: x[1],
|
||||
|
||||
@@ -18,13 +18,11 @@ import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class LegalInventoryGenerator:
|
||||
@@ -477,7 +475,7 @@ def main():
|
||||
if 'markdown' in formats:
|
||||
generator.export_markdown(output_dir / f'basic_memory_inventory_{timestamp}.md')
|
||||
|
||||
print(f"\nLegal inventory generation complete!")
|
||||
print("\nLegal inventory generation complete!")
|
||||
print(f"Output saved to: {output_dir}")
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ Create Date: 2025-08-19 22:06:00.000000
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
||||
@@ -106,8 +106,15 @@ class DirectoryService:
|
||||
List of DirectoryNode objects matching the criteria
|
||||
"""
|
||||
# Normalize directory path
|
||||
# Strip ./ prefix if present (handles relative path notation)
|
||||
if dir_name.startswith("./"):
|
||||
dir_name = dir_name[2:] # Remove "./" prefix
|
||||
|
||||
# Ensure path starts with "/"
|
||||
if not dir_name.startswith("/"):
|
||||
dir_name = f"/{dir_name}"
|
||||
|
||||
# Remove trailing slashes except for root
|
||||
if dir_name != "/" and dir_name.endswith("/"):
|
||||
dir_name = dir_name.rstrip("/")
|
||||
|
||||
|
||||
@@ -465,3 +465,79 @@ async def test_list_directory_complex_glob_patterns(mcp_server, app):
|
||||
assert "Project Beta Plan.md" in list_text
|
||||
assert "Meeting Minutes" not in list_text
|
||||
assert "matching 'Project*'" in list_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_dot_slash_prefix_paths(mcp_server, app):
|
||||
"""Test directory listing with ./ prefix paths (reproduces bug report issue)."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create test files in a subdirectory
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"title": "Artifact One",
|
||||
"folder": "artifacts",
|
||||
"content": "# Artifact One\n\nFirst artifact document.",
|
||||
"tags": "artifact,test",
|
||||
},
|
||||
)
|
||||
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"title": "Artifact Two",
|
||||
"folder": "artifacts",
|
||||
"content": "# Artifact Two\n\nSecond artifact document.",
|
||||
"tags": "artifact,test",
|
||||
},
|
||||
)
|
||||
|
||||
# Test normal path without ./ prefix (should work)
|
||||
normal_result = await client.call_tool(
|
||||
"list_directory",
|
||||
{
|
||||
"dir_name": "artifacts",
|
||||
"depth": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(normal_result.content) == 1
|
||||
normal_text = normal_result.content[0].text
|
||||
assert "Artifact One.md" in normal_text
|
||||
assert "Artifact Two.md" in normal_text
|
||||
assert "2 files" in normal_text
|
||||
|
||||
# Test with ./ prefix (this was the failing case in the bug report)
|
||||
dot_slash_result = await client.call_tool(
|
||||
"list_directory",
|
||||
{
|
||||
"dir_name": "./artifacts",
|
||||
"depth": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(dot_slash_result.content) == 1
|
||||
dot_slash_text = dot_slash_result.content[0].text
|
||||
|
||||
# Should show the same files as normal path
|
||||
assert "Artifact One.md" in dot_slash_text
|
||||
assert "Artifact Two.md" in dot_slash_text
|
||||
assert "2 files" in dot_slash_text
|
||||
|
||||
# Test with trailing slash after ./ prefix
|
||||
dot_slash_trailing_result = await client.call_tool(
|
||||
"list_directory",
|
||||
{
|
||||
"dir_name": "./artifacts/",
|
||||
"depth": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert len(dot_slash_trailing_result.content) == 1
|
||||
dot_slash_trailing_text = dot_slash_trailing_result.content[0].text
|
||||
|
||||
# Should show the same files
|
||||
assert "Artifact One.md" in dot_slash_trailing_text
|
||||
assert "Artifact Two.md" in dot_slash_trailing_text
|
||||
assert "2 files" in dot_slash_trailing_text
|
||||
|
||||
@@ -85,7 +85,12 @@ def test_project_default_command(mock_reload, mock_run, cli_env):
|
||||
# Patching call_put directly since it's imported at the module level
|
||||
|
||||
# Patch the os.environ for checking
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
# On Windows, preserve USERPROFILE to allow home directory detection
|
||||
env_vars = {}
|
||||
if os.name == 'nt' and 'USERPROFILE' in os.environ:
|
||||
env_vars['USERPROFILE'] = os.environ['USERPROFILE']
|
||||
|
||||
with patch.dict(os.environ, env_vars, clear=True):
|
||||
# Patch ConfigManager.set_default_project to prevent validation error
|
||||
with patch("basic_memory.config.ConfigManager.set_default_project"):
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
@@ -51,6 +52,9 @@ def project_root() -> Path:
|
||||
def config_home(tmp_path, monkeypatch) -> Path:
|
||||
# Patch HOME environment variable for the duration of the test
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
# On Windows, also set USERPROFILE
|
||||
if os.name == 'nt':
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
# Set BASIC_MEMORY_HOME to the test directory
|
||||
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
|
||||
return tmp_path
|
||||
|
||||
@@ -168,6 +168,28 @@ async def test_list_directory_path_normalization(directory_service: DirectorySer
|
||||
assert result_names == base_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_dot_slash_prefix_normalization(
|
||||
directory_service: DirectoryService, test_graph
|
||||
):
|
||||
"""Test that ./ prefixed directory paths are normalized correctly."""
|
||||
# This test reproduces the bug report issue where ./dirname fails
|
||||
base_result = await directory_service.list_directory(dir_name="/test")
|
||||
|
||||
# Test paths with ./ prefix that should be equivalent to /test
|
||||
dot_paths_to_test = ["./test", "./test/"]
|
||||
|
||||
for path in dot_paths_to_test:
|
||||
result = await directory_service.list_directory(dir_name=path)
|
||||
assert len(result) == len(base_result), (
|
||||
f"Path '{path}' returned {len(result)} results, expected {len(base_result)}"
|
||||
)
|
||||
# Compare by name since the objects might be different instances
|
||||
result_names = {node.name for node in result}
|
||||
base_names = {node.name for node in base_result}
|
||||
assert result_names == base_names, f"Path '{path}' returned different files than expected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_directory_glob_no_matches(directory_service: DirectoryService, test_graph):
|
||||
"""Test listing directory with glob that matches nothing."""
|
||||
|
||||
@@ -225,7 +225,7 @@ class TestSyncConflictHandling:
|
||||
):
|
||||
"""Test conflict handling when case differences cause issues."""
|
||||
import platform
|
||||
|
||||
|
||||
project_dir = project_config.home
|
||||
|
||||
# Create directory structure that might cause case conflicts
|
||||
@@ -256,7 +256,7 @@ class TestSyncConflictHandling:
|
||||
|
||||
# Verify entities were created
|
||||
entities = await entity_repository.find_all()
|
||||
|
||||
|
||||
# On case-insensitive file systems (macOS, Windows), only one entity will be created
|
||||
# On case-sensitive file systems (Linux), two entities will be created
|
||||
if platform.system() in ["Darwin", "Windows"]:
|
||||
@@ -264,7 +264,9 @@ class TestSyncConflictHandling:
|
||||
assert len(entities) >= 1
|
||||
# Only one of the paths will exist
|
||||
file_paths = [entity.file_path for entity in entities]
|
||||
assert any(path in ["Finance/investment.md", "finance/investment.md"] for path in file_paths)
|
||||
assert any(
|
||||
path in ["Finance/investment.md", "finance/investment.md"] for path in file_paths
|
||||
)
|
||||
else:
|
||||
# Case-sensitive file systems (Linux)
|
||||
assert len(entities) >= 2
|
||||
|
||||
@@ -122,7 +122,7 @@ async def test_rapid_atomic_writes(watch_service, project_config, test_project,
|
||||
await create_test_file(tmp2_path, "Second version")
|
||||
|
||||
# Simulate the first atomic write
|
||||
tmp1_path.rename(final_path)
|
||||
tmp1_path.replace(final_path)
|
||||
|
||||
# Brief pause to ensure file system registers the change
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -132,7 +132,7 @@ async def test_rapid_atomic_writes(watch_service, project_config, test_project,
|
||||
assert content1 == "First version"
|
||||
|
||||
# Simulate the second atomic write
|
||||
tmp2_path.rename(final_path)
|
||||
tmp2_path.replace(final_path)
|
||||
|
||||
# Verify content was updated
|
||||
content2 = final_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test configuration management."""
|
||||
|
||||
from basic_memory.config import BasicMemoryConfig
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
class TestBasicMemoryConfig:
|
||||
"""Test BasicMemoryConfig behavior with BASIC_MEMORY_HOME environment variable."""
|
||||
@@ -15,7 +15,7 @@ class TestBasicMemoryConfig:
|
||||
|
||||
# Should use the default path (home/basic-memory)
|
||||
expected_path = (config_home / "basic-memory").as_posix()
|
||||
assert config.projects["main"] == expected_path
|
||||
assert config.projects["main"] == Path(expected_path).as_posix()
|
||||
|
||||
def test_respects_basic_memory_home_environment_variable(self, config_home, monkeypatch):
|
||||
"""Test that config respects BASIC_MEMORY_HOME environment variable."""
|
||||
@@ -38,7 +38,7 @@ class TestBasicMemoryConfig:
|
||||
|
||||
# model_post_init should have added main project with BASIC_MEMORY_HOME
|
||||
assert "main" in config.projects
|
||||
assert config.projects["main"] == custom_path
|
||||
assert config.projects["main"] == Path(custom_path).as_posix()
|
||||
|
||||
def test_model_post_init_fallback_without_basic_memory_home(self, config_home, monkeypatch):
|
||||
"""Test that model_post_init falls back to default when BASIC_MEMORY_HOME is not set."""
|
||||
@@ -52,7 +52,7 @@ class TestBasicMemoryConfig:
|
||||
# model_post_init should have added main project with default path
|
||||
expected_path = (config_home / "basic-memory").as_posix()
|
||||
assert "main" in config.projects
|
||||
assert config.projects["main"] == expected_path
|
||||
assert config.projects["main"] == Path(expected_path).as_posix()
|
||||
|
||||
def test_basic_memory_home_with_relative_path(self, config_home, monkeypatch):
|
||||
"""Test that BASIC_MEMORY_HOME works with relative paths."""
|
||||
|
||||
@@ -9,16 +9,13 @@ Usage: python test_production_cascade_delete.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
||||
|
||||
|
||||
class ProductionCascadeTest:
|
||||
@@ -234,7 +231,7 @@ class ProductionCascadeTest:
|
||||
async def restore_backup(self):
|
||||
"""Restore database from backup."""
|
||||
if self.backup_path.exists():
|
||||
print(f"🔄 Restoring database from backup...")
|
||||
print("🔄 Restoring database from backup...")
|
||||
import shutil
|
||||
shutil.copy2(self.backup_path, self.db_path)
|
||||
print("✅ Database restored from backup")
|
||||
|
||||
Reference in New Issue
Block a user