Skip to content

Commit 0862007

Browse files
committed
Fix module menu projection and stabilize integration tests
1 parent 298aecd commit 0862007

File tree

22 files changed

+411
-195
lines changed

22 files changed

+411
-195
lines changed

.nuke/build.schema.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"CleanPluginsArtifacts",
3434
"Compile",
3535
"Default",
36-
"GenerateBundledModules",
3736
"Pack",
3837
"PackCli",
3938
"PackLibs",

src/Hosts/Modulus.Host.Avalonia/Shell/ViewModels/ModuleListViewModel.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,15 +316,15 @@ private async Task ImportModuleAsync()
316316
{
317317
if (string.IsNullOrWhiteSpace(ImportPath)) return;
318318

319-
// ImportPath could be a directory or manifest.json
319+
// ImportPath could be a directory or extension.vsixmanifest
320320
var path = ImportPath;
321-
if (File.Exists(path) && Path.GetFileName(path) == "manifest.json")
321+
if (File.Exists(path) && Path.GetFileName(path) == SystemModuleInstaller.VsixManifestFileName)
322322
{
323323
// ok
324324
}
325325
else if (Directory.Exists(path))
326326
{
327-
path = Path.Combine(path, "manifest.json");
327+
path = Path.Combine(path, SystemModuleInstaller.VsixManifestFileName);
328328
}
329329
else
330330
{
@@ -334,7 +334,7 @@ private async Task ImportModuleAsync()
334334

335335
try
336336
{
337-
await _moduleInstaller.RegisterDevelopmentModuleAsync(path);
337+
await _moduleInstaller.RegisterDevelopmentModuleAsync(path, hostType: _runtimeContext.HostType);
338338
ImportPath = string.Empty;
339339
await RefreshModulesAsync();
340340
_notificationService?.ShowInfoAsync("Success", "Module imported.");

src/Hosts/Modulus.Host.Blazor/Components/Layout/MainLayout.razor

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
@using Modulus.UI.Abstractions
2+
@using Modulus.UI.Abstractions.Messages
3+
@using CommunityToolkit.Mvvm.Messaging
24
@using Microsoft.AspNetCore.Components
35
@using Microsoft.AspNetCore.Components.Routing
46
@using MudBlazor
@@ -197,6 +199,11 @@
197199
NavigationManager.LocationChanged += OnLocationChanged;
198200
ModuleStylesheetService.Changed += OnModuleStylesheetChanged;
199201

202+
// Keep navigation menu in sync with runtime installs/enables (menus are still sourced from DB only).
203+
WeakReferenceMessenger.Default.Register<MenuRefreshMessage>(this, (_, _) => RefreshMenus());
204+
WeakReferenceMessenger.Default.Register<MenuItemsAddedMessage>(this, (_, _) => RefreshMenus());
205+
WeakReferenceMessenger.Default.Register<MenuItemsRemovedMessage>(this, (_, _) => RefreshMenus());
206+
200207
// Don't assume dark mode here - will be set properly in OnAfterRenderAsync
201208
_isDarkMode = ThemeService.CurrentTheme == AppTheme.Dark;
202209
}
@@ -395,10 +402,21 @@
395402
_errorBoundary?.Recover();
396403
}
397404

405+
private void RefreshMenus()
406+
{
407+
_ = InvokeAsync(() =>
408+
{
409+
_mainMenuItems = MenuRegistry.GetItems(MenuLocation.Main).ToList();
410+
_bottomMenuItems = MenuRegistry.GetItems(MenuLocation.Bottom).ToList();
411+
StateHasChanged();
412+
});
413+
}
414+
398415
public void Dispose()
399416
{
400417
ThemeService.ThemeChanged -= OnThemeChanged;
401418
NavigationManager.LocationChanged -= OnLocationChanged;
402419
ModuleStylesheetService.Changed -= OnModuleStylesheetChanged;
420+
WeakReferenceMessenger.Default.UnregisterAll(this);
403421
}
404422
}

