diff --git a/plan-extended-anaylis/PLAN.md b/plan-extended-anaylis/PLAN.md index 6eb87ac..c73cb35 100644 --- a/plan-extended-anaylis/PLAN.md +++ b/plan-extended-anaylis/PLAN.md @@ -39,3 +39,25 @@ - [ ] Add support for `@Value` tracking in fields and constructors. - [ ] Implement constant resolution for cross-class references in annotations. - [ ] Integrate value propagation into the call graph analysis (linking injected values to `sendEvent` arguments). + +## Phase 8: Advanced String Expression Resolution +- [ ] Implement `ValueResolver` to handle string concatenations and variable references. +- [ ] Support cross-class constant lookup using JDT. +- [ ] Update all Enrichers (MVC, Rabbit, JMS) to use `ValueResolver` for metadata extraction. +- [ ] Verify with complex path patterns in the integration test suite. + +## Phase 9: Instance Identification & Persistence Mapping +- [ ] Implement `InstanceIdentifier` to track State Machine IDs (via `@Qualifier`, bean names, or factories). +- [ ] Detect persistence restoration logic (`persister.restore`). +- [ ] Update all Enrichers to include `stateMachineId` and `isRestoredFromPersistence` in metadata. +- [ ] Verify multi-SM identification in the integration test suite. + +## Phase 10: Incoming Payload Analysis +- [ ] Implement payload type extraction for REST and Message entry points. +- [ ] Detect usage of payload fields in `sendEvent` or `restore` calls. +- [ ] Add `payloadType` to `EntryPoint` and `TriggerPoint` models. + +## Phase 11: Interceptor & Filter Mapping +- [ ] Implement detection of `HandlerInterceptor` and `Filter` implementations. +- [ ] Map interceptors to endpoints using `WebMvcConfigurer` analysis. +- [ ] Provide "Interceptor Context" metadata for each mapped endpoint. diff --git a/plan-extended-anaylis/advanced_context.md b/plan-extended-anaylis/advanced_context.md new file mode 100644 index 0000000..7e9fb75 --- /dev/null +++ b/plan-extended-anaylis/advanced_context.md @@ -0,0 +1,42 @@ +# Advanced Context Extraction: Payloads and Interceptors + +## 1. Incoming Payload Analysis +To understand *what* data drives the state machine, we need to capture metadata about the incoming parameters at entry points. + +### REST Endpoints +For methods annotated with `@PostMapping`, `@PutMapping`, etc.: +1. **Extract Parameters**: Scan method parameters for `@RequestBody`, `@PathVariable`, or `@RequestParam`. +2. **Type Extraction**: Capture the FQN of the parameter type (e.g., `com.example.OrderRequest`). +3. **Data Linkage**: If a field from this payload is used in a `sendEvent(payload.getEvent())` call, mark the transition as "Dynamic based on Payload". + +### Message Listeners +For `@RabbitListener` or `@JmsListener`: +1. Identify the parameter receiving the message body. +2. Extract its type and any `@Header` or `@Payload` annotations. + +## 2. Interceptor and Filter Analysis +Interceptors often perform cross-cutting logic (authentication, context injection) that affects how state machines are driven. + +### Discovery Strategy +1. **Find Interceptors**: Scan for classes implementing `HandlerInterceptor` or `WebRequestInterceptor`. +2. **Registration Mapping**: Look for `WebMvcConfigurer.addInterceptors()` calls to identify which URL patterns each interceptor applies to. +3. **Context Injection**: Analyze if the interceptor modifies the request (e.g., `request.setAttribute("userContext", ...)`) or populates a `ThreadLocal` (like `SecurityContextHolder`). + +### Linking to State Machines +If a controller uses a value from a known Interceptor-injected context to decide which event to send or which state machine to restore, we can link the Interceptor's logic to the State Machine flow. + +## 3. Implementation in `EnrichmentService` +We will introduce a `ContextEnricher` that: +1. Builds a map of **Active Interceptors** per path. +2. Augments `EntryPoint` metadata with **Payload Type** information. + +## 4. Modeling Updates +Update `EntryPoint` and `TriggerPoint` to include `payloadType` and `interceptors`. + +```java +public record EntryPoint( + // ... existing fields ... + String payloadType, + List activeInterceptors +) {} +``` diff --git a/plan-extended-anaylis/architecture.md b/plan-extended-anaylis/architecture.md index 014ae53..a76a0d7 100644 --- a/plan-extended-anaylis/architecture.md +++ b/plan-extended-anaylis/architecture.md @@ -39,6 +39,13 @@ The State Machine Exporter now becomes a "Consumer" of intelligence. - **Maintainability**: Adding support for a new framework (e.g., Micronaut) only requires updating the `IntelligenceProvider`, not touching the core state machine logic. - **Performance**: We can run the SM Parser and the Intelligence Scanner in parallel since they are now independent. +## 5. Monorepo and Multi-Module Support +Applications are often split into multiple modules (e.g., `core`, `api`, `workers`). +- **Workspace Scanning**: The analyzer should treat the entire monorepo as a single codebase context. +- **Source Tracking**: Each metadata item (`TriggerPoint`, `EntryPoint`) includes a `sourceModule` identifier to show exactly where it was found. +- **Cross-Module Resolution**: Properties defined in one module's `application.yml` and used in another should be resolved globally. +- **Internal Dependency Following**: If the analyzer finds a call to a method in another module, it should continue the call-chain analysis into that module's source. + ## Challenges - **Multiple State Machines**: How to know which `StateMachine` instance is being used? - Initial heuristic: If there's only one, assume it's that one. diff --git a/plan-extended-anaylis/instance_identification.md b/plan-extended-anaylis/instance_identification.md new file mode 100644 index 0000000..65aae96 --- /dev/null +++ b/plan-extended-anaylis/instance_identification.md @@ -0,0 +1,73 @@ +# State Machine Instance Identification & Persistence Mapping + +## 1. The Multi-SM Problem +In large applications, multiple State Machines often coexist. A "Trigger Point" (like a REST Controller) must be linked to the correct State Machine definition. + +**Common Identification Patterns**: +- **Unique Types**: `StateMachine` vs `StateMachine`. +- **Bean Qualifiers**: `@Qualifier("orderStateMachine")`. +- **Factory IDs**: `factory.getStateMachine("order-123")`. +- **Persistence Restoration**: `persister.restore(sm, orderId)`. + +## 2. Analysis Strategy: "Instance Tracking" + +### 1. Definition Discovery (Existing) +Identify all configurations (classes with `@EnableStateMachineFactory` or `@EnableStateMachine`). Store their **Bean Names** and **Generic Types**. + +### 2. Dependency Analysis +When a class (e.g., `OrderController`) uses a state machine: +1. **Identify the Field/Parameter**: Look for `StateMachine`. +2. **Resolve Generic Types**: Match `S` and `E` against known SM definitions. +3. **Resolve Qualifiers**: Check for `@Qualifier` or variable names that match a SM bean name. + +### 3. Loading & Persistence Analysis +A dedicated "Loading Detector" will look for persistence logic. + +**Pattern: Persister Restore** +```java +persister.restore(stateMachine, id); +stateMachine.sendEvent(E); +``` +**Static Strategy**: +- Find calls to `Persister.restore(sm, ...)` or `PersistStateChangeListener`. +- Link the variable `sm` to the restoration event. +- Mark the `TriggerPoint` as "Restored from Persistence". + +**Pattern: Factory Creation** +```java +StateMachine sm = factory.getStateMachine(smId); +``` +**Static Strategy**: +- Find `factory.getStateMachine(...)`. +- If the argument is a literal (e.g., `"order"`), map it to the SM definition named "order". + +## 3. Implementation: `InstanceIdentifier` +We will introduce an `InstanceIdentifier` that works alongside the `ValueResolver`. + +```java +public class InstanceIdentifier { + public StateMachineReference identify(VariableDeclaration var, CodebaseContext context) { + // 1. Check type generics + // 2. Check @Qualifier + // 3. Trace back to factory or persister calls + } +} +``` + +## 4. Modeling in `AnalysisResult` +The `TriggerPoint` will be enhanced with a `stateMachineId` or `configFqn` field. + +```java +public record TriggerPoint( + String className, + String methodName, + String event, + String stateMachineId, // Links back to the specific SM + boolean isRestoredFromPersistence, + Map metadata +) {} +``` + +## 5. Challenges +- **Generic Controllers**: A single base controller that handles multiple SMs via generics. We might need to report "Multiple Potential SMs". +- **Dynamic Factory IDs**: `factory.getStateMachine(payload.getType())`. Hard to resolve statically without data flow analysis. diff --git a/plan-extended-anaylis/polyglot_support.md b/plan-extended-anaylis/polyglot_support.md new file mode 100644 index 0000000..ae73120 --- /dev/null +++ b/plan-extended-anaylis/polyglot_support.md @@ -0,0 +1,55 @@ +# Polyglot Analysis: Supporting Mixed Java and Kotlin + +## 1. The Need for Language Abstraction +Professional JVM codebases are increasingly "Mixed Mode" (Java + Kotlin). Tying our analysis logic directly to JDT (Java AST) creates a bottleneck for Kotlin support. + +## 2. The "Driver" Architecture +We decouple the **Intelligence Logic** (e.g., "Find all REST controllers") from the **AST Parser** (e.g., "Walk the JDT tree"). + +### 1. Language Driver Interface +```java +public interface LanguageDriver { + boolean supports(Path filePath); + + // Unified Extraction Methods + List extractClasses(Path filePath); + List extractMethods(RawClassMetadata cls); + List findInvocations(RawMethodMetadata method, String targetMethodName); +} +``` + +### 2. Unified Metadata Model (Intermediate Representation) +We move to a "Generic JVM Model" before performing the final state machine mapping. + +```java +public record RawClassMetadata( + String fqn, + String language, // "java", "kotlin" + List annotations, + String superclass, + List interfaces +) {} + +public record RawMethodMetadata( + String name, + List annotations, + String returnType, + List parameterTypes +) {} +``` + +## 3. Polyglot Analysis Flow +1. **File Discovery**: Find all `.java` and `.kt` files. +2. **Driver Assignment**: Delegate each file to the appropriate `LanguageDriver`. +3. **Cross-Language Resolution**: The `CodebaseContext` stores metadata for ALL classes. + - A Java controller calling a Kotlin service is resolved by looking up the Kotlin class's unified metadata. +4. **Enricher Execution**: Enrichers (like `SpringMvcEnricher`) now operate on the **Unified Metadata Model**, making them language-agnostic! + +## 4. Why this is "Future Proof": +- **Enrichers are written once**: The logic to find `@PostMapping` only needs to know how to query the `RawClassMetadata` annotations, not how to traverse JDT vs. Kotlin PSI. +- **Easy Expansion**: To support Scala, Groovy, or even a newer Java version, you just add a new `LanguageDriver`. + +## 5. Refactoring Plan +1. **Define Intermediate Models**: Create `RawClassMetadata` and `RawMethodMetadata`. +2. **Extract JDT Logic**: Move the current JDT-specific code into `JavaLanguageDriver`. +3. **Kotlin PoC (Phase 12)**: Implement a basic `KotlinLanguageDriver` using Regex or a lightweight parser (like Tree-Sitter) to prove the abstraction works. diff --git a/plan-extended-anaylis/property_resolution.md b/plan-extended-anaylis/property_resolution.md index d0d123e..1985818 100644 --- a/plan-extended-anaylis/property_resolution.md +++ b/plan-extended-anaylis/property_resolution.md @@ -38,21 +38,30 @@ public class AppProps { 2. Index their fields with the prefix (e.g., `app.messaging.queueName`). 3. When these beans are injected into a service, link the service's usage to the resolved property value. -## 3. Property Resolver (The "Config Scanner") -A component dedicated to building a global map of available properties. +## 3. Profile-Aware Property Resolver +A component dedicated to building a multi-dimensional map of available properties. -1. **File Scanning**: Parse `application.properties`, `application.yml`, and `bootstrap.yml`. -2. **Profile Support**: Handle `application-{profile}.yml` if a profile is active. -3. **Property Map**: Create a `Map` of all key-value pairs. +1. **File Scanning**: Parse `application.properties`, `application.yml`, and `bootstrap.yml`. +2. **Profile Identification**: + - From filenames: `application-{profile}.properties`. + - From YAML documents: `spring.config.activate.on-profile` (Spring Boot 2.4+). +3. **The Multi-Profile Map**: + - Instead of one map, we store: `Map>`. + - The "default" profile is the base. -## 4. Value Propagation (Data Flow) -Once a value is resolved or its placeholder is identified, we track its usage. +## 4. Smart Value Propagation +When a trigger uses a property, we want to know it's profile-dependent. **Example Trace**: -1. `application.yml` -> `app.queue: "orders"` -2. `OrderService` -> `@Value("${app.queue}") String q;` -> `this.queue = q;` -3. `OrderService.send()` -> `sm.sendEvent(this.queue);` -4. **Resolution**: The "Event" for this trigger is "orders". +1. `application.yml` -> `app.queue: "orders"` +2. `application-prod.yml` -> `app.queue: "orders-prod"` +3. `TriggerPoint` stores: + - `placeholder: "${app.queue}"` + - `defaultResolvedValue: "orders"` +4. **Rendering Strategy**: The renderer can show "orders" by default, but provide a "Profile Toggle" to switch to "prod" and see the labels update to "orders-prod". + +## 5. Metadata Retention +We should never "squash" profiles during analysis. The `CodebaseMetadata` will carry the full profile matrix to the exporter. ## 5. SpEL (Spring Expression Language) Lite Full SpEL support is hard for static analysis, but we can support "Common Patterns": diff --git a/plan-extended-anaylis/string_resolution.md b/plan-extended-anaylis/string_resolution.md new file mode 100644 index 0000000..b113e84 --- /dev/null +++ b/plan-extended-anaylis/string_resolution.md @@ -0,0 +1,53 @@ +# Advanced String and Variable Resolution Strategy + +## 1. The Problem +Annotations often use non-literal values for metadata: +- **Constants**: `@PostMapping(ApiConstants.SUBMIT_PATH)` +- **Concatenation**: `@RequestMapping(BASE + "/orders")` +- **Inherited Variables**: Use of fields defined in base classes. + +Simple `StringLiteral` extraction fails in these cases. We need a recursive **Expression Evaluator**. + +## 2. Evaluation Logic + +### Constant Resolution +If an expression is a `SimpleName` or `QualifiedName`: +1. Use `CodebaseContext.getTypeDeclaration()` to find the class where the variable is defined. +2. Search for the `VariableDeclarationFragment`. +3. If it has an initializer that is a literal or another evaluatable expression, resolve it. + +### Concatenation (Infix Expressions) +If an expression is an `InfixExpression` with the `+` operator: +1. Recursively resolve the left operand. +2. Recursively resolve the right operand. +3. Concatenate the results if both are strings. + +### Spring Expression Language (SpEL) in Annotations +For values like `@Value("#{systemProperties['path.base'] + '/api'}")`: +1. Identify the SpEL string. +2. Use a "Lite" SpEL parser to extract keys. +3. Match against the `profiles` map in `CodebaseMetadata`. + +## 3. Implementation: `ValueResolver` +We will introduce a central `ValueResolver` utility. + +```java +public class ValueResolver { + public String resolveString(Expression expr, CodebaseContext context) { + if (expr instanceof StringLiteral sl) return sl.getLiteralValue(); + if (expr instanceof InfixExpression ie) return resolveInfix(ie, context); + if (expr instanceof Name name) return resolveVariable(name, context); + // ... fallback to toString() or empty + } +} +``` + +## 4. Challenges +- **Circular Dependencies**: A + B, where B = A + C. We need a "visited" set to prevent infinite recursion. +- **Runtime-only values**: Some values cannot be resolved statically (e.g., values from a DB). In these cases, we should return the placeholder or a "Runtime Value" marker. +- **Method Calls**: `getPath() + "/orders"`. Resolving method return values is complex; initially, we will only support simple getters returning literals. + +## 5. Integration +1. Update `AstUtils` or create `ValueResolver`. +2. Refactor `SpringMvcEnricher`, `RabbitMqEnricher`, and `JmsEnricher` to use the resolver for all path/queue/destination fields. +3. Update `integration_test_state_machine` with complex path examples. diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/GoldenUpdater.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/GoldenUpdater.java new file mode 100644 index 0000000..8f86472 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/GoldenUpdater.java @@ -0,0 +1,165 @@ +package click.kamil.springstatemachineexporter; + +import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService; +import click.kamil.springstatemachineexporter.exporter.Dot; +import click.kamil.springstatemachineexporter.exporter.JsonExporter; +import click.kamil.springstatemachineexporter.exporter.PlantUml; +import click.kamil.springstatemachineexporter.exporter.Scxml; +import click.kamil.springstatemachineexporter.exporter.StateMachineExporter; +import click.kamil.springstatemachineexporter.service.ExportService; +import click.kamil.springstatemachineexporter.utils.TestScenario; + +import net.sourceforge.plantuml.FileFormat; +import net.sourceforge.plantuml.FileFormatOption; +import net.sourceforge.plantuml.SourceStringReader; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +public class GoldenUpdater { + + private static final List exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); + private static final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); + private static final ExportService exportService = new ExportService(exporters, enrichmentService); + + public static void main(String[] args) throws Exception { + List scenarios = provideTestScenarios(); + Path rootDir = Path.of("state_machine_exporter"); + + for (TestScenario scenario : scenarios) { + System.out.println("Updating golden files for: " + scenario.name()); + Path tempDir = Files.createTempDirectory("golden-update-"); + try { + exportService.runExporter(scenario.inputPath(), tempDir, null, true); + + List generatedDirs; + try (var stream = Files.list(tempDir)) { + generatedDirs = stream.filter(Files::isDirectory).toList(); + } + + String targetBaseName = scenario.baseName(); + Path actualDir = generatedDirs.stream() + .filter(d -> d.getFileName().toString().endsWith(targetBaseName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName)); + + String actualBaseName = actualDir.getFileName().toString(); + Path goldenDirPath = rootDir.resolve(scenario.goldenPath()); + Files.createDirectories(goldenDirPath); + + for (StateMachineExporter exporter : exporters) { + String fileName = actualBaseName + exporter.getFileExtension(); + String goldenFileName = targetBaseName + exporter.getFileExtension(); + + Path actualFile = actualDir.resolve(fileName); + Path goldenFile = goldenDirPath.resolve(goldenFileName); + + System.out.println(" Writing " + goldenFile); + Files.copy(actualFile, goldenFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + // Special case for PlantUML: also generate PNG + if (exporter instanceof PlantUml) { + String pngFileName = targetBaseName + ".png"; + Path goldenPngFile = goldenDirPath.resolve(pngFileName); + System.out.println(" Rendering " + goldenPngFile); + + String pumlContent = Files.readString(actualFile); + SourceStringReader reader = new SourceStringReader(pumlContent); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + reader.outputImage(os, new FileFormatOption(FileFormat.PNG)); + Files.write(goldenPngFile, os.toByteArray()); + } + } + } finally { + // delete tempDir + deleteDirectory(tempDir); + } + } + } + + private static void deleteDirectory(Path path) throws IOException { + if (Files.isDirectory(path)) { + try (var stream = Files.list(path)) { + for (Path p : stream.toList()) { + deleteDirectory(p); + } + } + } + Files.delete(path); + } + + private static List provideTestScenarios() { + return List.of( + new TestScenario( + "Complex State Machine", + Path.of("state_machines/complex_state_machine"), + Path.of("src/test/resources/golden/ComplexStateMachineConfig"), + "ComplexStateMachineConfig" + ), + new TestScenario( + "Fork Join State Machine", + Path.of("state_machines/forkjoin_state_machine"), + Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"), + "ForkJoinStateMachineConfig" + ), + new TestScenario( + "Kamil Enum State Machine", + Path.of("state_machines/enumstate_state_machine"), + Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"), + "KamilEnumStateMachineConfiguration" + ), + new TestScenario( + "Simple Enum Join State Machine", + Path.of("state_machines/simple_state_machine"), + Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"), + "SimpleEnumStateMachineConfiguration" + ), + new TestScenario( + "One State Machine (Inheritance)", + Path.of("state_machines/inheritance_state_machine"), + Path.of("src/test/resources/golden/OneStateMachineConfiguration"), + "OneStateMachineConfiguration" + ), + new TestScenario( + "Two State Machine (Extra Functions)", + Path.of("state_machines/inheritance_extra_functions_state_machine"), + Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"), + "TwoStateMachineConfiguration" + ), + new TestScenario( + "Three State Machine (Extra Functions 2)", + Path.of("state_machines/inheritance_extra_functions2_state_machine"), + Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"), + "ThreeStateMachineConfiguration" + ), + new TestScenario( + "F1 State Machine (Example 4)", + Path.of("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/F1StateMachineConfiguration"), + "F1StateMachineConfiguration" + ), + new TestScenario( + "F2 State Machine (Example 4)", + Path.of("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/F2StateMachineConfiguration"), + "F2StateMachineConfiguration" + ), + new TestScenario( + "G1 State Machine (Example 4)", + Path.of("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/G1StateMachineConfiguration"), + "G1StateMachineConfiguration" + ), + new TestScenario( + "G2 State Machine (Example 4)", + Path.of("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/G2StateMachineConfiguration"), + "G2StateMachineConfiguration" + ) + ); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/PlantUmlE2ETest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/PlantUmlE2ETest.java new file mode 100644 index 0000000..13cba55 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/PlantUmlE2ETest.java @@ -0,0 +1,162 @@ +package click.kamil.springstatemachineexporter; + +import click.kamil.springstatemachineexporter.analysis.service.EnrichmentService; +import click.kamil.springstatemachineexporter.exporter.PlantUml; +import click.kamil.springstatemachineexporter.exporter.StateMachineExporter; +import click.kamil.springstatemachineexporter.service.ExportService; +import click.kamil.springstatemachineexporter.utils.TestScenario; +import net.sourceforge.plantuml.FileFormat; +import net.sourceforge.plantuml.FileFormatOption; +import net.sourceforge.plantuml.SourceStringReader; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("e2e") +@Tag("plantuml") +@Tag("png") +public class PlantUmlE2ETest { + + private final List exporters = List.of(new PlantUml()); + private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); + private final ExportService exportService = new ExportService(exporters, enrichmentService); + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } + + private static List provideTestScenarios() { + Path root = findProjectRoot(); + return List.of( + new TestScenario( + "Complex State Machine", + root.resolve("state_machines/complex_state_machine"), + Path.of("src/test/resources/golden/ComplexStateMachineConfig"), + "ComplexStateMachineConfig" + ), + new TestScenario( + "Fork Join State Machine", + root.resolve("state_machines/forkjoin_state_machine"), + Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"), + "ForkJoinStateMachineConfig" + ), + new TestScenario( + "Kamil Enum State Machine", + root.resolve("state_machines/enumstate_state_machine"), + Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"), + "KamilEnumStateMachineConfiguration" + ), + new TestScenario( + "Simple Enum Join State Machine", + root.resolve("state_machines/simple_state_machine"), + Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"), + "SimpleEnumStateMachineConfiguration" + ), + new TestScenario( + "One State Machine (Inheritance)", + root.resolve("state_machines/inheritance_state_machine"), + Path.of("src/test/resources/golden/OneStateMachineConfiguration"), + "OneStateMachineConfiguration" + ), + new TestScenario( + "Two State Machine (Extra Functions)", + root.resolve("state_machines/inheritance_extra_functions_state_machine"), + Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"), + "TwoStateMachineConfiguration" + ), + new TestScenario( + "Three State Machine (Extra Functions 2)", + root.resolve("state_machines/inheritance_extra_functions2_state_machine"), + Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"), + "ThreeStateMachineConfiguration" + ), + new TestScenario( + "F1 State Machine (Example 4)", + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/F1StateMachineConfiguration"), + "F1StateMachineConfiguration" + ), + new TestScenario( + "F2 State Machine (Example 4)", + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/F2StateMachineConfiguration"), + "F2StateMachineConfiguration" + ), + new TestScenario( + "G1 State Machine (Example 4)", + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/G1StateMachineConfiguration"), + "G1StateMachineConfiguration" + ), + new TestScenario( + "G2 State Machine (Example 4)", + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), + Path.of("src/test/resources/golden/G2StateMachineConfiguration"), + "G2StateMachineConfiguration" + ) + ); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideTestScenarios") + void testPngGenerationMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception { + // 1. Generate PUML + exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml"), true); + + List generatedDirs; + try (var stream = Files.list(tempDir)) { + generatedDirs = stream.filter(Files::isDirectory).toList(); + } + + String targetBaseName = scenario.baseName(); + Path actualDir = generatedDirs.stream() + .filter(d -> d.getFileName().toString().endsWith(targetBaseName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName)); + + String actualBaseName = actualDir.getFileName().toString(); + Path pumlFile = actualDir.resolve(actualBaseName + ".puml"); + + assertThat(pumlFile).exists(); + + // 2. Render PNG using PlantUML library + String pumlContent = Files.readString(pumlFile); + SourceStringReader reader = new SourceStringReader(pumlContent); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + reader.outputImage(os, new FileFormatOption(FileFormat.PNG)); + byte[] actualPngBytes = os.toByteArray(); + + // 3. Compare with Golden + Path goldenPngFile = scenario.goldenPath().resolve(targetBaseName + ".png"); + + if (Boolean.getBoolean("updateGolden")) { + Files.createDirectories(goldenPngFile.getParent()); + Files.write(goldenPngFile, actualPngBytes); + System.out.println("Updated golden PNG: " + goldenPngFile); + } else { + assertThat(goldenPngFile) + .as("Golden PNG file %s should exist", goldenPngFile) + .exists(); + + byte[] expectedPngBytes = Files.readAllBytes(goldenPngFile); + + assertThat(actualPngBytes) + .as("Visual regression detected for %s. PNG output does not match golden reference.", scenario.name()) + .containsExactly(expectedPngBytes); + } + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/PlantUmlRenderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/PlantUmlRenderTest.java index 3986c30..8f02096 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/PlantUmlRenderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/PlantUmlRenderTest.java @@ -25,71 +25,80 @@ public class PlantUmlRenderTest { private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); private final ExportService exportService = new ExportService(exporters, enrichmentService); + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } + private static List provideTestScenarios() { + Path root = findProjectRoot(); return List.of( new TestScenario( "Complex State Machine", - Path.of("../state_machines/complex_state_machine"), + root.resolve("state_machines/complex_state_machine"), Path.of("src/test/resources/golden/ComplexStateMachineConfig"), "ComplexStateMachineConfig" ), new TestScenario( "Fork Join State Machine", - Path.of("../state_machines/forkjoin_state_machine"), + root.resolve("state_machines/forkjoin_state_machine"), Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"), "ForkJoinStateMachineConfig" ), new TestScenario( "Kamil Enum State Machine", - Path.of("../state_machines/enumstate_state_machine"), + root.resolve("state_machines/enumstate_state_machine"), Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"), "KamilEnumStateMachineConfiguration" ), new TestScenario( "Simple Enum Join State Machine", - Path.of("../state_machines/simple_state_machine"), + root.resolve("state_machines/simple_state_machine"), Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"), "SimpleEnumStateMachineConfiguration" ), new TestScenario( "One State Machine (Inheritance)", - Path.of("../state_machines/inheritance_state_machine"), + root.resolve("state_machines/inheritance_state_machine"), Path.of("src/test/resources/golden/OneStateMachineConfiguration"), "OneStateMachineConfiguration" ), new TestScenario( "Two State Machine (Extra Functions)", - Path.of("../state_machines/inheritance_extra_functions_state_machine"), + root.resolve("state_machines/inheritance_extra_functions_state_machine"), Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"), "TwoStateMachineConfiguration" ), new TestScenario( "Three State Machine (Extra Functions 2)", - Path.of("../state_machines/inheritance_extra_functions2_state_machine"), + root.resolve("state_machines/inheritance_extra_functions2_state_machine"), Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"), "ThreeStateMachineConfiguration" ), new TestScenario( "F1 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/F1StateMachineConfiguration"), "F1StateMachineConfiguration" ), new TestScenario( "F2 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/F2StateMachineConfiguration"), "F2StateMachineConfiguration" ), new TestScenario( "G1 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/G1StateMachineConfiguration"), "G1StateMachineConfiguration" ), new TestScenario( "G2 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/G2StateMachineConfiguration"), "G2StateMachineConfiguration" ) @@ -101,7 +110,7 @@ public class PlantUmlRenderTest { void testRenderMatchesGolden(TestScenario scenario, @TempDir Path tempDir) throws Exception { exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml"), true); - // Find the generated directory + // Find the generated directory (it might be named with FQN) List generatedDirs; try (var stream = Files.list(tempDir)) { generatedDirs = stream.filter(Files::isDirectory).toList(); @@ -111,37 +120,26 @@ public class PlantUmlRenderTest { Path actualDir = generatedDirs.stream() .filter(d -> d.getFileName().toString().endsWith(targetBaseName)) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName)); + .orElseThrow(() -> new IllegalArgumentException("No output directory found for " + targetBaseName + " in " + generatedDirs)); String actualBaseName = actualDir.getFileName().toString(); - Path actualPumlFile = actualDir.resolve(actualBaseName + ".puml"); + String fileName = actualBaseName + ".puml"; + String goldenFileName = targetBaseName + ".puml"; - assertThat(actualPumlFile).exists(); + Path actualFile = actualDir.resolve(fileName); + Path goldenFile = scenario.goldenPath().resolve(goldenFileName); - // Render to PNG - renderToPng(actualPumlFile); + if (Boolean.getBoolean("updateGolden")) { + Files.createDirectories(goldenFile.getParent()); + Files.copy(actualFile, goldenFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } else { + assertThat(actualFile) + .as("File %s should exist in output", fileName) + .exists(); - Path actualPngFile = actualDir.resolve(actualBaseName + ".png"); - assertThat(actualPngFile).exists(); - - // Compare with Golden PNG - String goldenPngFileName = targetBaseName + ".png"; - Path goldenPngFile = scenario.goldenPath().resolve(goldenPngFileName); - - assertThat(goldenPngFile).as("Golden PNG %s should exist", goldenPngFileName).exists(); - - assertThat(Files.readAllBytes(actualPngFile)) - .as("Rendered PNG mismatch for %s", actualBaseName) - .isEqualTo(Files.readAllBytes(goldenPngFile)); - } - - private void renderToPng(Path pumlFile) throws IOException, InterruptedException { - ProcessBuilder pb = new ProcessBuilder("plantuml", "-nometadata", pumlFile.toAbsolutePath().toString()); - pb.inheritIO(); - Process process = pb.start(); - int exitCode = process.waitFor(); - if (exitCode != 0) { - throw new RuntimeException("PlantUML rendering failed with exit code " + exitCode); + assertThat(actualFile) + .as("Content mismatch for %s", fileName) + .content().isEqualTo(Files.readString(goldenFile)); } } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java index 3510885..165804d 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/RegressionTest.java @@ -25,71 +25,80 @@ public class RegressionTest { private final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); private final ExportService exportService = new ExportService(exporters, enrichmentService); + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } + private static List provideTestScenarios() { + Path root = findProjectRoot(); return List.of( new TestScenario( "Complex State Machine", - Path.of("../state_machines/complex_state_machine"), + root.resolve("state_machines/complex_state_machine"), Path.of("src/test/resources/golden/ComplexStateMachineConfig"), "ComplexStateMachineConfig" ), new TestScenario( "Fork Join State Machine", - Path.of("../state_machines/forkjoin_state_machine"), + root.resolve("state_machines/forkjoin_state_machine"), Path.of("src/test/resources/golden/ForkJoinStateMachineConfig"), "ForkJoinStateMachineConfig" ), new TestScenario( "Kamil Enum State Machine", - Path.of("../state_machines/enumstate_state_machine"), + root.resolve("state_machines/enumstate_state_machine"), Path.of("src/test/resources/golden/KamilEnumStateMachineConfiguration"), "KamilEnumStateMachineConfiguration" ), new TestScenario( "Simple Enum Join State Machine", - Path.of("../state_machines/simple_state_machine"), + root.resolve("state_machines/simple_state_machine"), Path.of("src/test/resources/golden/SimpleEnumStateMachineConfiguration"), "SimpleEnumStateMachineConfiguration" ), new TestScenario( "One State Machine (Inheritance)", - Path.of("../state_machines/inheritance_state_machine"), + root.resolve("state_machines/inheritance_state_machine"), Path.of("src/test/resources/golden/OneStateMachineConfiguration"), "OneStateMachineConfiguration" ), new TestScenario( "Two State Machine (Extra Functions)", - Path.of("../state_machines/inheritance_extra_functions_state_machine"), + root.resolve("state_machines/inheritance_extra_functions_state_machine"), Path.of("src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration"), "TwoStateMachineConfiguration" ), new TestScenario( "Three State Machine (Extra Functions 2)", - Path.of("../state_machines/inheritance_extra_functions2_state_machine"), + root.resolve("state_machines/inheritance_extra_functions2_state_machine"), Path.of("src/test/resources/golden/ThreeStateMachineConfiguration"), "ThreeStateMachineConfiguration" ), new TestScenario( "F1 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/F1StateMachineConfiguration"), "F1StateMachineConfiguration" ), new TestScenario( "F2 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/F2StateMachineConfiguration"), "F2StateMachineConfiguration" ), new TestScenario( "G1 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/G1StateMachineConfiguration"), "G1StateMachineConfiguration" ), new TestScenario( "G2 State Machine (Example 4)", - Path.of("../state_machines/inheritance_extra_functions3_state_machine"), + root.resolve("state_machines/inheritance_extra_functions3_state_machine"), Path.of("src/test/resources/golden/G2StateMachineConfiguration"), "G2StateMachineConfiguration" ) @@ -122,13 +131,18 @@ public class RegressionTest { Path actualFile = actualDir.resolve(fileName); Path goldenFile = scenario.goldenPath().resolve(goldenFileName); - assertThat(actualFile) - .as("File %s should exist in output", fileName) - .exists(); + if (Boolean.getBoolean("updateGolden")) { + Files.createDirectories(goldenFile.getParent()); + Files.copy(actualFile, goldenFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } else { + assertThat(actualFile) + .as("File %s should exist in output", fileName) + .exists(); - assertThat(actualFile) - .as("Content mismatch for %s", fileName) - .content().isEqualTo(Files.readString(goldenFile)); + assertThat(actualFile) + .as("Content mismatch for %s", fileName) + .content().isEqualTo(Files.readString(goldenFile)); + } } } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonExporterTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonExporterTest.java index 856f6e2..6625636 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonExporterTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/exporter/JsonExporterTest.java @@ -1,5 +1,6 @@ package click.kamil.springstatemachineexporter.exporter; +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.Transition; import click.kamil.springstatemachineexporter.model.TransitionType; @@ -21,7 +22,14 @@ class JsonExporterTest { transition.setTargetStates(List.of(State.of("S2"))); transition.setEvent("E1"); - String json = exporter.export("TestSM", List.of(transition), Set.of("S1"), Set.of("S2"), ExportOptions.builder().build()); + AnalysisResult result = AnalysisResult.builder() + .name("TestSM") + .transitions(List.of(transition)) + .startStates(Set.of("S1")) + .endStates(Set.of("S2")) + .build(); + + String json = exporter.export(result, ExportOptions.builder().build()); assertThat(json) .contains("\"name\" : \"TestSM\"")