From 70c4a4a39a10813087441558b876bb22a1771e05 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 8 Feb 2026 11:29:09 -0700 Subject: [PATCH] test: comprehensive test suite for mcilspy MCP server Add complete test coverage for the mcilspy package: - T7: Create TestAssembly.dll fixture with known types/members - T1: Integration tests using real assembly (metadata reader + ILSpy wrapper) - T2: MCP tool tests with mocked wrapper for each @mcp.tool() - T3: Error path tests for regex, file not found, invalid assemblies - T4: Concurrency tests with asyncio.gather() for parallel operations - T5: Docstring coverage tests using AST introspection - T6: Timeout behavior tests for 5-minute subprocess timeout Test summary: - 147 tests passing - 14 skipped (ilspycmd-dependent integration tests) - 73% code coverage - All ruff linting checks pass --- tests/conftest.py | 66 ++- tests/fixtures/TestAssembly.cs | 214 +++++++++ tests/fixtures/TestAssembly.dll | Bin 0 -> 8704 bytes tests/integration/__init__.py | 1 + tests/integration/test_real_assembly.py | 379 ++++++++++++++++ tests/test_concurrency.py | 285 ++++++++++++ tests/test_docstrings.py | 301 ++++++++++++ tests/test_error_paths.py | 423 +++++++++++++++++ tests/test_models.py | 2 +- tests/test_server_tools.py | 580 ++++++++++++++++++++++++ tests/test_timeout.py | 261 +++++++++++ 11 files changed, 2500 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/TestAssembly.cs create mode 100644 tests/fixtures/TestAssembly.dll create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_real_assembly.py create mode 100644 tests/test_concurrency.py create mode 100644 tests/test_docstrings.py create mode 100644 tests/test_error_paths.py create mode 100644 tests/test_server_tools.py create mode 100644 tests/test_timeout.py diff --git a/tests/conftest.py b/tests/conftest.py index dee9233..78d068d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,33 +1,58 @@ """Shared pytest fixtures for mcilspy tests.""" import os +import shutil from pathlib import Path import pytest +# Path to test fixtures directory +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def test_assembly_path() -> str: + """Return path to the custom test assembly. + + This is the primary fixture for tests - uses our custom-built + TestAssembly.dll with known types and members. + """ + test_dll = FIXTURES_DIR / "TestAssembly.dll" + if not test_dll.exists(): + pytest.skip("TestAssembly.dll not found - run build_test_assembly.sh first") + return str(test_dll) + @pytest.fixture def sample_assembly_path() -> str: """Return path to a .NET assembly for testing. - Uses a known .NET SDK assembly that should exist on systems with dotnet installed. + Falls back to SDK assemblies if test assembly not available. + Prefer using test_assembly_path for new tests. """ - # Try to find a .NET SDK assembly + # First try our test assembly + test_dll = FIXTURES_DIR / "TestAssembly.dll" + if test_dll.exists(): + return str(test_dll) + + # Fallback: Try to find a .NET SDK assembly dotnet_base = Path("/usr/share/dotnet/sdk") if dotnet_base.exists(): - # Find any SDK version for sdk_dir in dotnet_base.iterdir(): - test_dll = sdk_dir / "Sdks" / "Microsoft.NET.Sdk" / "tools" / "net10.0" / "Microsoft.NET.Build.Tasks.dll" - if test_dll.exists(): - return str(test_dll) - # Try older paths - for net_version in ["net9.0", "net8.0", "net7.0", "net6.0"]: - test_dll = sdk_dir / "Sdks" / "Microsoft.NET.Sdk" / "tools" / net_version / "Microsoft.NET.Build.Tasks.dll" + for net_version in ["net10.0", "net9.0", "net8.0", "net7.0", "net6.0"]: + test_dll = ( + sdk_dir + / "Sdks" + / "Microsoft.NET.Sdk" + / "tools" + / net_version + / "Microsoft.NET.Build.Tasks.dll" + ) if test_dll.exists(): return str(test_dll) - # Fallback: any .dll in dotnet directory - for root, dirs, files in os.walk("/usr/share/dotnet"): + # Last resort: any .dll in dotnet directory + for root, _dirs, files in os.walk("/usr/share/dotnet"): for f in files: if f.endswith(".dll"): return os.path.join(root, f) @@ -39,3 +64,22 @@ def sample_assembly_path() -> str: def nonexistent_path() -> str: """Return a path that doesn't exist.""" return "/nonexistent/path/to/assembly.dll" + + +@pytest.fixture +def ilspycmd_installed() -> bool: + """Check if ilspycmd is available for integration tests.""" + return shutil.which("ilspycmd") is not None + + +@pytest.fixture +def skip_without_ilspycmd(ilspycmd_installed): + """Skip test if ilspycmd is not installed.""" + if not ilspycmd_installed: + pytest.skip("ilspycmd not installed") + + +@pytest.fixture +def temp_output_dir(tmp_path): + """Provide a temporary directory for test outputs.""" + return str(tmp_path) diff --git a/tests/fixtures/TestAssembly.cs b/tests/fixtures/TestAssembly.cs new file mode 100644 index 0000000..1af8668 --- /dev/null +++ b/tests/fixtures/TestAssembly.cs @@ -0,0 +1,214 @@ +using System; +using System.Threading.Tasks; + +namespace TestNamespace +{ + /// + /// A test class with various members for testing the mcilspy MCP server. + /// + public class TestClass + { + // Constants for testing string search + public const string API_KEY = "test-secret-key"; + public const string BASE_URL = "https://api.example.com"; + public const int MAX_RETRIES = 3; + + // Fields + public static readonly string BaseUrl = "https://api.example.com"; + private int _privateField; + protected string _protectedField; + internal double _internalField; + + // Properties + public string Name { get; set; } + public int Age { get; private set; } + public virtual bool IsActive { get; set; } + + // Events + public event EventHandler OnChange; + public event EventHandler OnMessage; + + // Constructors + public TestClass() + { + Name = "Default"; + Age = 0; + } + + public TestClass(string name, int age) + { + Name = name; + Age = age; + } + + // Methods + public void DoSomething() + { + Console.WriteLine("Hello from DoSomething"); + OnChange?.Invoke(this, EventArgs.Empty); + } + + public string GetGreeting() + { + return $"Hello, {Name}!"; + } + + public static int Add(int a, int b) + { + return a + b; + } + + protected virtual void OnPropertyChanged(string propertyName) + { + OnMessage?.Invoke(this, propertyName); + } + + private void PrivateMethod() + { + _privateField = 42; + } + } + + /// + /// Interface for testing interface discovery. + /// + public interface ITestService + { + void Execute(); + Task ExecuteAsync(); + string ServiceName { get; } + } + + /// + /// Another interface for inheritance testing. + /// + public interface IConfigurable + { + void Configure(string settings); + } + + /// + /// Struct for testing struct discovery. + /// + public struct TestStruct + { + public int Value; + public string Label; + + public TestStruct(int value, string label) + { + Value = value; + Label = label; + } + + public override string ToString() => $"{Label}: {Value}"; + } + + /// + /// Enum for testing enum discovery. + /// + public enum TestEnum + { + None = 0, + First = 1, + Second = 2, + Third = 3 + } + + /// + /// Delegate for testing delegate discovery. + /// + public delegate void TestDelegate(string message); + + /// + /// Delegate with return type. + /// + public delegate bool ValidationDelegate(T value); + + /// + /// Service implementation for testing class relationships. + /// + public class TestServiceImpl : ITestService, IConfigurable + { + private string _config; + + public string ServiceName => "TestService"; + + public void Execute() + { + Console.WriteLine($"Executing with config: {_config}"); + } + + public Task ExecuteAsync() + { + return Task.FromResult($"Async result from {ServiceName}"); + } + + public void Configure(string settings) + { + _config = settings; + } + } + + /// + /// Nested class for testing nested type discovery. + /// + public class OuterClass + { + public class NestedClass + { + public string Value { get; set; } + } + + private class PrivateNestedClass + { + public int Secret { get; set; } + } + + public NestedClass CreateNested() => new NestedClass(); + } + + /// + /// Abstract class for testing abstract type discovery. + /// + public abstract class AbstractBase + { + public abstract void AbstractMethod(); + public virtual void VirtualMethod() { } + + protected string BaseProperty { get; set; } + } + + /// + /// Derived class for testing inheritance. + /// + public class DerivedClass : AbstractBase + { + public override void AbstractMethod() + { + Console.WriteLine("Implemented abstract method"); + } + + public override void VirtualMethod() + { + base.VirtualMethod(); + Console.WriteLine("Overridden virtual method"); + } + } +} + +namespace TestNamespace.SubNamespace +{ + /// + /// Class in a sub-namespace for testing namespace filtering. + /// + public class SubClass + { + public const string CONNECTION_STRING = "Server=localhost;Database=test"; + + public void SubMethod() + { + Console.WriteLine("Sub namespace method"); + } + } +} diff --git a/tests/fixtures/TestAssembly.dll b/tests/fixtures/TestAssembly.dll new file mode 100644 index 0000000000000000000000000000000000000000..09c8b53ed1efbd5404a8f93a15d5b151b6929528 GIT binary patch literal 8704 zcmeHMeQX@pai6z$w>(nPiK0F%+cI@DZIz+LBPmf|m28nBDd`-Mlz0>+TSZTMcS~}k zy}k499z}+xAvd;~01t6cyJEl#O6y0 zy4d)3+binG+iepwmX|EMrD@kFCi8~ll>B7QOu7{(X*tQEu}rd9DwwHNtC}7NO&=X5 zI-(+UX`=mus<-#({^SbPLv#-~dcb{mH|ivwemq3Yf@?wC%;5N?oC5@(ON358%c}fW zt3#4mDCc2!jFEn#Z*d})m(xTm!FzHS(Zq7ukE4@B@j7`N^x--><(qRp==U}PKqh?+ z;|3)qNpw2pdTt(+#MTdlJGu!^UFk>kDc7`1P~=$YG#<8<#8X%Li4N3}f?ndhqHt^) z#VFcNG`EgO3HRfJR&4>@j&|O!MHU4Cqp`{m1dOICLl9cq6QNJQ6@0GI7FIymq_1d* zbf7Oy{wnZSYELpZ-p~P+hnn<;?f=vX{f}ywxWjfx5&XeRy+3%k7yg(Fe^9yA-|}|i z@1xpHX&tK(kLE(5%=UC`Lk9*?@0<)Jk$@WwxaZw9tcWSw6y)N;E^SMv_7u2Hr51qB z$KCzV&^Q;mi@96wSj+4dEpoDRH0V#zCQ`ycHg#^%Hg_(@(4Wqfn}PZ{c#n2If&oHq zqgr$k@kcvd_cLsDfeCGaCBJ+xQs1$jP2A&NfDoSlP;_86+OYuw2@~w^FuS7-0R`*` z(k4@YH9K2_j&|PT^>M$eBx4IOA50cOs-lz|QDG7~4{9Bl9J;%Ai36>zY$)ZH>Uo@I zHhEXoq##w35MZeVz67ZXuqZN*9#G+o$GFpMU#cgyy=Qyx9-jX(vH|}clezsVqLY}z zm$4_cXMERkraji!h{5^|j7IzM3|+)H@pjXG=y-Yv^=qInV$|CY*rgmRqkvQ=Hh-nD z5xJ)8YC9p7XfDedogm&4u-y>yDa#oBkiF#okY-)jkgXA65oU$7XMR=2HAVrYF~&G+ zzZ>~%OsB0;hJBGrY&HEJriD(~$e%`aS{qG+|CbSla{@02ye{ytz%G3cq^DvG?<0nH zi_H!6Ch))4m@_2O>w=d>|7w)^%@Kw#(gpZBFVf4R`3;e76>I++eG?H8ZDWAG@UNNH z>(^AT;9msw19c4@T19J#sW~m6Hc+E#rZH#^Lv|M_==8^~sWh#oyM=g^fWwt4<34HJ#~oeLuhWrfOQFVHTF7W8>k24fyfYhFMMx9 z=9}qXh1y6vX!2Zv_NmEpBLXLBzuHN?v3&^fHQ-UY2!4bL>Hs+Z0$kDGg0Ej zhXLEQBTA>!(fxoQM)xUQx1lq6?ps<0@Ohn~rx<<>P|>eIN6|sWe)o#a4+W;weswg- z**zbBPCEgelOgqMS~L1&gw&7JDW#Dm)YEZYQ|O^9)Rjg%nnk>y4ypNQ9@H~oyB*pL zdh}{Y9RL-h{|u>w<{_@z6n$V83DC%26H1$3ro~l6{pVy6#WinOcVWCNc~;( zgleKUh576%ba(=+h4ioPe*=QcXA&nRqbQm(`vh0Pi@N55E; zt$GQ0smZP(FRSQ>V&y#Id|oA}k(VM}p|=o=TrM!;EW5BoT~SY<9j{DYhgX!Xy{NEF zhg44clER`FDrm=RJkWeX`?9)wiL$kq6<>n_Sx&p8?xQW@{kx4EXDf9Dl;R$>(ojfo zk6P(eNO2!psUQ^l(`hr=0Y$${m(^zSZjy;Zx)OT{8Qe?@BDogfvG=*dE&fr%y-fw#W75QkZ(X3c_SvY@= zbspH!C?a?t&IgVDSTVm#bQo&%eRzxzOBH=qV7+E7p4(~GywqZD#9VL0jBlhF?D1;= zu^$6A;WjlBYsOy0aF4(NfoXxq1U@Ek9#E%0p|`XL=r3qo>jHj0`T!Zi|10{M_87fE z7xbSei=NPO^iA5VTk!jYRtCOBKP#N4=qfGh&rkx_)87P))2nz^(!T-r(+7Y<^k0C7 z={n#M@Dp^BZeRpYfuEuk^i7q*j{Yv-z4SxCcG9#IJw%TJZl^rpUR*I!G)RkppA>!u zuwN{k7I;qJO9C$od`}=L_M!wP1-1$77dRl$5cssfFKVoRRiKJ6-XCLlT428}xWJbL zUKV&S?Z+5n(6M9h0BqNEp(DsiBW@8ZaMC8I0p}5)*{^7S4b>*?4Zy|d+kjsZP5@ul zS$aCk(9++)Uf2Lz&1k8GHhDjefNq5KX6%kpp=0nLg)hFzHvreA-Fj$gSOY5nJF%B3 z$}H(o8vw-Ou7VM&0xR zvnCDODj2?1a)wOXoHl&(bT3U9-WgQGv!>%8Hk^WOx**ezzkM6+8z~hkw)qGRj*X5E z4^E`VMzfiT@$~2+IxvtK&K@5>LL&nwv*W{H4rlO2HgGhZ{p9dtG;7!uGn>V{&C&3j znXmZffH&{tX`oP`G3TgTDx0o9KR9DJ(`JF)nJ!+K&Hx{DO++?odOnCO-d=q(5A>jA z+Cp2i5C_!0fob!RGui9`BYy_BzmPd<6mP>t=PS2ir@eu^Z_VCDk}>nH>ECMpfZ>_d z_-~PBE$)e9*g-6?QF02DspLjXf2LHRqppM>Qj?bJR}4F(26CS78hJk?i$M#=^gSQr zVz>pu`#eLWhWqH1jl7wH?Iq%75v5r(W4g0e-lS>M&mvvqg|$&AWXGHl)AI~)U9(u4 zHR~8KFM>cV7^j+6NDr2ZWy3Xx=khX+H0%`8&TQ$7Ne9en%L(!HV9A-XrYo+IvrPiw zl~8j$9JmA|2y?<{g~rg!BoV@4)f2Aen@22%trT-ykCp&rXeZ{&Vq83#R81z27L8PF z-^v@FU!C`os3n5CVPW1MG9A;!&!&eD*Xt4vRG;5FWW=b6QvJwIXj_3R15 zMGp?*Q;T`Fja<#i^K>e3S75-~0_ zjj~u67%IxlW1SaM6Ej%#1*~BjDrHJM^%)O^VKAvMEi9QR1uuMb$n+1nrs*?Nc9=oK zwsY7IC|^Z*hod7#&a}xE$jhQGC7;6nk}BA?Op0&i(prBgSO#$)mx*^AMJ)ee)Wq)1_)bFce<|YQ8U#o}6 zq$!*!6?_B0&9Cb)XbdWqC<(rcx(I9tbw+R#S~IvQIW&#dHSMj?qDUK2_Y5?ADwBtk zvKv1GxP=>#Nprx9plskN%8NZ(vw(NYMd&7J0r$B@qPr#__h2J~+Qm)Og02b6!?0ii zuRs<=kfgJ~IHIK3a}cwI$k-Y^H~=g2h%^t%6`MgX>w8AK(n~QFq2s`sNd>gvbC#oU zf#p#rp%?VN1UYRVLu4GE3y%f-xNTCRb)j8BD|>mHPh`N)f$KoZ);wf`t((g=F*ILq zc=PkW-tdh>&#l)k{r19>Z4^x^6^|rI#el3?!(<}1zGaU}#2X?lJNSp&Q_ExgTiL84 zt<>C#ccNB&{8KGE)-*&!w+0%Tln_x|91{_}#nh9U+JuTC2^68$;(`Tg(pRCK5CC4+EN9=hoW~qUi$IXx^9tIUerRfPxk8AoWP1n%V9l9<(kLYVQBB2R9 zoLG%hs&RIaNkGwwAt{MCx*w0n5teF55@#U6{J6kP8{^4H%dteu@lDe97@)S68kxD4 z0>wvco7*ujkCTiu6Koi=A6RuIl>5H@9Eb|_S9lUhbQ_$lC5FJu< zcWQKaA`@O-wuZa+!?W8`J+P2iw{%nvSzg&T=J}jo!%dRaHc6;%RJ8VX+YhZ&)PU2@ zr@@g8j1lHW}!~-rOA~pu6sth@9O{JykB`D06sV+m4;IJRg`Y<%H%F%E2B!ikiRES!7w@!qMu9_*Mkdy}x|!eXu8 zy|`EOEIsUhBlJ0Qd=j{*`Bbj;v)^hSm 0 + assert meta.method_count > 0 + + def test_list_methods_finds_known_methods(self, test_assembly_path): + """Test that we can find methods we know exist.""" + with MetadataReader(test_assembly_path) as reader: + methods = reader.list_methods() + + method_names = [m.name for m in methods] + + # Check for methods we defined in TestClass + assert "DoSomething" in method_names + assert "GetGreeting" in method_names + assert "Add" in method_names + + def test_list_methods_with_type_filter(self, test_assembly_path): + """Test filtering methods by type.""" + with MetadataReader(test_assembly_path) as reader: + methods = reader.list_methods(type_filter="TestClass") + + # All methods should be from types containing "TestClass" + for method in methods: + assert "TestClass" in method.declaring_type + + def test_list_methods_with_namespace_filter(self, test_assembly_path): + """Test filtering methods by namespace.""" + with MetadataReader(test_assembly_path) as reader: + methods = reader.list_methods(namespace_filter="SubNamespace") + + # Should only find methods from SubNamespace + for method in methods: + assert method.namespace is not None + assert "SubNamespace" in method.namespace + + def test_list_methods_public_only(self, test_assembly_path): + """Test filtering for public methods only.""" + with MetadataReader(test_assembly_path) as reader: + public_methods = reader.list_methods(public_only=True) + all_methods = reader.list_methods(public_only=False) + + # Should have fewer public methods than total + assert len(public_methods) <= len(all_methods) + # All returned methods should be public + for method in public_methods: + assert method.is_public + + def test_list_fields_finds_known_fields(self, test_assembly_path): + """Test that we can find fields we defined.""" + with MetadataReader(test_assembly_path) as reader: + fields = reader.list_fields() + + field_names = [f.name for f in fields] + + # Check for constants and fields we defined + assert "API_KEY" in field_names + assert "BASE_URL" in field_names + assert "MAX_RETRIES" in field_names + + def test_list_fields_constants_only(self, test_assembly_path): + """Test filtering for constant fields only.""" + with MetadataReader(test_assembly_path) as reader: + constants = reader.list_fields(constants_only=True) + + # All returned fields should be literals + for field in constants: + assert field.is_literal + + const_names = [f.name for f in constants] + assert "API_KEY" in const_names + assert "MAX_RETRIES" in const_names + + def test_list_properties_finds_known_properties(self, test_assembly_path): + """Test that we can find properties we defined.""" + with MetadataReader(test_assembly_path) as reader: + properties = reader.list_properties() + + prop_names = [p.name for p in properties] + + # Check for properties we defined + assert "Name" in prop_names + assert "Age" in prop_names + assert "IsActive" in prop_names + assert "ServiceName" in prop_names + + def test_list_events_finds_known_events(self, test_assembly_path): + """Test that we can find events we defined.""" + with MetadataReader(test_assembly_path) as reader: + events = reader.list_events() + + event_names = [e.name for e in events] + + # Check for events we defined + assert "OnChange" in event_names + assert "OnMessage" in event_names + + def test_list_resources_empty_for_test_assembly(self, test_assembly_path): + """Test that test assembly has no embedded resources.""" + with MetadataReader(test_assembly_path) as reader: + resources = reader.list_resources() + + # Our simple test assembly has no resources + assert isinstance(resources, list) + + +class TestILSpyWrapperWithTestAssembly: + """Integration tests for ILSpyWrapper using real ilspycmd calls.""" + + @pytest.fixture + def wrapper(self, skip_without_ilspycmd): + """Get wrapper instance, skipping if ilspycmd not available.""" + return ILSpyWrapper() + + @pytest.mark.asyncio + async def test_list_types_finds_classes(self, wrapper, test_assembly_path): + """Test listing classes from test assembly.""" + request = ListTypesRequest( + assembly_path=test_assembly_path, + entity_types=[EntityType.CLASS], + ) + response = await wrapper.list_types(request) + + assert response.success + assert response.total_count > 0 + + type_names = [t.name for t in response.types] + assert "TestClass" in type_names + assert "TestServiceImpl" in type_names + assert "OuterClass" in type_names + + @pytest.mark.asyncio + async def test_list_types_finds_interfaces(self, wrapper, test_assembly_path): + """Test listing interfaces from test assembly.""" + request = ListTypesRequest( + assembly_path=test_assembly_path, + entity_types=[EntityType.INTERFACE], + ) + response = await wrapper.list_types(request) + + assert response.success + + type_names = [t.name for t in response.types] + assert "ITestService" in type_names + assert "IConfigurable" in type_names + + @pytest.mark.asyncio + async def test_list_types_finds_structs(self, wrapper, test_assembly_path): + """Test listing structs from test assembly.""" + request = ListTypesRequest( + assembly_path=test_assembly_path, + entity_types=[EntityType.STRUCT], + ) + response = await wrapper.list_types(request) + + assert response.success + + type_names = [t.name for t in response.types] + assert "TestStruct" in type_names + + @pytest.mark.asyncio + async def test_list_types_finds_enums(self, wrapper, test_assembly_path): + """Test listing enums from test assembly.""" + request = ListTypesRequest( + assembly_path=test_assembly_path, + entity_types=[EntityType.ENUM], + ) + response = await wrapper.list_types(request) + + assert response.success + + type_names = [t.name for t in response.types] + assert "TestEnum" in type_names + + @pytest.mark.asyncio + async def test_list_types_finds_delegates(self, wrapper, test_assembly_path): + """Test listing delegates from test assembly.""" + request = ListTypesRequest( + assembly_path=test_assembly_path, + entity_types=[EntityType.DELEGATE], + ) + response = await wrapper.list_types(request) + + assert response.success + + type_names = [t.name for t in response.types] + assert "TestDelegate" in type_names + + @pytest.mark.asyncio + async def test_decompile_specific_type(self, wrapper, test_assembly_path): + """Test decompiling a specific type.""" + request = DecompileRequest( + assembly_path=test_assembly_path, + type_name="TestNamespace.TestClass", + language_version=LanguageVersion.LATEST, + ) + response = await wrapper.decompile(request) + + assert response.success + assert response.source_code is not None + + # Check that decompiled code contains expected elements + source = response.source_code + assert "class TestClass" in source + assert "DoSomething" in source + assert "GetGreeting" in source + + @pytest.mark.asyncio + async def test_decompile_entire_assembly(self, wrapper, test_assembly_path): + """Test decompiling the entire assembly.""" + request = DecompileRequest( + assembly_path=test_assembly_path, + language_version=LanguageVersion.LATEST, + ) + response = await wrapper.decompile(request) + + assert response.success + assert response.source_code is not None + + # Check that all types are present + source = response.source_code + assert "TestClass" in source + assert "ITestService" in source + assert "TestStruct" in source + assert "TestEnum" in source + + @pytest.mark.asyncio + async def test_decompile_to_il(self, wrapper, test_assembly_path): + """Test decompiling to IL code.""" + request = DecompileRequest( + assembly_path=test_assembly_path, + type_name="TestNamespace.TestClass", + show_il_code=True, + ) + response = await wrapper.decompile(request) + + assert response.success + assert response.source_code is not None + + # IL code should contain IL-specific keywords + source = response.source_code + # IL typically shows .method, .field, etc. + assert ".class" in source or "IL_" in source + + @pytest.mark.asyncio + async def test_decompile_to_output_dir(self, wrapper, test_assembly_path, temp_output_dir): + """Test decompiling to an output directory.""" + request = DecompileRequest( + assembly_path=test_assembly_path, + output_dir=temp_output_dir, + ) + response = await wrapper.decompile(request) + + assert response.success + assert response.output_path is not None + + @pytest.mark.asyncio + async def test_decompile_with_project_structure( + self, wrapper, test_assembly_path, temp_output_dir + ): + """Test decompiling with project structure.""" + request = DecompileRequest( + assembly_path=test_assembly_path, + output_dir=temp_output_dir, + create_project=True, + ) + response = await wrapper.decompile(request) + + assert response.success + + @pytest.mark.asyncio + async def test_decompile_nonexistent_type(self, wrapper, test_assembly_path): + """Test decompiling a type that doesn't exist.""" + request = DecompileRequest( + assembly_path=test_assembly_path, + type_name="NonExistent.FakeClass", + ) + response = await wrapper.decompile(request) + + # Should still succeed but with empty or no matching output + # The actual behavior depends on ilspycmd version + assert response is not None + + +class TestIntegrationEndToEnd: + """End-to-end integration tests covering complete workflows.""" + + @pytest.mark.asyncio + async def test_discover_and_decompile_workflow( + self, skip_without_ilspycmd, test_assembly_path + ): + """Test the typical workflow: list types, then decompile specific one.""" + wrapper = ILSpyWrapper() + + # Step 1: List all types + list_request = ListTypesRequest( + assembly_path=test_assembly_path, + entity_types=[EntityType.CLASS], + ) + list_response = await wrapper.list_types(list_request) + + assert list_response.success + assert len(list_response.types) > 0 + + # Step 2: Find TestServiceImpl + service_type = None + for t in list_response.types: + if t.name == "TestServiceImpl": + service_type = t + break + + assert service_type is not None + + # Step 3: Decompile it + decompile_request = DecompileRequest( + assembly_path=test_assembly_path, + type_name=service_type.full_name, + ) + decompile_response = await wrapper.decompile(decompile_request) + + assert decompile_response.success + assert decompile_response.source_code is not None + assert "TestServiceImpl" in decompile_response.source_code + assert "ITestService" in decompile_response.source_code + + @pytest.mark.asyncio + async def test_metadata_and_decompile_combined( + self, skip_without_ilspycmd, test_assembly_path + ): + """Test using metadata reader and ILSpy wrapper together.""" + # Use metadata reader for quick discovery + with MetadataReader(test_assembly_path) as reader: + methods = reader.list_methods(type_filter="TestClass") + add_method = None + for m in methods: + if m.name == "Add": + add_method = m + break + + assert add_method is not None + assert add_method.is_static + + # Use ILSpy for decompilation + wrapper = ILSpyWrapper() + request = DecompileRequest( + assembly_path=test_assembly_path, + type_name="TestNamespace.TestClass", + ) + response = await wrapper.decompile(request) + + assert response.success + # Verify the static method is in the output + assert "static" in response.source_code + assert "Add" in response.source_code diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 0000000..4052bcb --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,285 @@ +"""Tests for concurrent tool invocations. + +These tests verify that the server handles multiple simultaneous +tool calls correctly using asyncio.gather(). +""" + +import asyncio + +import pytest + +from mcilspy import server +from mcilspy.metadata_reader import MetadataReader + + +class TestConcurrentMetadataOperations: + """Test concurrent metadata reading operations.""" + + @pytest.mark.asyncio + async def test_concurrent_search_methods(self, test_assembly_path): + """Test multiple search_methods calls running concurrently.""" + patterns = ["Get", "Do", "Set", "Add", "Create"] + + tasks = [ + server.search_methods(test_assembly_path, pattern=p) for p in patterns + ] + + results = await asyncio.gather(*tasks) + + # All tasks should complete successfully + assert len(results) == len(patterns) + + # Each result should be a string + for result in results: + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_concurrent_search_fields(self, test_assembly_path): + """Test multiple search_fields calls running concurrently.""" + patterns = ["API", "URL", "MAX", "BASE", "VALUE"] + + tasks = [ + server.search_fields(test_assembly_path, pattern=p) for p in patterns + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == len(patterns) + for result in results: + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_concurrent_search_properties(self, test_assembly_path): + """Test multiple search_properties calls running concurrently.""" + patterns = ["Name", "Value", "Is", "Service"] + + tasks = [ + server.search_properties(test_assembly_path, pattern=p) for p in patterns + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == len(patterns) + + @pytest.mark.asyncio + async def test_concurrent_mixed_operations(self, test_assembly_path): + """Test different metadata operations running concurrently.""" + tasks = [ + server.search_methods(test_assembly_path, pattern="Get"), + server.search_fields(test_assembly_path, pattern="API"), + server.search_properties(test_assembly_path, pattern="Name"), + server.list_events(test_assembly_path), + server.list_resources(test_assembly_path), + server.get_metadata_summary(test_assembly_path), + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == 6 + for result in results: + assert isinstance(result, str) + # None of them should have crashed + assert "Traceback" not in result + + @pytest.mark.asyncio + async def test_concurrent_same_assembly_multiple_readers(self, test_assembly_path): + """Test multiple MetadataReaders on the same assembly.""" + async def read_metadata(path): + """Async wrapper for metadata reading.""" + with MetadataReader(path) as reader: + return reader.get_assembly_metadata() + + # Run multiple readers concurrently + loop = asyncio.get_event_loop() + tasks = [ + loop.run_in_executor(None, lambda: MetadataReader(test_assembly_path).__enter__().get_assembly_metadata()) + for _ in range(5) + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == 5 + # All results should have the same assembly name + names = [r.name for r in results] + assert all(n == names[0] for n in names) + + +class TestConcurrentToolCalls: + """Test concurrent MCP tool invocations.""" + + @pytest.mark.asyncio + async def test_high_concurrency_search(self, test_assembly_path): + """Test high number of concurrent searches.""" + num_concurrent = 20 + + tasks = [ + server.search_methods(test_assembly_path, pattern=f"pattern{i}") + for i in range(num_concurrent) + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == num_concurrent + # Most should return "No methods found" but shouldn't crash + for result in results: + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_concurrent_with_errors(self, test_assembly_path, nonexistent_path): + """Test concurrent calls where some will fail.""" + tasks = [ + # These should succeed + server.search_methods(test_assembly_path, pattern="Get"), + server.search_fields(test_assembly_path, pattern="API"), + # These should fail gracefully + server.search_methods(nonexistent_path, pattern="test"), + server.search_fields(nonexistent_path, pattern="test"), + ] + + results = await asyncio.gather(*tasks, return_exceptions=True) + + assert len(results) == 4 + + # First two should be successful results + assert "GetGreeting" in results[0] or "No methods" in results[0] + assert "API_KEY" in results[1] or "No fields" in results[1] + + # Last two should have error messages + assert "Error" in results[2] + assert "Error" in results[3] + + @pytest.mark.asyncio + async def test_concurrent_list_operations(self, test_assembly_path): + """Test concurrent list operations.""" + tasks = [ + server.list_events(test_assembly_path), + server.list_events(test_assembly_path), + server.list_resources(test_assembly_path), + server.list_resources(test_assembly_path), + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == 4 + # Event results should be identical + assert results[0] == results[1] + # Resource results should be identical + assert results[2] == results[3] + + +class TestConcurrentWithRegex: + """Test concurrent operations with regex patterns.""" + + @pytest.mark.asyncio + async def test_concurrent_regex_searches(self, test_assembly_path): + """Test concurrent regex pattern searches.""" + patterns = [r"^Get.*", r".*Service$", r"On\w+", r".*Base.*"] + + tasks = [ + server.search_methods(test_assembly_path, pattern=p, use_regex=True) + for p in patterns + ] + + results = await asyncio.gather(*tasks) + + assert len(results) == 4 + for result in results: + assert isinstance(result, str) + + @pytest.mark.asyncio + async def test_concurrent_invalid_regex(self, test_assembly_path): + """Test that concurrent invalid regex patterns are handled safely.""" + patterns = [ + r"[invalid", # Invalid + r"valid.*", # Valid + r"(?P