src/Hosts/Modulus.Host.Blazor/Shell/ViewModels/ModuleListViewModel.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ public async Task ToggleModuleAsync(ModuleViewModel moduleVm)
206206
if (packagePath != null)
207207
{
208208
await _moduleLoader.LoadAsync(packagePath, moduleVm.IsSystem);
209+
210+
// Register menus from database and notify shell (incremental)
211+
var addedMenus = await RegisterModuleMenusAsync(moduleVm.Id);
212+
if (addedMenus.Count > 0)
213+
{
214+
WeakReferenceMessenger.Default.Send(new MenuItemsAddedMessage(addedMenus));
215+
}
209216
}
210217
}
211218

@@ -283,7 +290,7 @@ public async Task ImportModuleAsync()
283290

284291
try
285292
{
286-
await _moduleInstaller.RegisterDevelopmentModuleAsync(path);
293+
await _moduleInstaller.RegisterDevelopmentModuleAsync(path, hostType: _runtimeContext.HostType);
287294
ImportPath = string.Empty;
288295
await RefreshModulesAsync();
289296
SuccessMessage = "Module imported successfully.";

src/Modulus.Core/Installation/IModuleInstallerService.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ public interface IModuleInstallerService
1818
/// <summary>
1919
/// Registers a development module that exists on disk but is not in the database.
2020
/// </summary>
21-
Task RegisterDevelopmentModuleAsync(string manifestPath, CancellationToken cancellationToken = default);
21+
/// <param name="manifestPath">Path to extension.vsixmanifest or its containing directory.</param>
22+
/// <param name="hostType">Current host type for host-aware validation and menu projection.</param>
23+
Task RegisterDevelopmentModuleAsync(string manifestPath, string? hostType = null, CancellationToken cancellationToken = default);
2224

2325
/// <summary>
2426
/// Installs a module from a .modpkg package file.

src/Modulus.Core/Installation/ModuleInstallerService.cs

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ public async Task InstallFromPathAsync(string packagePath, bool isSystem = false
6969
// Extract menus from module assembly attributes (metadata-only parsing)
7070
var menus = new List<MenuInfo>();
7171
var moduleLocation = MenuLocation.Main;
72+
var requestedBottom = false;
7273

7374
if (hostType != null && validationResult.IsValid)
7475
{
@@ -91,7 +92,7 @@ public async Task InstallFromPathAsync(string packagePath, bool isSystem = false
9192
menus = ModuleMenuAttributeReader.ReadMenus(assemblyPath, hostType).ToList();
9293

9394
// Determine module location from menu attributes
94-
var requestedBottom = menus.Any(m => m.Location == MenuLocation.Bottom);
95+
requestedBottom = menus.Any(m => m.Location == MenuLocation.Bottom);
9596
moduleLocation = (isSystem && requestedBottom) ? MenuLocation.Bottom : MenuLocation.Main;
9697

9798
if (!isSystem && requestedBottom)
@@ -101,12 +102,16 @@ public async Task InstallFromPathAsync(string packagePath, bool isSystem = false
101102
}
102103
catch (Exception ex)
103104
{
104-
_logger.LogWarning(ex, "Failed to read menu attributes from {AssemblyPath} for module {ModuleId}. Menus will be empty.", assemblyPath, identity.Id);
105+
_logger.LogError(ex, "Failed to read menu attributes from {AssemblyPath} for module {ModuleId}.", assemblyPath, identity.Id);
106+
throw new InvalidOperationException(
107+
$"Invalid menu metadata in '{assemblyPath}' for module '{identity.Id}'. " +
108+
"Fix [BlazorMenu]/[AvaloniaMenu] declarations and reinstall.",
109+
ex);
105110
}
106111
}
107112
else
108113
{
109-
_logger.LogWarning("Package assembly not found at {AssemblyPath} for module {ModuleId}. Menus will be empty.", assemblyPath, identity.Id);
114+
throw new InvalidOperationException($"Package assembly not found at '{assemblyPath}' for module '{identity.Id}'.");
110115
}
111116
}
112117
else
@@ -115,15 +120,11 @@ public async Task InstallFromPathAsync(string packagePath, bool isSystem = false
115120
}
116121
}
117122

118-
var moduleLocation = (isSystem && requestedBottom) ? MenuLocation.Bottom : MenuLocation.Main;
119-
120123
// Compute manifest hash for change detection
121124
var manifestHash = await VsixManifestReader.ComputeHashAsync(manifestPath, cancellationToken);
122125

123126
// Preserve existing IsEnabled state when updating
124127
var existingModule = await _moduleRepository.GetAsync(identity.Id, cancellationToken);
125-
// Preserve disabled state across updates, but do not keep a module enabled if validation fails.
126-
var preserveIsEnabled = existingModule?.IsEnabled == false ? false : validationResult.IsValid;
127128

128129
// Prepare entities
129130
var moduleState = validationResult.IsValid
@@ -198,12 +199,19 @@ public async Task InstallFromPathAsync(string packagePath, bool isSystem = false
198199
_logger.LogInformation("Module {ModuleId} installed successfully.", identity.Id);
199200
}
200201

201-
public Task RegisterDevelopmentModuleAsync(string manifestPath, CancellationToken cancellationToken = default)
202+
public Task RegisterDevelopmentModuleAsync(string manifestPath, string? hostType = null, CancellationToken cancellationToken = default)
202203
{
203-
var dir = Path.GetDirectoryName(manifestPath);
204-
if (dir == null) throw new ArgumentException("Invalid manifest path");
205-
206-
return InstallFromPathAsync(dir, isSystem: false, hostType: null, cancellationToken);
204+
if (string.IsNullOrWhiteSpace(manifestPath))
205+
throw new ArgumentException("Manifest path is required.", nameof(manifestPath));
206+
207+
var dir = Directory.Exists(manifestPath)
208+
? manifestPath
209+
: Path.GetDirectoryName(manifestPath);
210+
211+
if (string.IsNullOrWhiteSpace(dir))
212+
throw new ArgumentException("Invalid manifest path.", nameof(manifestPath));
213+
214+
return InstallFromPathAsync(dir, isSystem: false, hostType: hostType, cancellationToken);
207215
}
208216

209217
public async Task<ModuleInstallResult> InstallFromPackageAsync(string packagePath, bool overwrite = false, string? hostType = null, CancellationToken cancellationToken = default)

src/Modulus.Core/Installation/ModuleMenuAttributeReader.cs

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,13 @@ public static IReadOnlyList<MenuInfo> ReadMenus(string assemblyPath, string host
4242
if (!IsDerivedFrom(type, ModulusPackageFullName))
4343
continue;
4444

45+
var declaringType = type.FullName ?? type.Name;
4546
var attrs = type.GetCustomAttributesData();
4647

4748
if (isBlazor)
48-
AppendBlazorMenus(attrs, menus);
49+
AppendBlazorMenus(declaringType, attrs, menus);
4950
else if (isAvalonia)
50-
AppendAvaloniaMenus(attrs, menus);
51+
AppendAvaloniaMenus(declaringType, attrs, menus);
5152
}
5253

5354
return menus;
@@ -98,19 +99,26 @@ private static bool IsDerivedFrom(Type type, string baseTypeFullName)
9899
return false;
99100
}
100101

