"""Characterization tests for Build, Release & Testing domain.
This module captures the CURRENT behavior of build, release, and testing infrastructure
without refactoring. Tests document:
1. Version Bumping Logic & File Updates
- Version loading from package.json
- Multi-file version sync across JSON/TOML/Markdown files
- Regex-based URL version patching in documentation
- Dry-run mode validation
2. Package Building & Validation
- MCPB bundle generation with manifest injection
- Icon file handling and temporary directory staging
- NPX subprocess invocation and error handling
- Asset store package preparation with staged edits
3. Test Setup & Teardown Patterns
- Temporary directory lifecycle management
- File state backup and restore
- Pytest async fixtures
- Status file discovery and cleanup
4. Stress Test Execution & Measurement
- Concurrent client connection management
- Frame-based binary protocol I/O (8-byte big-endian length headers)
- Reconnect backoff with exponential decay
- Handshake validation and timeout handling
- Script edit churn with precondition validation
5. Release Checklist & Git Integration
- Version consistency validation across all files
- Manifest version injection
- Changelog generation preparation
6. Git Tag & Changelog Generation
- Tag name formatting (v-prefixed semantic versions)
- Changelog pattern detection and update
Architecture Notes:
- tools/update_versions.py: Multi-target version sync (6 files)
- tools/generate_mcpb.py: Bundle creation with manifest templating
- tools/prepare_unity_asset_store_release.py: Staged editing with C# code modifications
- tools/stress_mcp.py: Async multi-client stress test with reload churn
- tools/stress_editor_state.py: Focused performance stress test for GC profiling
Test Patterns:
- Heavy use of regex for text file patching
- Temporary directories for isolated operations
- JSON/TOML config file manipulation
- Monkeypatching for file I/O isolation
- Mock socket connections for protocol testing
"""
import asyncio
import json
import os
import random
import re
import struct
import sys
import tempfile
import pytest
from pathlib import Path
from typing import Dict, List, Any
from unittest.mock import Mock, AsyncMock, patch, MagicMock, mock_open
# =============================================================================
# FIXTURES & SETUP PATTERNS
# =============================================================================
@pytest.fixture
def temp_repo():
"""Create a temporary repository structure for testing.
Pattern: Isolated filesystem for file operation testing
Captures: Multi-directory staging pattern used in build tools
"""
with tempfile.TemporaryDirectory(prefix="unity_mcp_test_") as tmpdir:
repo_root = Path(tmpdir)
# Create typical project structure
(repo_root / "MCPForUnity").mkdir(parents=True)
(repo_root / "Server").mkdir(parents=True)
(repo_root / "docs" / "i18n").mkdir(parents=True)
yield {
"root": repo_root,
"mcp_package": repo_root / "MCPForUnity" / "package.json",
"manifest": repo_root / "manifest.json",
"pyproject": repo_root / "Server" / "pyproject.toml",
"server_readme": repo_root / "Server" / "README.md",
"root_readme": repo_root / "README.md",
"zh_readme": repo_root / "docs" / "i18n" / "README-zh.md",
}
@pytest.fixture
def sample_package_json():
"""Sample package.json structure.
Pattern: Version as string in JSON root level
Used by: update_versions.py::load_package_version()
"""
return {
"name": "com.coplay.mcpforunity",
"version": "9.2.0",
"displayName": "MCP for Unity",
"description": "Model Context Protocol for Unity",
"unity": "2022.2",
"keywords": ["mcp", "ai", "unity"],
"author": {"name": "Coplay", "url": "https://coplay.dev"},
}
@pytest.fixture
def sample_manifest_json():
"""Sample manifest.json structure.
Pattern: Version as string in root, icon filename reference
Used by: generate_mcpb.py::create_manifest()
"""
return {
"name": "unity-mcp",
"version": "9.2.0",
"description": "Model Context Protocol Bundle for Unity",
"icon": "coplay-logo.png",
"license": "MIT",
}
@pytest.fixture
def sample_pyproject_toml():
"""Sample pyproject.toml content.
Pattern: TOML version string on single line, must match exactly
Used by: update_versions.py::update_pyproject_toml()
Challenge: Regex must preserve exact formatting
"""
return '''[project]
name = "mcpforunityserver"
version = "9.2.0"
description = "MCP for Unity Server"
readme = "README.md"
requires-python = ">=3.10"
[build-system]
requires = ["setuptools>=64.0.0"]
build-backend = "setuptools.build_meta"
'''
@pytest.fixture
def sample_readme_content():
"""Sample README with git URL references.
Pattern: Git URLs with version tags in fragments
Used by: update_versions.py::update_server_readme()
"""
return '''# MCP for Unity
## Installation
Install from git:
```bash
pip install git+https://github.com/CoplayDev/unity-mcp@v9.2.0#subdirectory=Server
```
Or via package URL:
```
https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v9.2.0
```
'''
# =============================================================================
# VERSION MANAGEMENT TESTS
# =============================================================================
class TestVersionBumpingLogic:
"""Tests for version bumping and file synchronization.
Domain: Version Management
Scripts: tools/update_versions.py
Patterns: JSON/TOML parsing, regex-based patching, dry-run simulation
"""
def test_load_package_version_from_json(self, temp_repo, sample_package_json):
"""Test extracting version from package.json.
Behavior: Loads JSON, reads 'version' field, returns string
Failure modes:
- File not found
- Version field missing
- Invalid JSON
"""
# Write package.json
temp_repo["mcp_package"].write_text(
json.dumps(sample_package_json, indent=2),
encoding="utf-8"
)
# Simulate load_package_version()
package_data = json.loads(
temp_repo["mcp_package"].read_text(encoding="utf-8")
)
version = package_data.get("version")
assert version == "9.2.0"
assert isinstance(version, str)
def test_load_package_version_missing_file(self):
"""Test error handling when package.json not found.
Behavior: Raises FileNotFoundError with clear message
"""
nonexistent = Path("/tmp/nonexistent/package.json")
with pytest.raises(FileNotFoundError):
if not nonexistent.exists():
raise FileNotFoundError(f"Package file not found: {nonexistent}")
def test_update_package_json_version(self, temp_repo, sample_package_json):
"""Test updating version in package.json.
Behavior:
1. Load current JSON
2. Modify 'version' field
3. Write with indent=2, +newline
4. Return True if changed, False if already at target
"""
temp_repo["mcp_package"].write_text(
json.dumps(sample_package_json, indent=2),
encoding="utf-8"
)
new_version = "9.3.0"
package_data = json.loads(
temp_repo["mcp_package"].read_text(encoding="utf-8")
)
old_version = package_data.get("version")
assert old_version == "9.2.0"
# Update
package_data["version"] = new_version
temp_repo["mcp_package"].write_text(
json.dumps(package_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8"
)
# Verify
updated = json.loads(
temp_repo["mcp_package"].read_text(encoding="utf-8")
)
assert updated["version"] == "9.3.0"
def test_update_pyproject_toml_version(self, temp_repo, sample_pyproject_toml):
"""Test updating version in pyproject.toml with regex.
Behavior:
1. Read file as text
2. Match pattern: ^version = "([^"]+)"
3. Replace with: version = "NEW_VERSION"
4. Only first match (count=1)
5. MULTILINE flag required
Challenge: Must not modify version strings in other contexts
"""
temp_repo["pyproject"].write_text(sample_pyproject_toml, encoding="utf-8")
new_version = "9.3.0"
content = temp_repo["pyproject"].read_text(encoding="utf-8")
# Apply regex replacement pattern from update_versions.py
pattern = r'^version = "([^"]+)"'
match = re.search(pattern, content, re.MULTILINE)
assert match is not None
assert match.group(1) == "9.2.0"
# Replace exactly once
new_content, count = re.subn(
pattern,
f'version = "{new_version}"',
content,
count=1,
flags=re.MULTILINE
)
assert count == 1 # Exactly one replacement
assert f'version = "{new_version}"' in new_content
temp_repo["pyproject"].write_text(new_content, encoding="utf-8")
# Verify
updated = temp_repo["pyproject"].read_text(encoding="utf-8")
assert f'version = "{new_version}"' in updated
def test_update_readme_git_url_with_version(self, temp_repo, sample_readme_content):
"""Test updating git URLs with version tags in README.
Behavior:
1. Match pattern: git+https://...@vX.Y.Z#subdirectory=...
2. Replace version tag in URL fragment
3. CRITICAL: Fragment hash # not escaped in regex
Pattern:
FROM: git+https://github.com/CoplayDev/unity-mcp@v9.2.0#subdirectory=Server
TO: git+https://github.com/CoplayDev/unity-mcp@v9.3.0#subdirectory=Server
"""
temp_repo["server_readme"].write_text(sample_readme_content, encoding="utf-8")
new_version = "9.3.0"
content = temp_repo["server_readme"].read_text(encoding="utf-8")
# Pattern from update_versions.py
pattern = r'git\+https://github\.com/CoplayDev/unity-mcp@v[0-9]+\.[0-9]+\.[0-9]+#subdirectory=Server'
replacement = f'git+https://github.com/CoplayDev/unity-mcp@v{new_version}#subdirectory=Server'
assert re.search(pattern, content) is not None
new_content = re.sub(pattern, replacement, content)
assert f'@v{new_version}#subdirectory=Server' in new_content
assert '@v9.2.0#' not in new_content
temp_repo["server_readme"].write_text(new_content, encoding="utf-8")
def test_update_readme_package_url_with_version(self, temp_repo, sample_readme_content):
"""Test updating package URLs with version tags in README.
Behavior:
1. Match pattern: https://github.com/...?path=...#vX.Y.Z
2. Replace version in fragment
Pattern:
FROM: https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v9.2.0
TO: https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v9.3.0
"""
temp_repo["root_readme"].write_text(sample_readme_content, encoding="utf-8")
new_version = "9.3.0"
content = temp_repo["root_readme"].read_text(encoding="utf-8")
# Pattern from update_versions.py
pattern = r'https://github\.com/CoplayDev/unity-mcp\.git\?path=/MCPForUnity#v[0-9]+\.[0-9]+\.[0-9]+'
replacement = f'https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#v{new_version}'
if re.search(pattern, content):
new_content = re.sub(pattern, replacement, content)
assert f'#v{new_version}' in new_content
def test_dry_run_mode_no_file_modifications(self, temp_repo, sample_package_json):
"""Test that dry-run mode doesn't modify files.
Behavior:
1. Read file
2. Compute what would change
3. Report changes WITHOUT writing
4. File remains unchanged
Pattern: Conditional write based on dry_run flag
"""
temp_repo["mcp_package"].write_text(
json.dumps(sample_package_json, indent=2),
encoding="utf-8"
)
original_content = temp_repo["mcp_package"].read_text(encoding="utf-8")
new_version = "9.3.0"
dry_run = True
package_data = json.loads(original_content)
package_data["version"] = new_version
# With dry_run=True, skip the write
if not dry_run:
temp_repo["mcp_package"].write_text(
json.dumps(package_data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8"
)
# File should be unchanged
after_content = temp_repo["mcp_package"].read_text(encoding="utf-8")
assert after_content == original_content
def test_version_consistency_validation(self, temp_repo, sample_package_json,
sample_manifest_json, sample_pyproject_toml):
"""Test comprehensive version consistency check across all files.
Behavior: Load versions from all sources, compare
Files checked:
1. MCPForUnity/package.json (JSON)
2. manifest.json (JSON)
3. Server/pyproject.toml (TOML)
4. Server/README.md (URL patterns)
5. README.md (URL patterns)
6. docs/i18n/README-zh.md (URL patterns)
Expected: All have same version
"""
# Setup all files
temp_repo["mcp_package"].write_text(
json.dumps(sample_package_json, indent=2),
encoding="utf-8"
)
temp_repo["manifest"].write_text(
json.dumps(sample_manifest_json, indent=2),
encoding="utf-8"
)
temp_repo["pyproject"].write_text(
sample_pyproject_toml,
encoding="utf-8"
)
# Extract versions
versions = {}
# From package.json
pkg = json.loads(temp_repo["mcp_package"].read_text(encoding="utf-8"))
versions["package.json"] = pkg["version"]
# From manifest.json
mfst = json.loads(temp_repo["manifest"].read_text(encoding="utf-8"))
versions["manifest.json"] = mfst["version"]
# From pyproject.toml
pyproj = temp_repo["pyproject"].read_text(encoding="utf-8")
match = re.search(r'^version = "([^"]+)"', pyproj, re.MULTILINE)
versions["pyproject.toml"] = match.group(1) if match else None
# All should match
unique_versions = set(versions.values())
assert len(unique_versions) == 1
assert "9.2.0" in unique_versions
# =============================================================================
# PACKAGE BUILDING & VALIDATION TESTS
# =============================================================================
class TestMCPBBundleGeneration:
"""Tests for MCPB bundle generation process.
Domain: Package Building
Script: tools/generate_mcpb.py
Patterns: Manifest templating, icon handling, subprocess invocation
"""
@pytest.fixture
def mock_manifest_template(self, temp_repo):
"""Setup manifest template in test repo."""
template = {
"name": "unity-mcp",
"version": "0.0.0", # Will be injected
"description": "Model Context Protocol Bundle for Unity",
"icon": "coplay-logo.png",
"license": "MIT",
}
temp_repo["manifest"].write_text(
json.dumps(template, indent=2),
encoding="utf-8"
)
return template
@pytest.fixture
def mock_icon_file(self, temp_repo):
"""Create a mock icon file."""
icon_path = temp_repo["root"] / "docs" / "images"
icon_path.mkdir(parents=True, exist_ok=True)
icon_file = icon_path / "coplay-logo.png"
icon_file.write_bytes(b"PNG_FAKE_DATA")
return icon_file
def test_create_manifest_with_version_injection(self, temp_repo, mock_manifest_template):
"""Test manifest creation with version injection.
Behavior:
1. Load manifest template
2. Inject version string
3. Inject icon filename
4. Return modified dict
Pattern: In-memory manipulation before file write
"""
template_content = temp_repo["manifest"].read_text(encoding="utf-8")
manifest = json.loads(template_content)
version = "9.2.0"
icon_filename = "coplay-logo.png"
# Inject (simulating create_manifest)
manifest["version"] = version
manifest["icon"] = icon_filename
assert manifest["version"] == "9.2.0"
assert manifest["icon"] == "coplay-logo.png"
def test_mcpb_build_directory_staging(self, temp_repo, mock_icon_file):
"""Test temporary directory staging for MCPB build.
Behavior:
1. Create temp dir with "mcpb-build" subdirectory
2. Copy icon into build dir
3. Write manifest.json into build dir
4. Copy LICENSE and README if exist
5. Call npx mcpb pack
6. Clean up temp dir
Pattern: Temporary directory scoped to context manager
Captures: File staging and aggregation pattern
"""
with tempfile.TemporaryDirectory() as tmpdir:
build_dir = Path(tmpdir) / "mcpb-build"
build_dir.mkdir()
# Stage files
# 1. Copy icon
icon_dest = build_dir / "coplay-logo.png"
icon_dest.write_bytes(b"PNG_FAKE_DATA")
assert icon_dest.exists()
# 2. Write manifest
manifest = {
"name": "unity-mcp",
"version": "9.2.0",
"icon": "coplay-logo.png",
}
manifest_path = build_dir / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8"
)
assert manifest_path.exists()
# 3. Copy LICENSE if exists
license_src = temp_repo["root"] / "LICENSE"
if license_src.exists():
license_src.write_text("MIT License", encoding="utf-8")
license_dst = build_dir / "LICENSE"
license_dst.write_text(license_src.read_text(), encoding="utf-8")
assert license_dst.exists()
# 4. Copy README if exists
readme_src = temp_repo["root"] / "README.md"
if readme_src.exists():
readme_src.write_text("# Unity MCP", encoding="utf-8")
readme_dst = build_dir / "README.md"
readme_dst.write_text(readme_src.read_text(), encoding="utf-8")
assert readme_dst.exists()
# Verify staging complete
assert (build_dir / "manifest.json").exists()
assert (build_dir / "coplay-logo.png").exists()
@patch("subprocess.run")
def test_mcpb_pack_subprocess_invocation(self, mock_run, temp_repo):
"""Test subprocess invocation of 'npx mcpb pack'.
Behavior:
1. Execute: npx @anthropic-ai/mcpb pack . /path/to/output.mcpb
2. cwd = build_dir
3. capture_output=True, text=True
4. check=True (raises on error)
5. On CalledProcessError: print stderr and re-raise
Pattern: Subprocess with error propagation
"""
mock_run.return_value = Mock(
stdout="Packed successfully",
returncode=0
)
build_dir = temp_repo["root"] / "build"
build_dir.mkdir()
output_path = temp_repo["root"] / "output.mcpb"
# Simulate subprocess call
result = mock_run(
["npx", "@anthropic-ai/mcpb", "pack", ".", str(output_path.absolute())],
cwd=build_dir,
capture_output=True,
text=True,
check=True,
)
assert result.returncode == 0
mock_run.assert_called_once()
@patch("subprocess.run")
def test_mcpb_pack_error_handling(self, mock_run):
"""Test error handling when mcpb pack fails.
Behavior:
1. Catch CalledProcessError
2. Print stderr to sys.stderr
3. Re-raise exception
4. Caller must handle FileNotFoundError for missing npx
Failure scenarios:
- npx not installed -> FileNotFoundError
- mcpb command fails -> CalledProcessError with stderr
- Output file not created -> RuntimeError
"""
import subprocess
# Simulate mcpb failure
error = subprocess.CalledProcessError(
returncode=1,
cmd="npx @anthropic-ai/mcpb pack",
stderr="manifest.json not found in build directory"
)
mock_run.side_effect = error
# Should raise
with pytest.raises(subprocess.CalledProcessError):
mock_run(
["npx", "@anthropic-ai/mcpb", "pack", ".", "output.mcpb"],
capture_output=True,
text=True,
check=True,
)
def test_mcpb_output_file_validation(self, temp_repo):
"""Test validation that output .mcpb file was created.
Behavior:
1. After subprocess completes
2. Check if output_path.exists()
3. If not, raise RuntimeError
4. Print file size in bytes for logging
Pattern: Post-condition validation
"""
output_path = temp_repo["root"] / "unity-mcp-9.2.0.mcpb"
# File doesn't exist
assert not output_path.exists()
# Simulate error check
if not output_path.exists():
with pytest.raises(RuntimeError):
raise RuntimeError(f"MCPB file was not created: {output_path}")
# After "creation"
output_path.write_bytes(b"FAKE_MCPB_DATA" * 1000)
assert output_path.exists()
size = output_path.stat().st_size
assert size == 14000
# =============================================================================
# ASSET STORE PACKAGE PREPARATION TESTS
# =============================================================================
class TestAssetStorePackagePreparation:
"""Tests for Asset Store release packaging.
Domain: Package Building
Script: tools/prepare_unity_asset_store_release.py
Patterns: Staged editing, text file manipulation, directory replacement
"""
@pytest.fixture
def unity_project_structure(self, temp_repo):
"""Create a mock Unity project structure."""
asset_project = temp_repo["root"] / "AssetStoreTest"
assets = asset_project / "Assets"
assets.mkdir(parents=True)
# Create MCPForUnity source
source_mcp = temp_repo["root"] / "MCPForUnity"
source_mcp.mkdir(exist_ok=True)
(source_mcp / "package.json").write_text('{"version":"9.2.0"}')
return {
"asset_project": asset_project,
"assets_dir": assets,
"source_mcp": source_mcp,
}
def test_text_file_replacement_once(self, unity_project_structure):
"""Test exact-once regex replacement in C# files.
Behavior:
1. Read file
2. Apply regex substitution
3. Verify exactly 1 replacement (count=1)
4. Raise error if != 1
5. Write file only if changed
Pattern: Used in prepare_unity_asset_store_release.py::replace_once()
Example:
FROM: private const string DefaultBaseUrl = "http://localhost:8080";
TO: private const string DefaultBaseUrl = "https://aws-endpoint/";
"""
http_util = unity_project_structure["source_mcp"] / "HttpEndpointUtility.cs"
original_content = '''public class HttpEndpointUtility {
private const string DefaultBaseUrl = "http://localhost:8080";
public string GetBaseUrl() {
return DefaultBaseUrl;
}
}'''
http_util.write_text(original_content, encoding="utf-8")
# Simulate replace_once
pattern = r'private const string DefaultBaseUrl = "http://localhost:8080";'
replacement = 'private const string DefaultBaseUrl = "https://mc-0cb5e1039f6b4499b473670f70662d29.ecs.us-east-2.on.aws/";'
content = http_util.read_text(encoding="utf-8")
new_content, n = re.subn(pattern, replacement, content, flags=re.MULTILINE)
assert n == 1, f"Expected 1 replacement, got {n}"
assert "https://" in new_content
assert "localhost" not in new_content
http_util.write_text(new_content, encoding="utf-8")
def test_line_removal_exact_match(self, unity_project_structure):
"""Test removing a specific line by exact match.
Behavior:
1. Read file, split lines with keepends=True
2. Find and remove line matching exactly (stripped)
3. Verify exactly 1 removal
4. Join and write back
Pattern: Used for removing [InitializeOnLoad] attribute
Example:
REMOVE: [InitializeOnLoad]
"""
setup_service = unity_project_structure["source_mcp"] / "SetupWindowService.cs"
original_content = '''using UnityEngine;
[InitializeOnLoad]
public class SetupWindowService {
static SetupWindowService() {
EditorApplication.update += OnUpdate;
}
}'''
setup_service.write_text(original_content, encoding="utf-8")
# Simulate remove_line_exact
line_to_remove = "[InitializeOnLoad]"
content = setup_service.read_text(encoding="utf-8")
lines = content.splitlines(keepends=True)
removed = 0
kept = []
for l in lines:
if l.strip() == line_to_remove:
removed += 1
continue
kept.append(l)
assert removed == 1, f"Expected 1 removal, got {removed}"
new_content = "".join(kept)
assert "[InitializeOnLoad]" not in new_content
setup_service.write_text(new_content, encoding="utf-8")
def test_staged_copy_with_edits(self, unity_project_structure):
"""Test staged copying with multiple edits applied.
Behavior:
1. Create temp directory with "MCPForUnity" subdir
2. Copy source MCPForUnity to staged location
3. Apply Asset Store specific edits in place
4. Replace target Assets/MCPForUnity with staged version
5. Clean up temp dir
Pattern: Isolated edit environment prevents source pollution
Challenge: 4 files must exist and be editable
"""
source = unity_project_structure["source_mcp"]
# Create required files
(source / "Editor" / "Setup").mkdir(parents=True, exist_ok=True)
(source / "Editor" / "MenuItems").mkdir(parents=True, exist_ok=True)
(source / "Editor" / "Helpers").mkdir(parents=True, exist_ok=True)
(source / "Editor" / "Windows" / "Components" / "Connection").mkdir(
parents=True, exist_ok=True
)
setup_service = source / "Editor" / "Setup" / "SetupWindowService.cs"
setup_service.write_text("[InitializeOnLoad]\npublic class Setup {}")
http_util = source / "Editor" / "Helpers" / "HttpEndpointUtility.cs"
http_util.write_text('private const string DefaultBaseUrl = "http://localhost:8080";')
connection_section = (
source / "Editor" / "Windows" / "Components" / "Connection" / "McpConnectionSection.cs"
)
connection_section.write_text('transportDropdown.Init(TransportProtocol.HTTPLocal);')
with tempfile.TemporaryDirectory(prefix="assetstore_") as tmpdir:
staged_mcp = Path(tmpdir) / "MCPForUnity"
# Copy all files
import shutil
shutil.copytree(source, staged_mcp)
assert (staged_mcp / "Editor" / "Setup" / "SetupWindowService.cs").exists()
# Apply edits to staged copy
staged_service = staged_mcp / "Editor" / "Setup" / "SetupWindowService.cs"
content = staged_service.read_text(encoding="utf-8")
new_content, count = re.subn(
r"\[InitializeOnLoad\]", "", content
)
assert count == 1
staged_service.write_text(new_content, encoding="utf-8")
# Replace target (simulated)
target_mcp = unity_project_structure["assets_dir"] / "MCPForUnity"
if target_mcp.exists():
import shutil
shutil.rmtree(target_mcp)
shutil.copytree(staged_mcp, target_mcp)
assert target_mcp.exists()
@patch("shutil.copytree")
def test_backup_existing_mcp_folder(self, mock_copytree, unity_project_structure):
"""Test backing up existing Assets/MCPForUnity before replacement.
Behavior:
1. If Assets/MCPForUnity exists and --backup flag set
2. Create AssetStoreBackups directory
3. Copy Assets/MCPForUnity to: MCPForUnity.backup.TIMESTAMP
4. Return backup path
Pattern: Timestamped backup directory
Format: {src.name}.backup.{YYYYMMDD-HHMMSS}
"""
import datetime as dt
assets_dir = unity_project_structure["assets_dir"]
dest_mcp = assets_dir / "MCPForUnity"
dest_mcp.mkdir()
backup_root = assets_dir / "AssetStoreBackups"
backup_root.mkdir(parents=True, exist_ok=True)
# Simulate backup_dir function
ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = backup_root / f"{dest_mcp.name}.backup.{ts}"
# In real code, would use shutil.copytree
mock_copytree(dest_mcp, backup_path)
mock_copytree.assert_called_once_with(dest_mcp, backup_path)
def test_dry_run_validation_without_changes(self, unity_project_structure):
"""Test dry-run mode validates paths without making changes.
Behavior:
1. Verify all required directories exist
2. Report what WOULD be done
3. Return 0 without touching files
4. All paths must be valid even in dry-run
Pattern: Early validation prevents partial edits
"""
source_mcp = unity_project_structure["source_mcp"]
assets_dir = unity_project_structure["assets_dir"]
dest_mcp = assets_dir / "MCPForUnity"
# Validate paths exist (in real code)
assert source_mcp.is_dir()
assert assets_dir.is_dir()
# dest_mcp may not exist yet
# In dry-run, report what would happen
dry_run_output = [
"[dry-run] Validated paths. No changes applied.",
f"[dry-run] Would replace: {dest_mcp} with {source_mcp}",
]
assert all("Would" in line or "Validated" in line for line in dry_run_output)
# =============================================================================
# STRESS TEST PATTERNS & EXECUTION TESTS
# =============================================================================
class TestStressTestSetupPatterns:
"""Tests for stress test infrastructure.
Domain: Stress Testing
Scripts: tools/stress_mcp.py, tools/stress_editor_state.py
Patterns: Binary protocol I/O, async client loops, reconnect backoff
"""
@pytest.fixture
def mock_status_files(self, temp_repo):
"""Setup mock status files for port discovery.
Pattern: Status files stored in ~/.unity-mcp/unity-mcp-status-*.json
Purpose: Auto-discover bridge port from running Unity instance
"""
status_dir = temp_repo["root"] / ".unity-mcp"
status_dir.mkdir()
status_file = status_dir / "unity-mcp-status-latest.json"
status_data = {
"unity_port": 6400,
"unity_host": "127.0.0.1",
"project_path": "/path/to/project",
"timestamp": 1234567890,
}
status_file.write_text(json.dumps(status_data), encoding="utf-8")
return status_dir
def test_port_discovery_from_status_files(self, mock_status_files):
"""Test discovering bridge port from status files.
Behavior:
1. Check ~/.unity-mcp/ for unity-mcp-status-*.json files
2. Sort by mtime (most recent first)
3. Load JSON, extract "unity_port" field
4. Validate port range (0 < port < 65536)
5. If no valid file found, return default 6400
Pattern: Auto-discovery mechanism for dynamic ports
"""
# Simulate find_status_files
status_dir = mock_status_files
files = sorted(
status_dir.glob("unity-mcp-status-*.json"),
key=lambda p: p.stat().st_mtime,
reverse=True
)
assert len(files) > 0
# Load most recent
status_data = json.loads(files[0].read_text())
port = int(status_data.get("unity_port", 0) or 0)
assert 0 < port < 65536
assert port == 6400
def test_port_discovery_default_fallback(self):
"""Test port discovery falls back to default when no status file.
Behavior: Return 6400 if ~/.unity-mcp doesn't exist or no valid files
"""
status_dir = Path("/tmp/nonexistent_unity_mcp")
# Simulate find_status_files returning empty
if not status_dir.exists():
files = []
default_port = 6400
port = default_port if not files else int(files[0])
assert port == 6400
@pytest.mark.asyncio
async def test_binary_frame_protocol_read_exact(self):
"""Test reading exact number of bytes from async stream.
Pattern: Protocol framing with 8-byte big-endian length header
Frame format:
[8 bytes: length in big-endian] [length bytes: payload]
Behavior:
1. Loop until buf has exactly N bytes
2. If chunk empty, connection closed
3. Raise ConnectionError if closed before complete
"""
# Create mock reader
mock_reader = AsyncMock()
# Simulate 3 reads of 5 bytes each, expecting 15 total
mock_reader.read.side_effect = [
b"chunk",
b"data1",
b"data2",
]
# Simulate read_exact logic
n = 15
buf = b""
for _ in range(3):
chunk = await mock_reader.read(n - len(buf))
if not chunk:
raise ConnectionError("Connection closed while reading")
buf += chunk
assert len(buf) == 15
assert buf == b"chunkdata1data2"
@pytest.mark.asyncio
async def test_binary_frame_protocol_parse_header(self):
"""Test parsing 8-byte big-endian length header.
Behavior:
1. Read exactly 8 bytes
2. Unpack as unsigned 64-bit big-endian: struct.unpack(">Q", header)
3. Validate: 0 < length <= 64MB
4. Raise ValueError if invalid
Pattern: Data length extraction for frame boundaries
"""
# Test valid frame length
length_bytes = struct.pack(">Q", 512)
assert len(length_bytes) == 8
(length,) = struct.unpack(">Q", length_bytes)
assert length == 512
# Test too large
too_large = struct.pack(">Q", 100 * 1024 * 1024)
(length,) = struct.unpack(">Q", too_large)
assert length > 64 * 1024 * 1024
# Validation would reject this
if length <= 0 or length > (64 * 1024 * 1024):
with pytest.raises(ValueError):
raise ValueError(f"Invalid frame length: {length}")
@pytest.mark.asyncio
async def test_binary_frame_protocol_write(self):
"""Test writing binary frame with length header.
Behavior:
1. Compute payload length
2. Pack as 8-byte big-endian header
3. Write header + payload
4. Call drain() with timeout
5. Raise error if drain times out
Pattern: Atomic frame write with buffering flush
"""
mock_writer = AsyncMock()
mock_writer.drain = AsyncMock()
payload = b"hello world test"
# Simulate write_frame
header = struct.pack(">Q", len(payload))
mock_writer.write(header)
mock_writer.write(payload)
await asyncio.wait_for(mock_writer.drain(), timeout=2.0)
assert mock_writer.write.call_count == 2
mock_writer.drain.assert_called_once()
@pytest.mark.asyncio
async def test_connection_handshake_validation(self):
"""Test server handshake validation.
Behavior:
1. Read line from server
2. Validate contains "WELCOME UNITY-MCP"
3. Raise ConnectionError if unexpected
Pattern: Protocol initialization check
Expected: "WELCOME UNITY-MCP 1 FRAMING=1\n"
"""
# Valid handshake
mock_reader = AsyncMock()
mock_reader.readline.return_value = b"WELCOME UNITY-MCP 1 FRAMING=1\n"
line = await mock_reader.readline()
if not line or b"WELCOME UNITY-MCP" not in line:
raise ConnectionError(f"Unexpected handshake: {line!r}")
assert b"WELCOME UNITY-MCP" in line
# Invalid handshake
mock_reader.readline.return_value = b"UNKNOWN SERVER\n"
line = await mock_reader.readline()
with pytest.raises(ConnectionError):
if not line or b"WELCOME UNITY-MCP" not in line:
raise ConnectionError(f"Unexpected handshake: {line!r}")
@pytest.mark.asyncio
async def test_concurrent_client_loop_with_backoff(self):
"""Test single client loop with reconnect backoff.
Behavior:
1. Initialize reconnect_delay = 0.2
2. Loop until stop_time
3. Try to connect, perform work
4. On error: increment disconnect count, sleep(reconnect_delay)
5. Backoff decay: reconnect_delay *= 1.5, cap at 2.0
Pattern: Exponential backoff for reliability
Challenge: Prevent connection burst thundering
"""
stop_time = 0.5 # Short test
stats = {"pings": 0, "disconnects": 0, "errors": 0}
reconnect_delay = 0.2
# Simulate client_loop with errors
start = asyncio.get_event_loop().time()
while asyncio.get_event_loop().time() - start < stop_time:
try:
# Simulate immediate failure
raise ConnectionError("simulated disconnect")
except (ConnectionError, OSError):
stats["disconnects"] += 1
await asyncio.sleep(0.01) # Shorter for testing
reconnect_delay = min(reconnect_delay * 1.5, 2.0)
continue
assert stats["disconnects"] > 0
assert reconnect_delay <= 2.0
@pytest.mark.asyncio
async def test_stress_ping_frame_construction(self):
"""Test constructing ping frame for keep-alive.
Behavior:
1. Payload = b"ping"
2. Sent periodically to keep connection alive
3. Expected response = echo/acknowledgment
Pattern: Simple protocol for connection maintenance
"""
def make_ping_frame() -> bytes:
return b"ping"
frame = make_ping_frame()
assert frame == b"ping"
assert len(frame) == 4
@pytest.mark.asyncio
async def test_stress_manage_script_read_request(self):
"""Test constructing manage_script read request.
Behavior:
1. JSON payload with type, action, name, path
2. Used to read current file contents before editing
3. Response includes: success, data.contents, data.sha256
Pattern: Request/response protocol for file operations
"""
name = "LongUnityScriptClaudeTest"
path = "Assets/Scripts"
read_payload = {
"type": "manage_script",
"params": {
"action": "read",
"name": name,
"path": path,
}
}
frame = json.dumps(read_payload).encode("utf-8")
# Simulate response
read_response = {
"result": {
"success": True,
"data": {
"contents": "public class Test {}",
"sha256": "abc123...",
}
}
}
assert json.loads(frame)["type"] == "manage_script"
assert json.loads(frame)["params"]["action"] == "read"
@pytest.mark.asyncio
async def test_stress_apply_text_edits_with_precondition(self):
"""Test apply_text_edits request with SHA precondition.
Behavior:
1. Construct JSON with file path, edits, precondition_sha256
2. Edits include: startLine, startCol, endLine, endCol, newText
3. Options: refresh="immediate", validate="standard"
4. Precondition prevents apply if file changed since read
Pattern: Optimistic concurrency control via SHA comparison
Challenge: Lines and columns are 1-based (Unity convention)
"""
edits = [
{
"startLine": 5,
"startCol": 1,
"endLine": 5,
"endCol": 1,
"newText": "\n// Marker comment\n",
}
]
apply_payload = {
"type": "manage_script",
"params": {
"action": "apply_text_edits",
"name": "TestScript",
"path": "Assets/Scripts",
"edits": edits,
"precondition_sha256": "abc123def456...",
"options": {
"refresh": "immediate",
"validate": "standard",
}
}
}
# Validate structure
assert apply_payload["params"]["action"] == "apply_text_edits"
assert len(apply_payload["params"]["edits"]) == 1
assert "precondition_sha256" in apply_payload["params"]
@pytest.mark.asyncio
async def test_stress_reload_churn_marker_generation(self):
"""Test generating unique markers for reload churn.
Behavior:
1. Create marker: // MCP_STRESS seq={seq} time={timestamp}
2. Append to file (triggers recompilation)
3. Increment seq counter for uniqueness
4. Ensure comment appears on new line
Pattern: Deterministic but unique churn for reproducibility
Challenge: Must not corrupt existing code
"""
seq = 0
contents = "public class Test {}\n"
# Generate marker
marker = f"// MCP_STRESS seq={seq} time={int(1234567890)}"
seq += 1
# Insert text (append at EOF with newline if needed)
insert_text = ("\n" if not contents.endswith("\n") else "") + marker + "\n"
new_contents = contents + insert_text
assert "MCP_STRESS seq=0" in new_contents
assert seq == 1
assert new_contents.endswith("\n")
@pytest.mark.asyncio
async def test_stress_storm_mode_multiple_file_targets(self):
"""Test storm mode touching multiple C# files per cycle.
Behavior:
1. Collect all .cs files in Assets/ recursively
2. If storm_count > 1, randomly sample storm_count files
3. Apply edits to each file in parallel
4. Increases load on editor state cache
Pattern: Variable load parameter for scaling tests
"""
candidates = [
Path("Assets/Scripts/TestA.cs"),
Path("Assets/Scripts/TestB.cs"),
Path("Assets/Scripts/Nested/TestC.cs"),
]
storm_count = 2
if storm_count and storm_count > 1 and candidates:
k = min(max(1, storm_count), len(candidates))
targets = random.sample(candidates, k)
assert len(targets) == min(2, len(candidates))
@pytest.mark.asyncio
async def test_stress_stat_tracking_metrics(self):
"""Test stress test statistics accumulation.
Behavior:
1. Track counters: pings, disconnects, errors, applies, apply_errors
2. Increment on each event
3. Report final JSON with port and stats
Pattern: Minimal instrumentation for performance
"""
stats = {
"pings": 0,
"menus": 0,
"mods": 0,
"disconnects": 0,
"errors": 0,
"applies": 0,
"apply_errors": 0,
}
# Simulate events
stats["pings"] += 15
stats["disconnects"] += 2
stats["applies"] += 1
stats["apply_errors"] += 0
# Report
result = {
"port": 6400,
"stats": stats,
}
json_str = json.dumps(result, indent=2)
assert "pings" in json_str
assert stats["pings"] == 15
# =============================================================================
# RELEASE CHECKLIST & GIT INTEGRATION TESTS
# =============================================================================
class TestReleaseChecklistValidation:
"""Tests for release validation and checklist items.
Domain: Release Management
Patterns: Version consistency, manifest validation, changelog preparation
"""
def test_version_consistency_checklist(self, temp_repo, sample_package_json,
sample_manifest_json, sample_pyproject_toml):
"""Test comprehensive version consistency validation checklist.
Checklist items:
1. package.json version matches manifest.json
2. manifest.json version matches pyproject.toml
3. README.md git URL contains matching version tag
4. Server/README.md git URL contains matching version tag
5. docs/i18n/README-zh.md git URL contains matching version tag
All must match before release can proceed
"""
# Setup all files
temp_repo["mcp_package"].write_text(
json.dumps(sample_package_json, indent=2), encoding="utf-8"
)
temp_repo["manifest"].write_text(
json.dumps(sample_manifest_json, indent=2), encoding="utf-8"
)
temp_repo["pyproject"].write_text(sample_pyproject_toml, encoding="utf-8")
# Verify checklist
checks = {}
# Check 1: package.json
pkg = json.loads(temp_repo["mcp_package"].read_text(encoding="utf-8"))
checks["package.json"] = pkg["version"]
# Check 2: manifest.json
mfst = json.loads(temp_repo["manifest"].read_text(encoding="utf-8"))
checks["manifest.json"] = mfst["version"]
# Check 3: pyproject.toml
pyproj = temp_repo["pyproject"].read_text(encoding="utf-8")
match = re.search(r'^version = "([^"]+)"', pyproj, re.MULTILINE)
checks["pyproject.toml"] = match.group(1) if match else None
# All should be equal
versions = list(checks.values())
assert len(set(versions)) == 1, f"Version mismatch: {checks}"
def test_manifest_icon_file_exists(self, temp_repo, sample_manifest_json):
"""Test that manifest references existing icon file.
Behavior:
1. Load manifest.json
2. Get icon filename
3. Verify icon exists in docs/images/
4. Icon must be valid file (not directory)
Pre-release validation item
"""
icon_dir = temp_repo["root"] / "docs" / "images"
icon_dir.mkdir(parents=True, exist_ok=True)
icon_file = icon_dir / "coplay-logo.png"
icon_file.write_bytes(b"PNG_DATA")
# Load manifest
temp_repo["manifest"].write_text(
json.dumps(sample_manifest_json, indent=2), encoding="utf-8"
)
manifest = json.loads(temp_repo["manifest"].read_text(encoding="utf-8"))
# Verify icon exists
icon_name = manifest.get("icon", "")
icon_path = icon_dir / icon_name
assert icon_path.exists()
assert icon_path.is_file()
def test_license_file_exists_for_mcpb(self, temp_repo):
"""Test that LICENSE file exists for MCPB bundle inclusion.
Behavior:
1. Check repo root for LICENSE file
2. If exists, include in MCPB bundle
3. Not strictly required but expected for open source
Pre-release check item
"""
license_file = temp_repo["root"] / "LICENSE"
# License should exist
license_file.write_text("MIT License\n\nCopyright (c) 2024 Coplay", encoding="utf-8")
assert license_file.exists()
def test_readme_file_exists_for_mcpb(self, temp_repo):
"""Test that README.md exists for MCPB bundle inclusion.
Behavior:
1. Check repo root for README.md
2. If exists, include in MCPB bundle
3. Provides documentation to bundle users
Pre-release check item
"""
readme_file = temp_repo["root"] / "README.md"
# README should exist
readme_file.write_text("# Unity MCP\n\nA Unity package...", encoding="utf-8")
assert readme_file.exists()
# =============================================================================
# GIT TAG & CHANGELOG GENERATION TESTS
# =============================================================================
class TestGitTagAndChangelogGeneration:
"""Tests for git tag creation and changelog patterns.
Domain: Release Management
Pattern: Tag naming, changelog structure preparation
"""
def test_git_tag_naming_convention(self):
"""Test git tag naming follows v-prefixed semantic version.
Format: v{MAJOR}.{MINOR}.{PATCH}
Examples: v9.0.0, v9.2.0, v10.0.0
Behavior:
1. Extract version from package.json: "9.2.0"
2. Prepend "v" for git tag: "v9.2.0"
3. Tag must be deterministic from version
"""
version = "9.2.0"
tag_name = f"v{version}"
assert tag_name == "v9.2.0"
assert tag_name.startswith("v")
assert re.match(r"^v\d+\.\d+\.\d+$", tag_name)
def test_changelog_entry_structure(self):
"""Test changelog entry follows consistent structure.
Format (example):
```
## [9.2.0] - 2024-01-15
### Added
- Feature X
- Feature Y
### Fixed
- Bug fix for issue #123
### Changed
- Breaking change A
```
Pattern: Semantic versioning changelog format (keepachangelog.com)
"""
changelog_entry = """## [9.2.0] - 2024-01-15
### Added
- Support for async script operations
- Improved error handling
### Fixed
- Connection timeout handling
- Memory leak in EditorStateCache
### Changed
- Increased default timeout from 5s to 10s
"""
# Validate structure
assert "## [9.2.0]" in changelog_entry
assert "### Added" in changelog_entry
assert "### Fixed" in changelog_entry
assert "2024-01-15" in changelog_entry
def test_changelog_version_detection_pattern(self):
r"""Test detecting version from existing changelog.
Pattern: Find latest version entry with regex
Regex: ## \[(\d+\.\d+\.\d+)\]
Behavior:
1. Read CHANGELOG.md
2. Extract latest version from first ## [ entry
3. Compare with current version
4. If same, skip changelog update
5. If different, prompt for new entry
"""
changelog_content = """# Changelog
All notable changes to this project are documented in this file.
## [9.2.0] - 2024-01-15
### Added
- Feature X
## [9.1.0] - 2024-01-01
### Added
- Feature Y
"""
# Extract latest version
match = re.search(r"## \[(\d+\.\d+\.\d+)\]", changelog_content)
assert match is not None
latest_version = match.group(1)
assert latest_version == "9.2.0"
def test_changelog_requires_manual_update(self):
"""Test that changelog requires manual entries per release.
Behavior:
1. Script cannot auto-generate meaningful changelog
2. Manual steps required to document changes
3. Checklist item: "Update CHANGELOG.md with release notes"
Limitation: Requires human judgment for change categorization
"""
# This is a validation pattern, not auto-generation
checklist_item = "Update CHANGELOG.md with release notes"
# Human must manually create entries under:
# - Added (new features)
# - Changed (behavior changes)
# - Fixed (bug fixes)
# - Deprecated (to be removed)
# - Removed (previously deprecated)
required_sections = [
"[X.Y.Z]",
"### Added",
"### Fixed",
]
# Changelog entry template
template = """## [X.Y.Z] - YYYY-MM-DD
### Added
- Feature description
### Fixed
- Bug fix description
"""
# Validate required section markers are present
for section in required_sections:
assert section in template, f"Missing {section} in template"
# =============================================================================
# INTEGRATION & WORKFLOW TESTS
# =============================================================================
class TestBuildReleaseWorkflow:
"""Integration tests for complete build/release workflow.
Pattern: Multi-step process validation
Captures: Typical release steps in order
"""
def test_version_bump_workflow(self, temp_repo, sample_package_json,
sample_manifest_json, sample_pyproject_toml):
"""Test complete version bumping workflow.
Steps:
1. Load current version from package.json
2. Prompt for new version (human input, simulated)
3. Update all 6 files with new version
4. Validate all files updated
5. Dry-run first to catch errors
6. Commit changes with message
7. Tag with new version
Pattern: Fail-safe with dry-run preview
"""
# Setup
temp_repo["mcp_package"].write_text(
json.dumps(sample_package_json, indent=2), encoding="utf-8"
)
temp_repo["manifest"].write_text(
json.dumps(sample_manifest_json, indent=2), encoding="utf-8"
)
temp_repo["pyproject"].write_text(sample_pyproject_toml, encoding="utf-8")
old_version = "9.2.0"
new_version = "9.3.0"
# Step 1: Dry-run
dry_run_passed = True
# Step 2: Real update
files_updated = []
pkg = json.loads(temp_repo["mcp_package"].read_text(encoding="utf-8"))
pkg["version"] = new_version
temp_repo["mcp_package"].write_text(
json.dumps(pkg, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8"
)
files_updated.append("MCPForUnity/package.json")
# Step 3: Validate
assert len(files_updated) > 0
assert files_updated[0] == "MCPForUnity/package.json"
# Step 4: Would create git commit with message:
# "Bump version to 9.3.0"
# Step 5: Would create git tag:
# "v9.3.0"
def test_release_checklist_validation_order(self):
"""Test that release checklist items are validated in correct order.
Order:
1. Verify all version files match
2. Verify CHANGELOG.md updated
3. Verify icon file exists
4. Verify LICENSE and README exist
5. Run test suite (not in scope)
6. Build MCPB bundle
7. Verify bundle file created
8. Create git tag
9. Push to remote
Early checks prevent wasted time on later steps
"""
checklist = [
("Version consistency", "Check all files at target version"),
("Changelog updated", "Manual review of CHANGELOG.md"),
("Icon exists", "docs/images/coplay-logo.png present"),
("Metadata files", "LICENSE and README present"),
("Tests passing", "Run test suite"),
("MCPB buildable", "generate_mcpb.py succeeds"),
("Git tag ready", "v{VERSION} tag prepared"),
("Remote push", "All artifacts pushed"),
]
assert len(checklist) == 8
assert checklist[0][0] == "Version consistency"
assert checklist[-1][0] == "Remote push"
# =============================================================================
# ERROR HANDLING & EDGE CASES
# =============================================================================
class TestErrorHandlingPatterns:
"""Tests for error scenarios and edge cases.
Captures: How tools handle failures
Documents: Recovery strategies and validation
"""
def test_missing_source_file_error(self, temp_repo):
"""Test error handling when required source file missing.
Example: setup_service file not found in staged copy
Behavior:
1. Check file.exists()
2. If not, raise RuntimeError with path
3. Abort before attempting edits
Pattern: Fail fast with clear error message
"""
missing_file = temp_repo["root"] / "NonExistent.cs"
if not missing_file.exists():
with pytest.raises(RuntimeError):
raise RuntimeError(f"Expected file not found: {missing_file}")
def test_regex_replacement_count_mismatch(self, temp_repo):
"""Test error when regex replacement count != 1.
Behavior:
1. Apply regex substitution with count=1
2. Check return value (number of replacements)
3. If n != 1, raise RuntimeError
4. Prevents accidental double-replacement or miss
Pattern: Strict single-match requirement
Motivation: Protect against pattern ambiguity
"""
content = "version = 1.0\nversion = 1.0\n"
pattern = r'^version = 1\.0'
new_content, count = re.subn(
pattern, "version = 2.0", content, count=1, flags=re.MULTILINE
)
# Would replace only first, but need exactly 1 total
if count != 1:
with pytest.raises(RuntimeError):
raise RuntimeError(
f"Expected 1 replacement, got {count}"
)
def test_line_removal_count_mismatch(self, temp_repo):
"""Test error when exact line removal doesn't match exactly once.
Behavior:
1. Split file by lines (keepends=True)
2. Find exact matches to line.strip() == target
3. If found != 1, raise RuntimeError
4. Prevents accidental over-removal
Pattern: Strict single-match requirement
"""
content = "[InitializeOnLoad]\nclass A {}\n[InitializeOnLoad]\n"
lines = content.splitlines(keepends=True)
target = "[InitializeOnLoad]"
removed = 0
for l in lines:
if l.strip() == target:
removed += 1
if removed != 1:
with pytest.raises(RuntimeError):
raise RuntimeError(
f"Expected to remove exactly 1 line, removed {removed}"
)
def test_json_parsing_error_handling(self, temp_repo):
"""Test handling of invalid JSON files.
Behavior:
1. Try to parse JSON
2. Catch json.JSONDecodeError
3. Report which file failed
4. Abort operation
Pattern: Early parse validation
"""
bad_json = temp_repo["root"] / "bad.json"
bad_json.write_text("{invalid json}", encoding="utf-8")
with pytest.raises(json.JSONDecodeError):
json.loads(bad_json.read_text(encoding="utf-8"))
def test_file_permission_error_handling(self, temp_repo):
"""Test handling of permission errors during file write.
Behavior:
1. Attempt to write file
2. Catch PermissionError or OSError
3. Report which file failed
4. Suggest running with sudo or checking perms
Pattern: Error message includes remediation hint
"""
# Create read-only file
readonly_file = temp_repo["root"] / "readonly.json"
readonly_file.write_text("{}", encoding="utf-8")
readonly_file.chmod(0o444)
try:
readonly_file.write_text("{}", encoding="utf-8")
except (PermissionError, OSError) as e:
# Error handling code would log this
error_msg = f"Failed to write {readonly_file}: {e}"
assert "Failed to write" in error_msg
finally:
readonly_file.chmod(0o644) # Restore for cleanup
# =============================================================================
# TEST EXECUTION CONFIGURATION
# =============================================================================
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])