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:
Drew Cain
2025-08-25 06:58:24 -07:00
committed by GitHub
parent 9aa40246a8
commit 7a8b08d11e
14 changed files with 147 additions and 29 deletions
+12 -3
View File
@@ -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
+2 -2
View File
@@ -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].
+3 -4
View File
@@ -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],
+2 -4
View File
@@ -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
+6 -1
View File
@@ -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()
+4
View File
@@ -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
+22
View File
@@ -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."""
+5 -3
View File
@@ -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
+2 -2
View File
@@ -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")
+4 -4
View File
@@ -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."""
+2 -5
View File
@@ -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")