101-
private static void AppendBlazorMenus(IList<CustomAttributeData> attrs, List<MenuInfo> menus)
102+
private static void AppendBlazorMenus(string declaringType, IList<CustomAttributeData> attrs, List<MenuInfo> menus)
102103
{
103104
foreach (var a in attrs)
104105
{
105106
if (!string.Equals(a.AttributeType.FullName, BlazorMenuAttributeFullName, StringComparison.Ordinal))
106107
continue;
107108

108109
if (a.ConstructorArguments.Count < 3)
109-
continue;
110+
throw new InvalidOperationException($"Invalid [BlazorMenu] on '{declaringType}': expected constructor arguments (key, displayName, route).");
111+
112+
var key = a.ConstructorArguments[0].Value as string;
113+
var displayName = a.ConstructorArguments[1].Value as string;
114+
var route = a.ConstructorArguments[2].Value as string;
110115

111-
var key = a.ConstructorArguments[0].Value as string ?? string.Empty;
112-
var displayName = a.ConstructorArguments[1].Value as string ?? string.Empty;
113-
var route = a.ConstructorArguments[2].Value as string ?? string.Empty;
116+
if (string.IsNullOrWhiteSpace(key))
117+
throw new InvalidOperationException($"Invalid [BlazorMenu] on '{declaringType}': 'key' is required.");
118+
if (string.IsNullOrWhiteSpace(displayName))
119+
throw new InvalidOperationException($"Invalid [BlazorMenu] on '{declaringType}': 'displayName' is required.");
120+
if (string.IsNullOrWhiteSpace(route))
121+
throw new InvalidOperationException($"Invalid [BlazorMenu] on '{declaringType}': 'route' is required.");
114122

115123
var icon = IconKind.Grid;
116124
var location = MenuLocation.Main;
@@ -142,26 +150,34 @@ private static void AppendBlazorMenus(IList<CustomAttributeData> attrs, List<Men
142150
Route = route,
143151
Icon = icon.ToString(),
144152
Location = location,
145-
Order = order
153+
Order = order,
154+
DeclaringType = declaringType
146155
});
147156
}
148157
}
149158

