feat: enhance platform independence and integrate ComfyUI lifecycle management

- Added cross-platform process termination in PlatformUtils.
- Improved PathResolver with robust subfolder normalization.
- Integrated ComfyUI lifecycle service (Start/Stop/Restart) using ProcessHandle.
- Updated ComfyUI Bridge with improved UI refresh and sync logic.
- Added comprehensive integration and diagnostic tests.
- Updated documentation with process control details.
This commit is contained in:
Thomas Kippster
2026-05-14 16:48:43 +02:00
parent 64dff81e01
commit 3c52a0b520
34 changed files with 2587 additions and 269 deletions
@@ -3,6 +3,7 @@ package de.tki.comfymodels;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.LocalModelScanner;
import de.tki.comfymodels.service.impl.PathResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -26,13 +27,16 @@ class SubfolderRecognitionTest {
private ConfigService configService;
private PathResolver pathResolver;
@BeforeEach
void setUp() {
configService = new ConfigService(null, null) {
pathResolver = new PathResolver();
configService = new ConfigService(null, pathResolver) {
@Override public String getModelsPath() { return tempModels.toString(); }
@Override public String getArchivePath() { return "archive_mock"; }
};
localScanner = new LocalModelScanner(configService);
localScanner = new LocalModelScanner(configService, pathResolver);
}
@Test
@@ -44,7 +48,7 @@ class SubfolderRecognitionTest {
Files.createFile(modelFile);
// Test the generic recursive search method
Optional<Path> found = localScanner.findModelInDirectory(tempModels, "wan2.2_test.safetensors");
Optional<Path> found = localScanner.findModelWithPrefSize(tempModels, "wan2.2_test.safetensors", -1);
assertTrue(found.isPresent(), "Model should be found in deep subfolder");
assertEquals(modelFile.toAbsolutePath(), found.get().toAbsolutePath(), "Found path should match actual file path");
@@ -85,18 +89,18 @@ class SubfolderRecognitionTest {
Files.write(archivedFile, new byte[1234]);
// Mock config to have separate models and archive paths
ConfigService dualConfig = new ConfigService(null, null) {
ConfigService dualConfig = new ConfigService(null, pathResolver) {
@Override public String getModelsPath() { return tempModels.toString(); }
@Override public String getArchivePath() { return archiveDir.toString(); }
};
LocalModelScanner dualScanner = new LocalModelScanner(dualConfig);
LocalModelScanner dualScanner = new LocalModelScanner(dualConfig, pathResolver);
// Scan in models dir - should NOT find it
Optional<Path> foundLocal = dualScanner.findModelInDirectory(tempModels, "exclusive_model.safetensors");
Optional<Path> foundLocal = dualScanner.findModelWithPrefSize(tempModels, "exclusive_model.safetensors", -1);
assertFalse(foundLocal.isPresent(), "Should not find archived model in local models scan");
// Scan in archive dir - SHOULD find it
Optional<Path> foundArchive = dualScanner.findModelInDirectory(archiveDir, "exclusive_model.safetensors");
Optional<Path> foundArchive = dualScanner.findModelWithPrefSize(archiveDir, "exclusive_model.safetensors", -1);
assertTrue(foundArchive.isPresent(), "Should find model in archive scan");
}
}
@@ -0,0 +1,86 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import java.io.File;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration test to verify the full ComfyUI server lifecycle:
* Auto-discovery -> Start -> Reachability Check -> Stop -> Reachability Check.
*/
public class ComfyLifecycleIntegrationTest {
@Test
public void testFullServerLifecycle() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(TestConfig.class);
ConfigService config = context.getBean(ConfigService.class);
IComfyLifecycleService lifecycle = context.getBean(IComfyLifecycleService.class);
System.out.println("\n--- [START] Lifecycle Integration Test ---");
// 1. Discovery Phase
System.out.println("Step 1: Running path discovery...");
// Simulate real environment conditions
config.autoDiscoverPaths();
String root = config.getComfyUIPath();
String command = config.getComfyLaunchCommand();
String models = config.getModelsPath();
System.out.println("Detected Root: " + root);
System.out.println("Detected Command: " + command);
System.out.println("Detected Models: " + models);
assertNotNull(root, "ComfyUI Root should not be null");
assertFalse(root.isEmpty(), "ComfyUI Root should not be empty");
assertTrue(new File(root, "main.py").exists(), "main.py MUST exist in discovered root: " + root);
assertNotNull(command, "Launch command should not be null");
assertFalse(command.isEmpty(), "Launch command should not be empty");
assertNotNull(models, "Models path should not be null");
assertTrue(new File(models).exists(), "Models path MUST exist: " + models);
// 2. Startup Phase
System.out.println("Step 2: Starting server...");
lifecycle.start();
// Wait for server to become healthy (API response)
boolean started = false;
for (int i = 0; i < 30; i++) {
System.out.println("Waiting for health (Attempt " + (i + 1) + "/30)... Status: " + lifecycle.getStatus());
if (lifecycle.isHealthy()) {
started = true;
break;
}
Thread.sleep(2000); // 2s steps for ComfyUI
}
assertTrue(started, "Server should be healthy after start. Current Status: " + lifecycle.getStatus());
assertTrue(lifecycle.isRunning(), "Server should be running according to Lifecycle Service");
// 3. Stop Phase
System.out.println("Step 3: Stopping server...");
lifecycle.stop();
// Wait a bit for port release
Thread.sleep(5000);
assertFalse(lifecycle.isHealthy(), "Server should NO LONGER be healthy after stop");
assertFalse(lifecycle.isRunning(), "Server should NO LONGER be running after stop");
System.out.println("--- [SUCCESS] Lifecycle Integration Test PASSED ---\n");
context.close();
}
@Configuration
@ComponentScan("de.tki.comfymodels")
public static class TestConfig {
// Standard scan picks up all components
}
}
@@ -41,7 +41,7 @@ public class ComprehensiveStatusFlowTest {
configService.setModelsPath(modelsDir.toString());
configService.setArchivePath(archiveDir.toString());
localScanner = new LocalModelScanner(configService);
localScanner = new LocalModelScanner(configService, pathResolver);
archiveService = new ArchiveService(configService, pathResolver);
analyzer = new ComfyModelAnalyzer();
@@ -76,23 +76,23 @@ public class ComprehensiveStatusFlowTest {
Files.write(modelPath, new byte[2000]); // Update to 2KB
assertEquals("✅ Already exists", getStatus(results.get(0), true, false, 2000));
// 5. State: Found in Archive (Removed locally, moved to archive)
// 5. State: Archived (Removed locally, moved to archive)
Files.delete(modelPath);
Path archivePath = archiveDir.resolve("checkpoints").resolve(modelName);
Files.createDirectories(archivePath.getParent());
Files.write(archivePath, new byte[2000]);
assertEquals("📦 Found in Archive", getStatus(results.get(0), false, true, 0));
// 6. State: Already exists (Archived copy found) - Exists in both
assertEquals("📦 Archived", getStatus(results.get(0), false, true, 0));
// 6. State: Already exists (Size match, even if archived)
Files.write(modelPath, new byte[2000]);
assertEquals("✅ Already exists (Archived copy found)", getStatus(results.get(0), true, true, 2000));
}
assertEquals("✅ Already exists", getStatus(results.get(0), true, true, 2000));
}
/**
* Helper to simulate the status logic from Main.java analyzeJsonContent()
*/
private String getStatus(ModelInfo info, boolean existsLocally, boolean existsInArchive, long localSize) {
/**
* Helper to simulate the status logic from Main.java analyzeJsonContent()
*/
private String getStatus(ModelInfo info, boolean existsLocally, boolean existsInArchive, long localSize) {
boolean sizeMismatch = false;
if (existsLocally && info.getByteSize() > 0) {
if (localSize != info.getByteSize()) {
@@ -101,13 +101,13 @@ public class ComprehensiveStatusFlowTest {
}
}
if (existsLocally && existsInArchive) return "✅ Already exists (Archived copy found)";
if (existsLocally) return "✅ Already exists";
if (existsInArchive) return "📦 Found in Archive";
if (existsInArchive) return "📦 Archived";
if (sizeMismatch) return "🔄 Size Mismatch";
if (info.getUrl().equals("MISSING")) return "Idle";
return "✅ Known Good";
}
}
}
}
@@ -0,0 +1,93 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import org.json.JSONObject;
import org.json.JSONArray;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public class DeepModelDiagnosticTest {
@Test
public void inspectNodeOptions() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
String targetModel = "ltx2.3-transition.safetensors";
String url = configService.getComfyUIUrl() + "/object_info";
System.out.println("--- DEEP API INSPECTION ---");
System.out.println("Target: " + targetModel);
System.out.println("Querying: " + url);
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
System.out.println("❌ API Error: " + response.statusCode());
return;
}
JSONObject allNodes = new JSONObject(response.body());
String[] nodesToInspect = {"LoraLoader", "CheckpointLoaderSimple", "CheckpointLoader", "LoraLoaderModelOnly"};
boolean foundInAny = false;
for (String nodeName : nodesToInspect) {
if (allNodes.has(nodeName)) {
System.out.println("\nChecking Node: " + nodeName);
JSONObject node = allNodes.getJSONObject(nodeName);
if (node.has("input")) {
JSONObject input = node.getJSONObject("input");
JSONObject required = input.optJSONObject("required");
if (required != null) {
for (String key : required.keySet()) {
JSONArray param = required.optJSONArray(key);
if (param != null && param.length() > 0 && param.get(0) instanceof JSONArray) {
JSONArray options = param.getJSONArray(0);
System.out.println(" Parameter '" + key + "' has " + options.length() + " options.");
List<String> matches = new ArrayList<>();
for (int i = 0; i < options.length(); i++) {
String opt = options.getString(i);
if (opt.toLowerCase().contains("ltx") || opt.equalsIgnoreCase(targetModel)) {
matches.add(opt);
}
}
if (!matches.isEmpty()) {
System.out.println(" 🎯 POTENTIAL MATCHES FOUND:");
for (String m : matches) {
System.out.println(" - " + m);
if (m.equals(targetModel)) foundInAny = true;
}
} else {
System.out.println(" (No LTX-related models in this list)");
}
}
}
}
}
} else {
System.out.println("\nNode not found: " + nodeName);
}
}
if (foundInAny) {
System.out.println("\n✅ VERIFIED: The model is technically present in the API options.");
System.out.println("If it is missing in the GUI, it might be a caching issue in the browser or a custom node conflict.");
} else {
System.out.println("\n❌ FAILED: The model is NOT present in any examined node parameters.");
}
context.close();
}
}
@@ -0,0 +1,172 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.ArchiveService;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.LocalModelScanner;
import de.tki.comfymodels.service.impl.PathResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class ExtraModelPathsIntegrationTest {
@TempDir
Path tempDir;
private LocalModelScanner scanner;
private ConfigService configService;
private PathResolver pathResolver;
private ArchiveService archiveService;
@BeforeEach
public void setup() {
pathResolver = new PathResolver();
// Custom ConfigService to use our tempDir for everything and avoid complex setup
configService = new ConfigService(null, pathResolver) {
private String comfyPath = "";
private String modelsPath = "";
private String archivePath = "";
@Override public String getComfyUIPath() { return comfyPath; }
@Override public void setComfyUIPath(String path) { this.comfyPath = path; pathResolver.setComfyUIRoot(path); }
@Override public String getModelsPath() { return modelsPath; }
@Override public void setModelsPath(String path) { this.modelsPath = path; }
@Override public String getArchivePath() { return archivePath; }
@Override public void setArchivePath(String path) { this.archivePath = path; }
@Override public String getAppDataPath() { return tempDir.toString(); }
@Override public void save() { /* avoid encryption issues in test */ }
@Override public boolean isUnlocked() { return true; }
};
scanner = new LocalModelScanner(configService, pathResolver);
archiveService = new ArchiveService(configService, pathResolver);
}
@Test
public void testExtraModelPathsDiscovery() throws IOException {
// 1. Setup ComfyUI Root and extra_model_paths.yaml
Path comfyRoot = tempDir.resolve("ComfyUI");
Files.createDirectories(comfyRoot);
configService.setComfyUIPath(comfyRoot.toString());
Path extraModelsDir = tempDir.resolve("ExternalModels");
Path checkpointsDir = extraModelsDir.resolve("my_checkpoints");
Files.createDirectories(checkpointsDir);
// Create a model file in the extra path
Path modelFile = checkpointsDir.resolve("extra_model.safetensors");
Files.writeString(modelFile, "dummy");
// Create the YAML
String yamlContent =
"my_extra_path:\n" +
" base_path: " + extraModelsDir.toAbsolutePath().toString().replace("\\", "/") + "\n" +
" checkpoints: my_checkpoints\n";
Files.writeString(comfyRoot.resolve("extra_model_paths.yaml"), yamlContent);
// 2. Load the extra paths
configService.loadExtraModelPaths();
// 3. Verify PathResolver knows about it
List<Path> paths = pathResolver.getModelPaths("checkpoints");
assertFalse(paths.isEmpty(), "Should have found extra checkpoints path");
assertTrue(paths.stream().anyMatch(p -> p.toAbsolutePath().equals(checkpointsDir.toAbsolutePath())), "Should contain the correct checkpoints dir");
// 4. Run Scanner and verify discovery
List<ModelInfo> results = scanner.scanLocalModels();
boolean found = results.stream().anyMatch(m -> m.getName().equals("extra_model.safetensors"));
assertTrue(found, "Scanner should find model in extra path");
ModelInfo info = results.stream().filter(m -> m.getName().equals("extra_model.safetensors")).findFirst().get();
assertEquals("checkpoints", info.getType(), "Should correctly identify as checkpoint");
}
@Test
public void testArchiveRestorationToExtraPath() throws IOException {
// 1. Setup ComfyUI Root and extra_model_paths.yaml
Path comfyRoot = tempDir.resolve("ComfyUI");
Files.createDirectories(comfyRoot);
configService.setComfyUIPath(comfyRoot.toString());
Path extraModelsDir = tempDir.resolve("ExternalModels");
Path checkpointsDir = extraModelsDir.resolve("my_checkpoints");
Files.createDirectories(checkpointsDir);
// Create the YAML mapping checkpoints to ExternalModels/my_checkpoints
String yamlContent =
"my_extra_path:\n" +
" base_path: " + extraModelsDir.toAbsolutePath().toString().replace("\\", "/") + "\n" +
" checkpoints: my_checkpoints\n";
Files.writeString(comfyRoot.resolve("extra_model_paths.yaml"), yamlContent);
configService.loadExtraModelPaths();
// 2. Setup Archive
Path archiveRoot = tempDir.resolve("Archive");
Files.createDirectories(archiveRoot);
configService.setArchivePath(archiveRoot.toString());
// Put a model in the archive structure
Path archivedModel = archiveRoot.resolve("checkpoints").resolve("archived_extra.safetensors");
Files.createDirectories(archivedModel.getParent());
Files.writeString(archivedModel, "dummy model data");
// 3. Run restoration for "checkpoints" folder
boolean success = archiveService.restoreFromArchiveWithProgress("checkpoints", "archived_extra.safetensors", null);
assertTrue(success, "Restoration should be successful");
// 4. VERIFY: Should be in checkpointsDir (extra path)
Path expectedTarget = checkpointsDir.resolve("archived_extra.safetensors");
assertTrue(Files.exists(expectedTarget), "Model should be restored to the extra checkpoints path: " + expectedTarget);
assertFalse(Files.exists(archivedModel), "Source should be deleted from archive after successful move");
}
@Test
public void testArchiveRestorationToNestedExtraPath() throws IOException {
// Setup same as above but with nested folder
Path comfyRoot = tempDir.resolve("ComfyUI");
Files.createDirectories(comfyRoot);
configService.setComfyUIPath(comfyRoot.toString());
Path extraModelsDir = tempDir.resolve("ExternalModels");
Path checkpointsDir = extraModelsDir.resolve("checkpoints");
Path sd15Dir = checkpointsDir.resolve("SD15");
Files.createDirectories(sd15Dir);
String yamlContent =
"my_extra_path:\n" +
" base_path: " + extraModelsDir.toAbsolutePath().toString().replace("\\", "/") + "\n" +
" checkpoints: checkpoints\n";
Files.writeString(comfyRoot.resolve("extra_model_paths.yaml"), yamlContent);
configService.loadExtraModelPaths();
// Setup Archive
Path archiveRoot = tempDir.resolve("Archive");
Files.createDirectories(archiveRoot);
configService.setArchivePath(archiveRoot.toString());
// Model in archive: checkpoints/SD15/nested.safetensors
Path archivedModel = archiveRoot.resolve("checkpoints/SD15/nested.safetensors");
Files.createDirectories(archivedModel.getParent());
Files.writeString(archivedModel, "nested data");
// Run restoration for "checkpoints/SD15"
boolean success = archiveService.restoreFromArchiveWithProgress("checkpoints/SD15", "nested.safetensors", null);
assertTrue(success, "Nested restoration should be successful");
// VERIFY: Should be in sd15Dir (extra path + subfolder)
Path expectedTarget = sd15Dir.resolve("nested.safetensors");
assertTrue(Files.exists(expectedTarget), "Model should be restored to the nested extra path: " + expectedTarget);
}
}
@@ -0,0 +1,57 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
public class FinalDampfhammerTest {
@Test
public void executeRestartAndVerify() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyLifecycleService lifecycleService = context.getBean(ComfyLifecycleService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
System.out.println("--- 🔨 EXECUTING FINAL DAMPFHAMMER RESTART ---");
// 1. Force Kill
System.out.println("⏹ Terminating existing Python processes...");
Runtime.getRuntime().exec("taskkill /F /IM python.exe /T").waitFor();
Thread.sleep(2000);
// 2. Start via Service
System.out.println("▶ Starting ComfyUI...");
lifecycleService.start();
// 3. Robust Health Check (Wait up to 90 seconds)
System.out.println("⏳ Waiting for API (http://127.0.0.1:8000)...");
boolean ready = false;
for (int i = 0; i < 90; i++) {
if (lifecycleService.isHealthy()) {
ready = true;
break;
}
if (i % 10 == 0) System.out.println("Still waiting... (" + i + "s)");
Thread.sleep(1000);
}
if (ready) {
System.out.println("✅ ComfyUI is ONLINE and HEALTHY.");
// 4. Final verification of the specific model
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("🎯 TARGET ACHIEVED: '" + model + "' is recognized by ComfyUI!");
} else {
System.out.println("❌ Model still missing. This implies the file is not in 'models/loras' or extra_model_paths is misconfigured.");
}
} else {
System.out.println("❌ ComfyUI failed to respond. Please check your console/logs.");
}
context.close();
}
}
@@ -25,14 +25,15 @@ public class LocalModelScannerTest {
@BeforeEach
public void setup() {
PathResolver pathResolver = new PathResolver();
// Use a simple stub for ConfigService to avoid Mockito issues on Java 25
configService = new ConfigService(null, new PathResolver()) {
configService = new ConfigService(null, pathResolver) {
@Override
public String getModelsPath() {
return tempDir.toString();
}
};
scanner = new LocalModelScanner(configService);
scanner = new LocalModelScanner(configService, pathResolver);
}
@Test
@@ -0,0 +1,60 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class ModelPresenceVerificationTest {
@Test
public void verifyModelPresence() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
String modelName = "ltx2.3-transition.safetensors";
String type = "loras";
System.out.println("--- Starting Automated Refresh Loop for: " + modelName + " ---");
boolean found = false;
for (int i = 1; i <= 5; i++) {
System.out.println("Attempt " + i + ": Triggering Refresh...");
// Trigger Refresh
try {
HttpRequest refreshReq = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
client.send(refreshReq, HttpResponse.BodyHandlers.ofString());
} catch (Exception e) {
System.out.println("Refresh ping failed (Bridge maybe not installed?): " + e.getMessage());
}
// Wait for processing
Thread.sleep(3000);
// Check Presence
if (diagnosticService.isModelAvailableInComfy(modelName, type)) {
System.out.println("✅ SUCCESS: Model '" + modelName + "' is now visible in ComfyUI!");
found = true;
break;
} else {
System.out.println("❌ Model not found yet.");
}
}
if (!found) {
System.out.println("⚠️ FAILURE: Model still not visible after 5 attempts.");
}
context.close();
}
}
@@ -0,0 +1,139 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.Main;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.*;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mockito;
import javax.swing.*;
import javax.swing.table.DefaultTableModel;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
public class ModelStatusLogicIntegrationTest {
@TempDir
Path tempDir;
private Main main;
private ConfigService configService;
private PathResolver pathResolver;
private ArchiveService archiveService;
private LocalModelScanner localScanner;
private ComfyModelAnalyzer analyzer;
@BeforeEach
public void setup() throws Exception {
pathResolver = new PathResolver();
configService = new ConfigService(null, pathResolver) {
private String modelsPath = "";
private String archivePath = "";
@Override public String getModelsPath() { return modelsPath; }
@Override public void setModelsPath(String path) { this.modelsPath = path; }
@Override public String getArchivePath() { return archivePath; }
@Override public void setArchivePath(String path) { this.archivePath = path; }
@Override public String getAppDataPath() { return tempDir.toString(); }
@Override public boolean isUnlocked() { return true; }
@Override public void save() {}
};
analyzer = Mockito.mock(ComfyModelAnalyzer.class);
localScanner = new LocalModelScanner(configService, pathResolver);
archiveService = new ArchiveService(configService, pathResolver);
main = new Main(
analyzer,
Mockito.mock(DefaultDownloadManager.class),
Mockito.mock(WorkflowService.class),
Mockito.mock(ModelSearchService.class),
Mockito.mock(ModelValidator.class),
Mockito.mock(RestBridgeService.class),
archiveService,
Mockito.mock(ComfyLifecycleService.class),
Mockito.mock(ComfyDiagnosticService.class)
);
// Inject dependencies via reflection since they are @Autowired in Main
setField(main, "configService", configService);
setField(main, "localScanner", localScanner);
setField(main, "pathResolver", pathResolver);
setField(main, "archiveService", archiveService);
// Initialize enough UI for analyzeJsonContent to work
setField(main, "tableModel", new DefaultTableModel(new String[]{"S", "T", "N", "Sz", "Src", "P", "U", "Status"}, 0));
JTextArea area = new JTextArea("dummy workflow");
setField(main, "jsonInputArea", area);
setField(main, "statusLabel", new JLabel());
setField(main, "downloadButton", new JButton());
}
private void setField(Object target, String name, Object value) throws Exception {
Field f = target.getClass().getDeclaredField(name);
f.setAccessible(true);
f.set(target, value);
}
@Test
public void testStatusTransitions() throws IOException, Exception {
Path modelsDir = tempDir.resolve("models");
Files.createDirectories(modelsDir.resolve("checkpoints"));
configService.setModelsPath(modelsDir.toString());
Path archiveDir = tempDir.resolve("archive");
Files.createDirectories(archiveDir.resolve("checkpoints"));
configService.setArchivePath(archiveDir.toString());
// Prepare 3 scenarios
List<ModelInfo> models = new ArrayList<>();
models.add(new ModelInfo("checkpoints", "local_only.safetensors", "COMMUNITY"));
models.get(0).setUrl("http://example.com/1");
models.add(new ModelInfo("checkpoints", "archived_only.safetensors", "COMMUNITY"));
models.get(1).setUrl("http://example.com/2");
models.add(new ModelInfo("checkpoints", "both_locations.safetensors", "COMMUNITY"));
models.get(2).setUrl("http://example.com/3");
models.add(new ModelInfo("checkpoints", "new_model.safetensors", "COMMUNITY"));
models.get(3).setUrl("http://example.com/4");
when(analyzer.analyze(anyString(), anyString())).thenReturn(models);
// Create the files
Files.writeString(modelsDir.resolve("checkpoints/local_only.safetensors"), "data");
Files.writeString(archiveDir.resolve("checkpoints/archived_only.safetensors"), "data");
Files.writeString(modelsDir.resolve("checkpoints/both_locations.safetensors"), "data");
Files.writeString(archiveDir.resolve("checkpoints/both_locations.safetensors"), "data");
// Execute analysis
java.lang.reflect.Method method = main.getClass().getDeclaredMethod("analyzeJsonContent");
method.setAccessible(true);
method.invoke(main);
DefaultTableModel model = (DefaultTableModel) getField(main, "tableModel");
// Verify Statuses
assertEquals("✅ Already exists", model.getValueAt(0, 7), "Should be local only");
assertEquals("📦 Archived", model.getValueAt(1, 7), "Should be in archive only");
assertEquals("✅ Already exists", model.getValueAt(2, 7), "Should prioritize local over archive (obsolete status removed)");
assertEquals("✅ Known Good", model.getValueAt(3, 7), "Should be new/downloadable");
}
private Object getField(Object target, String name) throws Exception {
Field f = target.getClass().getDeclaredField(name);
f.setAccessible(true);
return f.get(target);
}
}
@@ -0,0 +1,70 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.io.InputStream;
public class NuclearFrontendRefreshTest {
@Test
public void triggerReload() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().build();
System.out.println("--- ☢️ TRIGGERING NUCLEAR FRONTEND REFRESH ---");
// 1. Sync Bridge to Disk (Ensure latest JS/Python is there)
String comfyPath = "C:\\AI\\ComfyUI\\resources\\ComfyUI";
Path bridgeDir = Paths.get(comfyPath, "custom_nodes", "comfyui-model-downloader");
System.out.println("📦 Syncing Bridge to: " + bridgeDir);
Files.createDirectories(bridgeDir.resolve("web"));
copyResource("/comfyui-bridge/__init__.py", bridgeDir.resolve("__init__.py"));
copyResource("/comfyui-bridge/web/downloader.js", bridgeDir.resolve("web/downloader.js"));
Files.writeString(bridgeDir.resolve("web/config.json"), "{\"token\": \"" + configService.getApiToken() + "\"}");
// 2. Send the "Nuclear" REST Call
System.out.println("🚀 Sending REST signal with 'force_reload: true'...");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"force_reload\": true}"))
.build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("📡 Server Response: " + response.statusCode() + " " + response.body());
} catch (Exception e) {
System.out.println("❌ Failed to reach ComfyUI: " + e.getMessage());
}
// 3. Verify Backend
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("✅ BACKEND VERIFIED: '" + model + "' is present in the API.");
} else {
System.out.println("❌ BACKEND FAILED: Model still not in API.");
}
context.close();
}
private void copyResource(String name, Path target) throws Exception {
try (InputStream is = getClass().getResourceAsStream(name)) {
Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,88 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import de.tki.comfymodels.Main;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
public class OneClickFixTest {
@Test
public void performNuclearFix() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
ComfyLifecycleService lifecycleService = context.getBean(ComfyLifecycleService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
System.out.println("--- ☢️ NUCLEAR ONE-CLICK FIX STARTING ---");
// 1. Reinstall Bridge to the correct location
String comfyPath = "C:\\AI\\ComfyUI\\resources\\ComfyUI";
Path bridgeDir = Paths.get(comfyPath, "custom_nodes", "comfyui-model-downloader");
System.out.println("📦 Reinstalling Bridge to: " + bridgeDir);
Files.createDirectories(bridgeDir);
Files.createDirectories(bridgeDir.resolve("web"));
copyResource("/comfyui-bridge/__init__.py", bridgeDir.resolve("__init__.py"));
copyResource("/comfyui-bridge/web/downloader.js", bridgeDir.resolve("web/downloader.js"));
String configJson = "{\"token\": \"" + configService.getApiToken() + "\"}";
Files.writeString(bridgeDir.resolve("web/config.json"), configJson);
System.out.println("✅ Bridge reinstalled.");
// 2. Kill existing process
System.out.println("⏹ Stopping ComfyUI...");
lifecycleService.stop();
// Fallback kill for PID 14544 just in case
Runtime.getRuntime().exec("taskkill /F /PID 14544").waitFor();
System.out.println("✅ ComfyUI stopped.");
// 3. Start via Lifecycle Service
System.out.println("▶ Starting ComfyUI...");
lifecycleService.start();
// 4. Wait for Health
System.out.println("⏳ Waiting for API to become healthy...");
boolean healthy = false;
for (int i = 0; i < 60; i++) {
if (lifecycleService.isHealthy()) {
healthy = true;
break;
}
Thread.sleep(1000);
}
if (healthy) {
System.out.println("✅ ComfyUI is back online!");
// 5. Final Diagnostic
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("🎯 TARGET VERIFIED: '" + model + "' is now fully recognized!");
} else {
System.out.println("❌ Diagnostic failed even after restart. Please check if the file is in 'models/loras'.");
}
} else {
System.out.println("❌ ComfyUI failed to start within 60 seconds.");
}
context.close();
}
private void copyResource(String name, Path target) throws Exception {
try (InputStream is = getClass().getResourceAsStream(name)) {
Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
@@ -0,0 +1,33 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import de.tki.comfymodels.service.impl.ConfigService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import static org.junit.jupiter.api.Assertions.*;
/**
* Rapid Diagnostic Test for Refresh Mechanisms.
* This test uses the ComfyDiagnosticService to verify if ComfyUI actually sees
* the models after our refresh calls.
*/
public class RefreshDiagnosticTest {
@Test
public void testModelVisibility() {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
System.out.println("--- ComfyUI Diagnostic Scan ---");
boolean reachable = diagnosticService.isModelAvailableInComfy("non_existent_model_xyz", "checkpoint");
// If the API is up, it should at least return false for a non-existent model
// If it throws an exception or timeout, the API is down.
System.out.println("API Reachable: " + !reachable);
context.close();
}
}
@@ -3,6 +3,7 @@ package de.tki.comfymodels.service;
import de.tki.comfymodels.domain.ModelInfo;
import de.tki.comfymodels.service.impl.ArchiveService;
import de.tki.comfymodels.service.impl.LocalModelScanner;
import de.tki.comfymodels.service.impl.PathResolver;
import de.tki.comfymodels.service.impl.RestBridgeService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -31,7 +32,7 @@ class RestBridgeServiceTest {
private final String testToken = "test-token";
private static class LocalModelScannerStub extends LocalModelScanner {
public LocalModelScannerStub() { super(null); }
public LocalModelScannerStub() { super(null, new PathResolver()); }
@Override
public java.util.List<ModelInfo> scanLocalModels() {
return Collections.singletonList(new ModelInfo("checkpoints", "local_test.safetensors", "LOCAL"));
@@ -28,9 +28,9 @@ public class RestorationStatusTest {
private Path tempArchiveDir;
// Status Constants (as used in Main.java)
public static final String STATUS_FOUND_IN_ARCHIVE = "📦 Found in Archive";
public static final String STATUS_ARCHIVED = "📦 Archived";
public static final String STATUS_RESTORING = "📦 Restoring...";
public static final String STATUS_RESTORED = "✅ Restored";
public static final String STATUS_RESTORED = "✅ Already exists";
public static final String STATUS_RESTORE_FAILED = "❌ Restore Failed";
@BeforeEach
@@ -76,8 +76,8 @@ public class RestorationStatusTest {
List<String> statusSequence = new ArrayList<>();
// Initial state (determined by UI/Logic)
statusSequence.add(STATUS_FOUND_IN_ARCHIVE);
assertEquals(STATUS_FOUND_IN_ARCHIVE, statusSequence.get(0));
statusSequence.add(STATUS_ARCHIVED);
assertEquals(STATUS_ARCHIVED, statusSequence.get(0));
// 3. Act: Start Restoration
statusSequence.add(STATUS_RESTORING);
@@ -109,7 +109,7 @@ public class RestorationStatusTest {
// 2. Define transitions tracker
List<String> statusSequence = new ArrayList<>();
statusSequence.add(STATUS_FOUND_IN_ARCHIVE);
statusSequence.add(STATUS_ARCHIVED);
statusSequence.add(STATUS_RESTORING);
// 3. Act: Attempt Restoration
@@ -0,0 +1,48 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class StabilityValidationTest {
@Test
public void validateStability() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
System.out.println("--- 🛡️ STARTING STABILITY VALIDATION ---");
for (int i = 1; i <= 3; i++) {
System.out.println("Cycle " + i + ": Triggering Refresh...");
HttpRequest refreshReq = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"force_reload\": false}"))
.build();
HttpResponse<String> response = client.send(refreshReq, HttpResponse.BodyHandlers.ofString());
System.out.println("📡 Server Response: " + response.statusCode());
Thread.sleep(2000);
// Check if API is still alive
if (diagnosticService.isModelAvailableInComfy("ltx2.3-transition.safetensors", "loras")) {
System.out.println("✅ Cycle " + i + ": Server is healthy and model is visible.");
} else {
System.out.println("❌ Cycle " + i + ": Model visibility check failed.");
}
}
System.out.println("--- 🛡️ STABILITY VALIDATION COMPLETE ---");
context.close();
}
}
@@ -0,0 +1,86 @@
package de.tki.comfymodels.service;
import de.tki.comfymodels.service.impl.ConfigService;
import de.tki.comfymodels.service.impl.ComfyLifecycleService;
import de.tki.comfymodels.service.impl.ComfyDiagnosticService;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import de.tki.comfymodels.Main.AppConfig;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.io.InputStream;
public class UltimateGuiSyncTest {
@Test
public void ensureGuiSyncs() throws Exception {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
ConfigService configService = context.getBean(ConfigService.class);
ComfyLifecycleService lifecycleService = context.getBean(ComfyLifecycleService.class);
ComfyDiagnosticService diagnosticService = context.getBean(ComfyDiagnosticService.class);
HttpClient client = HttpClient.newBuilder().build();
System.out.println("--- 🚀 ULTIMATE GUI SYNC SEQUENCE ---");
// 1. HARD REINSTALL OF BRIDGE FILES
String comfyPath = "C:\\AI\\ComfyUI\\resources\\ComfyUI";
Path bridgeDir = Paths.get(comfyPath, "custom_nodes", "comfyui-model-downloader");
System.out.println("📦 Enforcing Latest Bridge in: " + bridgeDir);
Files.createDirectories(bridgeDir.resolve("web"));
copyResource("/comfyui-bridge/__init__.py", bridgeDir.resolve("__init__.py"));
copyResource("/comfyui-bridge/web/downloader.js", bridgeDir.resolve("web/downloader.js"));
Files.writeString(bridgeDir.resolve("web/config.json"), "{\"token\": \"" + configService.getApiToken() + "\"}");
// 2. FORCE RESTART BACKEND (To load new Python bridge)
System.out.println("⏹ Terminating ComfyUI for Clean State...");
Runtime.getRuntime().exec("taskkill /F /IM python.exe /T").waitFor();
Thread.sleep(2000);
System.out.println("▶ Starting ComfyUI...");
lifecycleService.start();
// 3. WAIT FOR BACKEND READINESS
System.out.println("⏳ Waiting for API...");
for (int i = 0; i < 90; i++) {
if (lifecycleService.isHealthy()) break;
Thread.sleep(1000);
}
// 4. TRIGGER NUCLEAR FRONTEND RELOAD
// This will force every open browser tab to run window.location.reload()
System.out.println("☢️ Triggering Nuclear Browser Reload...");
HttpRequest reloadReq = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:8000/cmfd/refresh-models"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"force_reload\": true}"))
.build();
try {
client.send(reloadReq, HttpResponse.BodyHandlers.ofString());
System.out.println("✅ Reload signal broadcasted to all tabs.");
} catch (Exception ignored) {}
// 5. FINAL VERIFICATION
String model = "ltx2.3-transition.safetensors";
if (diagnosticService.isModelAvailableInComfy(model, "loras")) {
System.out.println("🎯 TARGET ACHIEVED: Backend is synchronized.");
System.out.println("Check your browser now. All tabs should have reloaded and the model should be visible!");
} else {
System.out.println("❌ Critical Failure: Model still missing from API.");
}
context.close();
}
private void copyResource(String name, Path target) throws Exception {
try (InputStream is = getClass().getResourceAsStream(name)) {
Files.copy(is, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}