|
23 | 23 | import org.junit.jupiter.api.Test; |
24 | 24 | import org.junit.jupiter.api.io.TempDir; |
25 | 25 | import org.openrewrite.config.DeclarativeRecipe; |
| 26 | +import org.openrewrite.internal.InMemoryLargeSourceSet; |
26 | 27 | import org.openrewrite.internal.RecipeRunException; |
27 | 28 | import org.openrewrite.marker.Markup; |
| 29 | +import org.openrewrite.scheduling.RecipeRunCycle; |
| 30 | +import org.openrewrite.scheduling.WatchableExecutionContext; |
28 | 31 | import org.openrewrite.scheduling.WorkingDirectoryExecutionContextView; |
| 32 | +import org.openrewrite.table.RecipeRunStats; |
| 33 | +import org.openrewrite.table.SearchResults; |
29 | 34 | import org.openrewrite.table.SourcesFileErrors; |
| 35 | +import org.openrewrite.table.SourcesFileResults; |
30 | 36 | import org.openrewrite.test.RewriteTest; |
31 | 37 | import org.openrewrite.text.PlainText; |
32 | 38 | import org.openrewrite.text.PlainTextVisitor; |
@@ -165,6 +171,133 @@ void managedWorkingDirectoryWithMultipleRecipes(@TempDir Path path) { |
165 | 171 | ); |
166 | 172 | assertThat(path).doesNotExist(); |
167 | 173 | } |
| 174 | + |
| 175 | + @Test |
| 176 | + void verifyCycleInvariantsDuringMultipleCycles() { |
| 177 | + List<Integer> cyclesFromFactory = new java.util.ArrayList<>(); |
| 178 | + List<Integer> cyclesFromContext = new java.util.ArrayList<>(); |
| 179 | + AtomicInteger visitCount = new AtomicInteger(0); |
| 180 | + |
| 181 | + RecipeScheduler trackingScheduler = new RecipeScheduler() { |
| 182 | + @Override |
| 183 | + protected RecipeRunCycle<LargeSourceSet> createRecipeRunCycle( |
| 184 | + Recipe recipe, int cycle, Cursor rootCursor, |
| 185 | + WatchableExecutionContext ctxWithWatch, |
| 186 | + RecipeRunStats recipeRunStats, SearchResults searchResults, |
| 187 | + SourcesFileResults sourceFileResults, SourcesFileErrors errorsTable) { |
| 188 | + cyclesFromFactory.add(cycle); |
| 189 | + return super.createRecipeRunCycle(recipe, cycle, rootCursor, ctxWithWatch, |
| 190 | + recipeRunStats, searchResults, sourceFileResults, errorsTable); |
| 191 | + } |
| 192 | + }; |
| 193 | + |
| 194 | + // Recipe that causes another cycle by returning different content each time (up to 2 times) |
| 195 | + Recipe multiCycleRecipe = toRecipe(() -> new PlainTextVisitor<>() { |
| 196 | + @Override |
| 197 | + public PlainText visitText(PlainText text, ExecutionContext ctx) { |
| 198 | + // Verify cycle is accessible from context during visitor execution |
| 199 | + cyclesFromContext.add(ctx.getCycle()); |
| 200 | + int count = visitCount.incrementAndGet(); |
| 201 | + if (count <= 2) { |
| 202 | + return text.withText(text.getText() + count); |
| 203 | + } |
| 204 | + return text; |
| 205 | + } |
| 206 | + }).withCausesAnotherCycle(true); |
| 207 | + |
| 208 | + InMemoryExecutionContext ctx = new InMemoryExecutionContext(); |
| 209 | + List<SourceFile> sources = List.of(PlainText.builder().text("v").sourcePath(Path.of("test.txt")).build()); |
| 210 | + trackingScheduler.scheduleRun(multiCycleRecipe, new InMemoryLargeSourceSet(sources), ctx, 5, 1); |
| 211 | + |
| 212 | + // Verify cycle numbers increment correctly: Cycle 1, 2, 3 (stops after no change in cycle 3) |
| 213 | + assertThat(cyclesFromFactory).containsExactly(1, 2, 3); |
| 214 | + // Verify cycle is correctly registered in context and accessible during visitor execution |
| 215 | + assertThat(cyclesFromContext).containsExactly(1, 2, 3); |
| 216 | + } |
| 217 | + |
| 218 | + @Test |
| 219 | + void recordsBeforeAndAfterSourceFilesCorrectly() { |
| 220 | + List<String> beforeContents = new java.util.ArrayList<>(); |
| 221 | + List<String> afterContents = new java.util.ArrayList<>(); |
| 222 | + |
| 223 | + RecipeScheduler trackingScheduler = new RecipeScheduler() { |
| 224 | + @Override |
| 225 | + protected RecipeRunCycle<LargeSourceSet> createRecipeRunCycle( |
| 226 | + Recipe recipe, int cycle, Cursor rootCursor, |
| 227 | + WatchableExecutionContext ctxWithWatch, |
| 228 | + RecipeRunStats recipeRunStats, SearchResults searchResults, |
| 229 | + SourcesFileResults sourceFileResults, SourcesFileErrors errorsTable) { |
| 230 | + return new RecipeRunCycle<>(recipe, cycle, rootCursor, ctxWithWatch, |
| 231 | + recipeRunStats, searchResults, sourceFileResults, errorsTable, LargeSourceSet::edit) { |
| 232 | + @Override |
| 233 | + protected void recordSourceFileResultAndSearchResults( |
| 234 | + @Nullable SourceFile before, @Nullable SourceFile after, |
| 235 | + java.util.Stack<Recipe> recipeStack, ExecutionContext ctx) { |
| 236 | + if (before instanceof PlainText) { |
| 237 | + beforeContents.add(((PlainText) before).getText()); |
| 238 | + } |
| 239 | + if (after instanceof PlainText) { |
| 240 | + afterContents.add(((PlainText) after).getText()); |
| 241 | + } |
| 242 | + super.recordSourceFileResultAndSearchResults(before, after, recipeStack, ctx); |
| 243 | + } |
| 244 | + }; |
| 245 | + } |
| 246 | + }; |
| 247 | + |
| 248 | + Recipe recipe = toRecipe(() -> new PlainTextVisitor<>() { |
| 249 | + @Override |
| 250 | + public PlainText visitText(PlainText text, ExecutionContext ctx) { |
| 251 | + return text.withText("modified:" + text.getText()); |
| 252 | + } |
| 253 | + }); |
| 254 | + |
| 255 | + InMemoryExecutionContext ctx = new InMemoryExecutionContext(); |
| 256 | + List<SourceFile> sources = List.of( |
| 257 | + PlainText.builder().text("a").sourcePath(Path.of("a.txt")).build(), |
| 258 | + PlainText.builder().text("b").sourcePath(Path.of("b.txt")).build() |
| 259 | + ); |
| 260 | + trackingScheduler.scheduleRun(recipe, new InMemoryLargeSourceSet(sources), ctx, 3, 1); |
| 261 | + |
| 262 | + assertThat(beforeContents).containsExactlyInAnyOrder("a", "b"); |
| 263 | + assertThat(afterContents).containsExactlyInAnyOrder("modified:a", "modified:b"); |
| 264 | + } |
| 265 | + |
| 266 | + @Test |
| 267 | + void recordsGeneratedSourceFiles() { |
| 268 | + List<String> generatedPaths = new java.util.ArrayList<>(); |
| 269 | + |
| 270 | + RecipeScheduler trackingScheduler = new RecipeScheduler() { |
| 271 | + @Override |
| 272 | + protected RecipeRunCycle<LargeSourceSet> createRecipeRunCycle( |
| 273 | + Recipe recipe, int cycle, Cursor rootCursor, |
| 274 | + WatchableExecutionContext ctxWithWatch, |
| 275 | + RecipeRunStats recipeRunStats, SearchResults searchResults, |
| 276 | + SourcesFileResults sourceFileResults, SourcesFileErrors errorsTable) { |
| 277 | + return new RecipeRunCycle<>(recipe, cycle, rootCursor, ctxWithWatch, |
| 278 | + recipeRunStats, searchResults, sourceFileResults, errorsTable, LargeSourceSet::edit) { |
| 279 | + @Override |
| 280 | + protected void recordSourceFileResultAndSearchResults( |
| 281 | + @Nullable SourceFile before, @Nullable SourceFile after, |
| 282 | + java.util.Stack<Recipe> recipeStack, ExecutionContext ctx) { |
| 283 | + // Track files that were generated (before is null) |
| 284 | + if (before == null && after != null) { |
| 285 | + generatedPaths.add(after.getSourcePath().toString()); |
| 286 | + } |
| 287 | + super.recordSourceFileResultAndSearchResults(before, after, recipeStack, ctx); |
| 288 | + } |
| 289 | + }; |
| 290 | + } |
| 291 | + }; |
| 292 | + |
| 293 | + Recipe generatingRecipe = new GeneratingRecipe(); |
| 294 | + |
| 295 | + InMemoryExecutionContext ctx = new InMemoryExecutionContext(); |
| 296 | + List<SourceFile> sources = List.of(PlainText.builder().text("existing").sourcePath(Path.of("existing.txt")).build()); |
| 297 | + trackingScheduler.scheduleRun(generatingRecipe, new InMemoryLargeSourceSet(sources), ctx, 3, 1); |
| 298 | + |
| 299 | + assertThat(generatedPaths).containsExactly("generated.txt"); |
| 300 | + } |
168 | 301 | } |
169 | 302 |
|
170 | 303 | @AllArgsConstructor |
@@ -228,6 +361,41 @@ public StackTraceElement[] getStackTrace() { |
228 | 361 | } |
229 | 362 | } |
230 | 363 |
|
| 364 | +class GeneratingRecipe extends ScanningRecipe<AtomicInteger> { |
| 365 | + @Getter |
| 366 | + final String displayName = "Generating recipe"; |
| 367 | + |
| 368 | + @Getter |
| 369 | + final String description = "Generates a new file."; |
| 370 | + |
| 371 | + @Override |
| 372 | + public AtomicInteger getInitialValue(ExecutionContext ctx) { |
| 373 | + return new AtomicInteger(0); |
| 374 | + } |
| 375 | + |
| 376 | + @Override |
| 377 | + public TreeVisitor<?, ExecutionContext> getScanner(AtomicInteger acc) { |
| 378 | + return new TreeVisitor<>() { |
| 379 | + @Override |
| 380 | + public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) { |
| 381 | + acc.incrementAndGet(); |
| 382 | + return tree; |
| 383 | + } |
| 384 | + }; |
| 385 | + } |
| 386 | + |
| 387 | + @Override |
| 388 | + public Collection<? extends SourceFile> generate(AtomicInteger acc, ExecutionContext ctx) { |
| 389 | + if (acc.get() > 0) { |
| 390 | + return List.of(PlainText.builder() |
| 391 | + .text("generated content") |
| 392 | + .sourcePath(Path.of("generated.txt")) |
| 393 | + .build()); |
| 394 | + } |
| 395 | + return List.of(); |
| 396 | + } |
| 397 | +} |
| 398 | + |
231 | 399 | @AllArgsConstructor |
232 | 400 | class RecipeWritingToFile extends ScanningRecipe<RecipeWritingToFile.Accumulator> { |
233 | 401 |
|
|
0 commit comments