150-
private static void AppendAvaloniaMenus(IList<CustomAttributeData> attrs, List<MenuInfo> menus)
159+
private static void AppendAvaloniaMenus(string declaringType, IList<CustomAttributeData> attrs, List<MenuInfo> menus)
151160
{
152161
foreach (var a in attrs)
153162
{
154163
if (!string.Equals(a.AttributeType.FullName, AvaloniaMenuAttributeFullName, StringComparison.Ordinal))
155164
continue;
156165

157166
if (a.ConstructorArguments.Count < 3)
158-
continue;
167+
throw new InvalidOperationException($"Invalid [AvaloniaMenu] on '{declaringType}': expected constructor arguments (key, displayName, viewModelType).");
159168

160-
var key = a.ConstructorArguments[0].Value as string ?? string.Empty;
161-
var displayName = a.ConstructorArguments[1].Value as string ?? string.Empty;
169+
var key = a.ConstructorArguments[0].Value as string;
170+
var displayName = a.ConstructorArguments[1].Value as string;
162171
var viewModelType = a.ConstructorArguments[2].Value as Type;
163172
var route = viewModelType?.FullName ?? viewModelType?.Name ?? string.Empty;
164173

174+
if (string.IsNullOrWhiteSpace(key))
175+
throw new InvalidOperationException($"Invalid [AvaloniaMenu] on '{declaringType}': 'key' is required.");
176+
if (string.IsNullOrWhiteSpace(displayName))
177+
throw new InvalidOperationException($"Invalid [AvaloniaMenu] on '{declaringType}': 'displayName' is required.");
178+
if (string.IsNullOrWhiteSpace(route))
179+
throw new InvalidOperationException($"Invalid [AvaloniaMenu] on '{declaringType}': 'viewModelType' is required.");
180+
165181
var icon = IconKind.Grid;
166182
var location = MenuLocation.Main;
167183
var order = 50;
@@ -192,7 +208,8 @@ private static void AppendAvaloniaMenus(IList<CustomAttributeData> attrs, List<M
192208
Route = route,
193209
Icon = icon.ToString(),
194210
Location = location,
195-
Order = order
211+
Order = order,
212+
DeclaringType = declaringType
196213
});
197214
}
198215
}
@@ -249,5 +266,6 @@ public sealed class MenuInfo
249266
public string Icon { get; set; } = string.Empty;
250267
public MenuLocation Location { get; set; } = MenuLocation.Main;
251268
public int Order { get; set; }
269+
public string DeclaringType { get; set; } = string.Empty;
252270
}
253271

src/Modulus.Core/Runtime/ModuleLoader.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ public async Task InitializeLoadedModulesAsync(CancellationToken cancellationTok
213213
return existingModule!.Descriptor;
214214
}
215215

216+
// Allow explicit reload after unload by resetting execution health.
217+
// Unload marks the module as Unloaded in the execution guard; a subsequent Load/Reload is an explicit recovery attempt.
218+
_executionGuard.ResetHealth(identity.Id);
219+
216220
if (!NuGetVersion.TryParse(identity.Version, out _))
217221
{
218222
_logger.LogWarning("Module {ModuleId} version {Version} is not a valid semantic version.", identity.Id, identity.Version);

tests/Modulus.Cli.IntegrationTests/Commands/BuildCommandTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ public async Task Build_NoModuleProject_Fails()
7878
Assert.Contains("No module project found", result.CombinedOutput);
7979
}
8080

81-
[Fact(Skip = "Verbose build has process wait issues in test environment")]
81+
[Fact]
8282
public async Task Build_Verbose_ShowsDetails()
8383
{
8484
// Arrange

tests/Modulus.Cli.IntegrationTests/Commands/InstallCommandTests.cs

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,11 @@ public InstallCommandTests()
2020
[Fact]
2121
public async Task Install_FromModpkg_Succeeds()
2222
{
23-
// Arrange - Create and pack a module
24-
var newResult = await _runner.NewAsync("InstallTest", "avalonia");
25-
Assert.True(newResult.IsSuccess, $"Failed to create module: {newResult.CombinedOutput}");
26-
27-
var moduleDir = Path.Combine(_context.WorkingDirectory, "InstallTest");
28-
var packResult = await _runner.PackAsync(path: moduleDir, output: _context.OutputDirectory);
29-
Assert.True(packResult.IsSuccess, $"Pack failed: {packResult.CombinedOutput}");
30-
31-
var packages = Directory.GetFiles(_context.OutputDirectory, "*.modpkg");
32-
Assert.Single(packages);
23+
// Arrange - reuse shared prebuilt package to avoid repeated new/pack/build cost
24+
var artifact = await SharedCliArtifacts.GetAvaloniaAsync();
3325

3426
// Act
35-
var result = await _runner.InstallAsync(packages[0], force: true);
27+
var result = await _runner.InstallAsync(artifact.PackagePath, force: true);
3628

3729
// Assert
3830
Assert.True(result.IsSuccess, $"Install failed: {result.CombinedOutput}");
@@ -42,16 +34,11 @@ public async Task Install_FromModpkg_Succeeds()
4234
[Fact]
4335
public async Task Install_FromDirectory_Succeeds()
4436
{
45-
// Arrange - Create and build a module (not packed)
46-
var newResult = await _runner.NewAsync("DirInstall", "avalonia");
47-
Assert.True(newResult.IsSuccess, $"Failed to create module: {newResult.CombinedOutput}");
48-
49-
var moduleDir = Path.Combine(_context.WorkingDirectory, "DirInstall");
50-
var buildResult = await _runner.BuildAsync(path: moduleDir);
51-
Assert.True(buildResult.IsSuccess, $"Build failed: {buildResult.CombinedOutput}");
37+
// Arrange - reuse extracted directory from shared package (valid manifest + DLLs present)
38+
var artifact = await SharedCliArtifacts.GetAvaloniaAsync();
5239

5340
// Act - Install from module directory
54-
var result = await _runner.InstallAsync(moduleDir, force: true);
41+
var result = await _runner.InstallAsync(artifact.ExtractedDir, force: true);
5542

5643
// Assert
5744
Assert.True(result.IsSuccess, $"Install failed: {result.CombinedOutput}");
@@ -61,22 +48,15 @@ public async Task Install_FromDirectory_Succeeds()
6148
[Fact]
6249
public async Task Install_ForceOverwrite_Succeeds()
6350
{
64-
// Arrange - Create and install a module
65-
var newResult = await _runner.NewAsync("ForceInstall", "avalonia");
66-
Assert.True(newResult.IsSuccess, $"Failed to create module: {newResult.CombinedOutput}");
67-
68-
var moduleDir = Path.Combine(_context.WorkingDirectory, "ForceInstall");
69-
var packResult = await _runner.PackAsync(path: moduleDir, output: _context.OutputDirectory);
70-
Assert.True(packResult.IsSuccess, $"Pack failed: {packResult.CombinedOutput}");
71-
72-
var packages = Directory.GetFiles(_context.OutputDirectory, "*.modpkg");
51+
// Arrange - install the same shared package twice (force overwrite)
52+
var artifact = await SharedCliArtifacts.GetAvaloniaAsync();
7353

7454
// Install first time
75-
var firstInstall = await _runner.InstallAsync(packages[0], force: true);
55+
var firstInstall = await _runner.InstallAsync(artifact.PackagePath, force: true);
7656
Assert.True(firstInstall.IsSuccess, $"First install failed: {firstInstall.CombinedOutput}");
7757

7858
// Act - Install again with force
79-
var result = await _runner.InstallAsync(packages[0], force: true);
59+
var result = await _runner.InstallAsync(artifact.PackagePath, force: true);
8060

8161
// Assert
8262
Assert.True(result.IsSuccess, $"Second install failed: {result.CombinedOutput}");

0 commit comments

Comments
 (0)