View Javadoc
1   /*
2    * SPDX-License-Identifier: LGPL-2.1-or-later
3    * See LICENSE file for details.
4    *
5    * Copyright 2012-2026 Hazendaz.
6    */
7   package net.alchim31.maven.yuicompressor;
8   
9   import java.io.File;
10  import java.lang.reflect.Field;
11  import java.nio.charset.StandardCharsets;
12  import java.nio.file.Files;
13  import java.util.List;
14  
15  import org.apache.maven.api.plugin.testing.InjectMojo;
16  import org.apache.maven.api.plugin.testing.MojoExtension;
17  import org.apache.maven.api.plugin.testing.MojoTest;
18  import org.apache.maven.model.Resource;
19  import org.codehaus.plexus.build.DefaultBuildContext;
20  import org.junit.jupiter.api.Assertions;
21  import org.junit.jupiter.api.Test;
22  import org.junit.jupiter.api.io.TempDir;
23  import org.mockito.ArgumentMatchers;
24  import org.mockito.Mockito;
25  import org.sonatype.plexus.build.incremental.BuildContext;
26  
27  /**
28   * Tests for {@link YuiCompressorMojo} using the maven-plugin-testing-harness 3.5.1 JUnit 5 extension
29   * ({@code @MojoTest}) combined with direct instantiation tests.
30   */
31  // Note: public here needed for javadocs to work so don't remove it
32  @MojoTest
33  public class YuiCompressorMojoTest {
34  
35      /** Temporary directory for test output files. */
36      @TempDir
37      File tempDir;
38  
39      // ---------------------------------------------------------------- harness-based tests
40  
41      /**
42       * Verify that the mojo can be looked up via the harness and has the expected configuration values from the test
43       * pom.
44       *
45       * @param mojo
46       *            the mojo instance injected by {@link MojoExtension}
47       *
48       * @throws Exception
49       *             if field access fails
50       */
51      @Test
52      @InjectMojo(goal = "compress", pom = "src/test/resources/unit/compress-basic-test/pom.xml")
53      void testMojoConfiguredByHarness_hasExpectedConfigValues(YuiCompressorMojo mojo) throws Exception {
54          Assertions.assertNotNull(mojo, "Mojo must be injected by the harness");
55          // Encoding set to UTF-8 in the test pom
56          final String encoding = (String) MojoExtension.getVariableValueFromObject(mojo, "encoding");
57          Assertions.assertEquals("UTF-8", encoding, "encoding should be UTF-8 from test pom");
58          // statistics disabled in the test pom
59          final boolean statistics = (boolean) MojoExtension.getVariableValueFromObject(mojo, "statistics");
60          Assertions.assertFalse(statistics, "statistics should be false per test pom");
61          // default suffix should be the default "-min"
62          final String suffix = (String) MojoExtension.getVariableValueFromObject(mojo, "suffix");
63          Assertions.assertEquals("-min", suffix, "suffix should be '-min' (default)");
64      }
65  
66      // ---------------------------------------------------------------- ratioOfSize
67  
68      /**
69       * Test ratioOfSize with two files of known sizes.
70       *
71       * @throws Exception
72       *             if test setup fails
73       */
74      @Test
75      void testRatioOfSize_regularFiles() throws Exception {
76          final var mojo = new YuiCompressorMojo();
77          final File large = tempDir.toPath().resolve("large.js").toFile();
78          final File small = tempDir.toPath().resolve("small.js").toFile();
79          Files.write(large.toPath(), new byte[100]);
80          Files.write(small.toPath(), new byte[50]);
81  
82          final long ratio = mojo.ratioOfSize(large, small);
83          Assertions.assertEquals(50L, ratio, "Expected 50% ratio");
84      }
85  
86      /**
87       * Test ratioOfSize when the "100%" file is empty – Math.max prevents division by zero.
88       *
89       * @throws Exception
90       *             if test setup fails
91       */
92      @Test
93      void testRatioOfSize_emptyBaseFile() throws Exception {
94          final var mojo = new YuiCompressorMojo();
95          final File empty = tempDir.toPath().resolve("empty.js").toFile();
96          final File small = tempDir.toPath().resolve("small.js").toFile();
97          Files.write(empty.toPath(), new byte[0]);
98          Files.write(small.toPath(), new byte[1]);
99  
100         // Math.max(0, 1) → base = 1; ratio = 1*100/1 = 100
101         final long ratio = mojo.ratioOfSize(empty, small);
102         Assertions.assertEquals(100L, ratio);
103     }
104 
105     /**
106      * Test ratioOfSize when both files are the same size.
107      *
108      * @throws Exception
109      *             if test setup fails
110      */
111     @Test
112     void testRatioOfSize_sameSize() throws Exception {
113         final var mojo = new YuiCompressorMojo();
114         final File f1 = tempDir.toPath().resolve("f1.js").toFile();
115         final File f2 = tempDir.toPath().resolve("f2.js").toFile();
116         Files.write(f1.toPath(), new byte[200]);
117         Files.write(f2.toPath(), new byte[200]);
118 
119         Assertions.assertEquals(100L, mojo.ratioOfSize(f1, f2));
120     }
121 
122     // ----------------------------------------------------------- gzipIfRequested
123 
124     /**
125      * Test that gzipIfRequested returns null when gzip is disabled.
126      *
127      * @throws Exception
128      *             if test setup fails
129      */
130     @Test
131     void testGzipIfRequested_gzipDisabled_returnsNull() throws Exception {
132         final var mojo = createMojoWithBuildContext();
133         MojoExtension.setVariableValueToObject(mojo, "gzip", false);
134 
135         final File file = tempDir.toPath().resolve("test.js").toFile();
136         Files.write(file.toPath(), "var x=1;".getBytes(StandardCharsets.UTF_8));
137 
138         Assertions.assertNull(mojo.gzipIfRequested(file), "Expected null when gzip is disabled");
139     }
140 
141     /**
142      * Test that gzipIfRequested returns null when the provided file is null.
143      *
144      * @throws Exception
145      *             if test setup fails
146      */
147     @Test
148     void testGzipIfRequested_nullFile_returnsNull() throws Exception {
149         final var mojo = createMojoWithBuildContext();
150         MojoExtension.setVariableValueToObject(mojo, "gzip", true);
151         MojoExtension.setVariableValueToObject(mojo, "level", 9);
152 
153         Assertions.assertNull(mojo.gzipIfRequested(null), "Expected null for null file");
154     }
155 
156     /**
157      * Test that gzipIfRequested returns null when the file does not exist.
158      *
159      * @throws Exception
160      *             if test setup fails
161      */
162     @Test
163     void testGzipIfRequested_fileNotExists_returnsNull() throws Exception {
164         final var mojo = createMojoWithBuildContext();
165         MojoExtension.setVariableValueToObject(mojo, "gzip", true);
166         MojoExtension.setVariableValueToObject(mojo, "level", 9);
167 
168         final File missing = tempDir.toPath().resolve("nonexistent.js").toFile();
169         Assertions.assertNull(mojo.gzipIfRequested(missing), "Expected null when file does not exist");
170     }
171 
172     /**
173      * Test that gzipIfRequested returns null when the file is already a .gz file.
174      *
175      * @throws Exception
176      *             if test setup fails
177      */
178     @Test
179     void testGzipIfRequested_alreadyGzipped_returnsNull() throws Exception {
180         final var mojo = createMojoWithBuildContext();
181         MojoExtension.setVariableValueToObject(mojo, "gzip", true);
182         MojoExtension.setVariableValueToObject(mojo, "level", 9);
183 
184         final File gzFile = tempDir.toPath().resolve("test.js.gz").toFile();
185         Files.write(gzFile.toPath(), new byte[] { 0x1f, (byte) 0x8b });
186 
187         Assertions.assertNull(mojo.gzipIfRequested(gzFile), "Expected null for already-gzipped file");
188     }
189 
190     /**
191      * Test that gzipIfRequested creates a .gz file when gzip is enabled and the source file exists.
192      *
193      * @throws Exception
194      *             if test setup fails
195      */
196     @Test
197     void testGzipIfRequested_validFile_createsGzFile() throws Exception {
198         final var mojo = createMojoWithBuildContext();
199         MojoExtension.setVariableValueToObject(mojo, "gzip", true);
200         MojoExtension.setVariableValueToObject(mojo, "level", 9);
201 
202         final File jsFile = tempDir.toPath().resolve("test.js").toFile();
203         Files.write(jsFile.toPath(), "function hello(){}".getBytes(StandardCharsets.UTF_8));
204 
205         final File gzFile = mojo.gzipIfRequested(jsFile);
206 
207         Assertions.assertNotNull(gzFile, "Expected a gzipped file to be created");
208         Assertions.assertTrue(gzFile.exists(), "Gzipped file should exist on disk");
209         Assertions.assertTrue(gzFile.getName().endsWith(".gz"), "Gzipped file should have .gz extension");
210         Assertions.assertTrue(gzFile.length() > 0, "Gzipped file should not be empty");
211     }
212 
213     // ------------------------------------------------------ mojo execute via direct instantiation
214 
215     /**
216      * Test that mojo execution is skipped when the {@code skip} parameter is true.
217      *
218      * @throws Exception
219      *             if setup or execution fails
220      */
221     @Test
222     void testMojoExecute_skipTrue_doesNotProcess() throws Exception {
223         final var mojo = createAndConfigureMojo(tempDir.toPath().resolve("webapp-skip").toFile(),
224                 tempDir.toPath().resolve("output-skip").toFile());
225         MojoExtension.setVariableValueToObject(mojo, "skip", true);
226 
227         // Should complete without any processing (and without NullPointerException)
228         mojo.execute();
229     }
230 
231     /**
232      * Test that JS files are compressed when the mojo processes a webapp directory containing a JS file.
233      *
234      * @throws Exception
235      *             if test fails
236      */
237     @Test
238     void testMojoExecute_compressesJsFile() throws Exception {
239         final File webappDir = tempDir.toPath().resolve("webapp").toFile();
240         webappDir.mkdirs();
241         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
242         Files.write(jsFile.toPath(),
243                 "function greet(name) { var msg = 'Hello ' + name; return msg; }".getBytes(StandardCharsets.UTF_8));
244 
245         final File outputDir = tempDir.toPath().resolve("output").toFile();
246         outputDir.mkdirs();
247 
248         final var mojo = createAndConfigureMojo(webappDir, outputDir);
249         mojo.execute();
250 
251         final File compressedJs = outputDir.toPath().resolve("app-min.js").toFile();
252         Assertions.assertTrue(compressedJs.exists(), "Compressed JS file should be created");
253         Assertions.assertTrue(compressedJs.length() < jsFile.length(),
254                 "Compressed file should be smaller than original");
255     }
256 
257     /**
258      * Test that CSS files are compressed when the mojo processes a webapp directory containing a CSS file.
259      *
260      * @throws Exception
261      *             if test fails
262      */
263     @Test
264     void testMojoExecute_compressesCssFile() throws Exception {
265         final File webappDir = tempDir.toPath().resolve("webapp-css").toFile();
266         webappDir.mkdirs();
267         final File cssFile = webappDir.toPath().resolve("style.css").toFile();
268         Files.write(cssFile.toPath(), "body {\n  background-color: white;\n  color: black;\n  font-size: 14px;\n}\n"
269                 .getBytes(StandardCharsets.UTF_8));
270 
271         final File outputDir = tempDir.toPath().resolve("output-css").toFile();
272         outputDir.mkdirs();
273 
274         final var mojo = createAndConfigureMojo(webappDir, outputDir);
275         mojo.execute();
276 
277         final File compressedCss = outputDir.toPath().resolve("style-min.css").toFile();
278         Assertions.assertTrue(compressedCss.exists(), "Compressed CSS file should be created");
279         Assertions.assertTrue(compressedCss.length() < cssFile.length(),
280                 "Compressed CSS should be smaller than original");
281     }
282 
283     /**
284      * Test that the {@code nosuffix} option writes the compressed output with the original filename.
285      *
286      * @throws Exception
287      *             if test fails
288      */
289     @Test
290     void testMojoExecute_nosuffix_writesOriginalFilename() throws Exception {
291         final File webappDir = tempDir.toPath().resolve("webapp-nosuffix").toFile();
292         webappDir.mkdirs();
293         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
294         Files.write(jsFile.toPath(), "function f(x) { return x * 2; }".getBytes(StandardCharsets.UTF_8));
295 
296         final File outputDir = tempDir.toPath().resolve("output-nosuffix").toFile();
297         outputDir.mkdirs();
298 
299         final var mojo = createAndConfigureMojo(webappDir, outputDir);
300         MojoExtension.setVariableValueToObject(mojo, "nosuffix", true);
301 
302         mojo.execute();
303 
304         final File outputFile = outputDir.toPath().resolve("app.js").toFile();
305         Assertions.assertTrue(outputFile.exists(), "Output JS file with original name should be created");
306     }
307 
308     /**
309      * Test that already-minified files (matching the suffix pattern) are skipped.
310      *
311      * @throws Exception
312      *             if test fails
313      */
314     @Test
315     void testMojoExecute_alreadyMinifiedFile_isSkipped() throws Exception {
316         final File webappDir = tempDir.toPath().resolve("webapp-skipmin").toFile();
317         webappDir.mkdirs();
318         final File minFile = webappDir.toPath().resolve("app-min.js").toFile();
319         Files.write(minFile.toPath(), "function f(x){return x*2;}".getBytes(StandardCharsets.UTF_8));
320 
321         final File outputDir = tempDir.toPath().resolve("output-skipmin").toFile();
322         outputDir.mkdirs();
323 
324         final var mojo = createAndConfigureMojo(webappDir, outputDir);
325         mojo.execute();
326 
327         // app-min.js should not be re-processed (no app-min-min.js created)
328         final File doubleMin = outputDir.toPath().resolve("app-min-min.js").toFile();
329         Assertions.assertFalse(doubleMin.exists(), "Already-minified file should not be double-compressed");
330     }
331 
332     /**
333      * Test that statistics logging does not throw an exception when files are processed.
334      *
335      * @throws Exception
336      *             if test fails
337      */
338     @Test
339     void testMojoExecute_statisticsEnabled_doesNotThrow() throws Exception {
340         final File webappDir = tempDir.toPath().resolve("webapp-stats").toFile();
341         webappDir.mkdirs();
342         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
343         Files.write(jsFile.toPath(), "var x = 1;".getBytes(StandardCharsets.UTF_8));
344 
345         final File outputDir = tempDir.toPath().resolve("output-stats").toFile();
346         outputDir.mkdirs();
347 
348         final var mojo = createAndConfigureMojo(webappDir, outputDir);
349         MojoExtension.setVariableValueToObject(mojo, "statistics", true);
350 
351         // Should not throw
352         mojo.execute();
353     }
354 
355     // ------------------------------------------------------- nocompress option
356 
357     /**
358      * Test that the {@code nocompress} option copies the file as-is (no actual compression).
359      *
360      * @throws Exception
361      *             if test fails
362      */
363     @Test
364     void testMojoExecute_nocompress_copiesFileAsIs() throws Exception {
365         final File webappDir = tempDir.toPath().resolve("webapp-nocompress").toFile();
366         webappDir.mkdirs();
367         final String content = "function hello(name) { var msg = 'Hello ' + name; return msg; }";
368         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
369         Files.write(jsFile.toPath(), content.getBytes(StandardCharsets.UTF_8));
370 
371         final File outputDir = tempDir.toPath().resolve("output-nocompress").toFile();
372         outputDir.mkdirs();
373 
374         final var mojo = createAndConfigureMojo(webappDir, outputDir);
375         MojoExtension.setVariableValueToObject(mojo, "nocompress", true);
376         mojo.execute();
377 
378         final File outputFile = outputDir.toPath().resolve("app-min.js").toFile();
379         Assertions.assertTrue(outputFile.exists(), "Output file should be created even with nocompress");
380         final String outputContent = new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8);
381         Assertions.assertEquals(content, outputContent, "nocompress should copy file content unchanged");
382     }
383 
384     // ------------------------------------------------------- gzip during processFile
385 
386     /**
387      * Test that gzip=true creates a .gz file alongside the compressed output.
388      *
389      * @throws Exception
390      *             if test fails
391      */
392     @Test
393     void testMojoExecute_gzipEnabled_createsGzFile() throws Exception {
394         final File webappDir = tempDir.toPath().resolve("webapp-gzip-proc").toFile();
395         webappDir.mkdirs();
396         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
397         Files.write(jsFile.toPath(),
398                 "function greet(name) { var msg = 'Hello ' + name; return msg; }".getBytes(StandardCharsets.UTF_8));
399 
400         final File outputDir = tempDir.toPath().resolve("output-gzip-proc").toFile();
401         outputDir.mkdirs();
402 
403         final var mojo = createAndConfigureMojo(webappDir, outputDir);
404         MojoExtension.setVariableValueToObject(mojo, "gzip", true);
405         MojoExtension.setVariableValueToObject(mojo, "level", 9);
406         MojoExtension.setVariableValueToObject(mojo, "statistics", true);
407         mojo.execute();
408 
409         final File gzFile = outputDir.toPath().resolve("app-min.js.gz").toFile();
410         Assertions.assertTrue(gzFile.exists(), "A .gz file should be created when gzip=true");
411         Assertions.assertTrue(gzFile.length() > 0, "The .gz file should not be empty");
412     }
413 
414     // ------------------------------------------------------- statistics with CSS
415 
416     /**
417      * Test that statistics logging works correctly for CSS files.
418      *
419      * @throws Exception
420      *             if test fails
421      */
422     @Test
423     void testMojoExecute_statisticsEnabled_withCss() throws Exception {
424         final File webappDir = tempDir.toPath().resolve("webapp-stats-css").toFile();
425         webappDir.mkdirs();
426         final File cssFile = webappDir.toPath().resolve("style.css").toFile();
427         Files.write(cssFile.toPath(), "body {\n  background-color: white;\n  color: black;\n  font-size: 14px;\n}\n"
428                 .getBytes(StandardCharsets.UTF_8));
429 
430         final File outputDir = tempDir.toPath().resolve("output-stats-css").toFile();
431         outputDir.mkdirs();
432 
433         final var mojo = createAndConfigureMojo(webappDir, outputDir);
434         MojoExtension.setVariableValueToObject(mojo, "statistics", true);
435         mojo.execute();
436 
437         final File compressedCss = outputDir.toPath().resolve("style-min.css").toFile();
438         Assertions.assertTrue(compressedCss.exists(), "Compressed CSS should exist");
439     }
440 
441     // ------------------------------------------------------- force option
442 
443     /**
444      * Test that the {@code force} option re-compresses a file even when the output is newer than the input.
445      *
446      * @throws Exception
447      *             if test fails
448      */
449     @Test
450     void testMojoExecute_forceOption_recompressesExistingOutput() throws Exception {
451         final File webappDir = tempDir.toPath().resolve("webapp-force").toFile();
452         webappDir.mkdirs();
453         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
454         Files.write(jsFile.toPath(), "function f(x) { return x * 2; }".getBytes(StandardCharsets.UTF_8));
455 
456         final File outputDir = tempDir.toPath().resolve("output-force").toFile();
457         outputDir.mkdirs();
458 
459         // First run without force
460         final var mojo1 = createAndConfigureMojo(webappDir, outputDir);
461         mojo1.execute();
462 
463         final File outputFile = outputDir.toPath().resolve("app-min.js").toFile();
464         Assertions.assertTrue(outputFile.exists(), "Output should exist after first run");
465         final long firstSize = outputFile.length();
466 
467         // Now run again with force=true; should re-compress
468         final var mojo2 = createAndConfigureMojo(webappDir, outputDir);
469         MojoExtension.setVariableValueToObject(mojo2, "force", true);
470         mojo2.execute();
471 
472         Assertions.assertTrue(outputFile.exists(), "Output should still exist after forced re-compression");
473         Assertions.assertEquals(firstSize, outputFile.length(), "Size should remain the same after re-compression");
474     }
475 
476     // ------------------------------------------------------- useSmallestFile
477 
478     /**
479      * Test that {@code useSmallestFile=true} keeps the original file when compressed output is larger.
480      *
481      * @throws Exception
482      *             if test fails
483      */
484     @Test
485     void testMojoExecute_useSmallestFile_keepOriginalWhenCompressedIsLarger() throws Exception {
486         final File webappDir = tempDir.toPath().resolve("webapp-smallest").toFile();
487         webappDir.mkdirs();
488         // Very short content that may not compress well
489         final File jsFile = webappDir.toPath().resolve("tiny.js").toFile();
490         Files.write(jsFile.toPath(), "var x=1;".getBytes(StandardCharsets.UTF_8));
491 
492         final File outputDir = tempDir.toPath().resolve("output-smallest").toFile();
493         outputDir.mkdirs();
494 
495         final var mojo = createAndConfigureMojo(webappDir, outputDir);
496         MojoExtension.setVariableValueToObject(mojo, "useSmallestFile", true);
497         mojo.execute();
498 
499         // Output file should exist either way
500         final File outputFile = outputDir.toPath().resolve("tiny-min.js").toFile();
501         Assertions.assertTrue(outputFile.exists(), "Output file should exist with useSmallestFile=true");
502     }
503 
504     // ------------------------------------------------------- resources processing
505 
506     /**
507      * Test that resource directories are processed when {@code excludeResources=false}.
508      *
509      * @throws Exception
510      *             if test fails
511      */
512     @Test
513     void testMojoExecute_resourcesProcessed_whenNotExcluded() throws Exception {
514         final File resourceDir = tempDir.toPath().resolve("resources").toFile();
515         resourceDir.mkdirs();
516         final File cssFile = resourceDir.toPath().resolve("theme.css").toFile();
517         Files.write(cssFile.toPath(),
518                 "body {\n  background: white;\n  color: navy;\n}\n".getBytes(StandardCharsets.UTF_8));
519 
520         final File outputDir = tempDir.toPath().resolve("output-resources").toFile();
521         outputDir.mkdirs();
522 
523         final var mojo = createAndConfigureMojo(tempDir.toPath().resolve("no-webapp").toFile(),
524                 tempDir.toPath().resolve("no-webapp-out").toFile());
525         MojoExtension.setVariableValueToObject(mojo, "excludeWarSourceDirectory", true);
526         MojoExtension.setVariableValueToObject(mojo, "excludeResources", false);
527         MojoExtension.setVariableValueToObject(mojo, "outputDirectory", outputDir);
528 
529         // Set up a resource entry pointing to our resource directory
530         final var resource = new Resource();
531         resource.setDirectory(resourceDir.getAbsolutePath());
532         MojoExtension.setVariableValueToObject(mojo, "resources", List.of(resource));
533 
534         mojo.execute();
535 
536         final File compressedCss = outputDir.toPath().resolve("theme-min.css").toFile();
537         Assertions.assertTrue(compressedCss.exists(), "CSS from resource directory should be compressed");
538     }
539 
540     // ------------------------------------------------------- linebreakpos option
541 
542     /**
543      * Test that setting linebreakpos produces output with line breaks in JS.
544      *
545      * @throws Exception
546      *             if test fails
547      */
548     @Test
549     void testMojoExecute_withLinebreakpos_jsFile() throws Exception {
550         final File webappDir = tempDir.toPath().resolve("webapp-linebreak").toFile();
551         webappDir.mkdirs();
552         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
553         Files.write(jsFile.toPath(),
554                 "function a(x) { return x + 1; } function b(x) { return x * 2; }".getBytes(StandardCharsets.UTF_8));
555 
556         final File outputDir = tempDir.toPath().resolve("output-linebreak").toFile();
557         outputDir.mkdirs();
558 
559         final var mojo = createAndConfigureMojo(webappDir, outputDir);
560         MojoExtension.setVariableValueToObject(mojo, "linebreakpos", 20);
561         mojo.execute();
562 
563         final File compressedJs = outputDir.toPath().resolve("app-min.js").toFile();
564         Assertions.assertTrue(compressedJs.exists(), "Compressed JS with linebreakpos should be created");
565     }
566 
567     // ------------------------------------------------------- CSS with gzip statistics
568 
569     /**
570      * Test statistics + gzip together for a CSS file.
571      *
572      * @throws Exception
573      *             if test fails
574      */
575     @Test
576     void testMojoExecute_gzipAndStatistics_cssFile() throws Exception {
577         final File webappDir = tempDir.toPath().resolve("webapp-gzip-css").toFile();
578         webappDir.mkdirs();
579         final File cssFile = webappDir.toPath().resolve("main.css").toFile();
580         Files.write(cssFile.toPath(), "body {\n  margin: 0;\n  padding: 0;\n  font-family: Arial, sans-serif;\n}\n"
581                 .getBytes(StandardCharsets.UTF_8));
582 
583         final File outputDir = tempDir.toPath().resolve("output-gzip-css").toFile();
584         outputDir.mkdirs();
585 
586         final var mojo = createAndConfigureMojo(webappDir, outputDir);
587         MojoExtension.setVariableValueToObject(mojo, "gzip", true);
588         MojoExtension.setVariableValueToObject(mojo, "level", 9);
589         MojoExtension.setVariableValueToObject(mojo, "statistics", true);
590         mojo.execute();
591 
592         final File compressedCss = outputDir.toPath().resolve("main-min.css").toFile();
593         Assertions.assertTrue(compressedCss.exists(), "Compressed CSS should be created");
594         final File gzCss = outputDir.toPath().resolve("main-min.css.gz").toFile();
595         Assertions.assertTrue(gzCss.exists(), "Gzipped CSS should be created");
596     }
597 
598     // ------------------------------------------------------- nomunge / disableOptimizations
599 
600     /**
601      * Test that nomunge=true keeps variable names unobfuscated.
602      *
603      * @throws Exception
604      *             if test fails
605      */
606     @Test
607     void testMojoExecute_nomunge_doesNotObfuscate() throws Exception {
608         final File webappDir = tempDir.toPath().resolve("webapp-nomunge").toFile();
609         webappDir.mkdirs();
610         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
611         Files.write(jsFile.toPath(),
612                 "function longFunctionName(longParamName) { return longParamName; }".getBytes(StandardCharsets.UTF_8));
613 
614         final File outputDir = tempDir.toPath().resolve("output-nomunge").toFile();
615         outputDir.mkdirs();
616 
617         final var mojo = createAndConfigureMojo(webappDir, outputDir);
618         MojoExtension.setVariableValueToObject(mojo, "nomunge", true);
619         mojo.execute();
620 
621         final File outputFile = outputDir.toPath().resolve("app-min.js").toFile();
622         Assertions.assertTrue(outputFile.exists(), "Output should exist with nomunge=true");
623         final String outputContent = new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8);
624         Assertions.assertTrue(outputContent.contains("longFunctionName"),
625                 "With nomunge=true, function names should be preserved");
626     }
627 
628     /**
629      * Test that disableOptimizations=true still produces valid output.
630      *
631      * @throws Exception
632      *             if test fails
633      */
634     @Test
635     void testMojoExecute_disableOptimizations_producesOutput() throws Exception {
636         final File webappDir = tempDir.toPath().resolve("webapp-disableopt").toFile();
637         webappDir.mkdirs();
638         final File jsFile = webappDir.toPath().resolve("app.js").toFile();
639         Files.write(jsFile.toPath(), "var a = 1; var b = 2; var c = a + b;".getBytes(StandardCharsets.UTF_8));
640 
641         final File outputDir = tempDir.toPath().resolve("output-disableopt").toFile();
642         outputDir.mkdirs();
643 
644         final var mojo = createAndConfigureMojo(webappDir, outputDir);
645         MojoExtension.setVariableValueToObject(mojo, "disableOptimizations", true);
646         mojo.execute();
647 
648         Assertions.assertTrue(outputDir.toPath().resolve("app-min.js").toFile().exists(),
649                 "Output should exist with disableOptimizations=true");
650     }
651 
652     // ----------------------------------------------------------- helper methods
653 
654     /**
655      * Creates a YuiCompressorMojo configured with a mock build context, source and output directories.
656      *
657      * @param warSourceDirectory
658      *            the webapp source directory
659      * @param webappDirectory
660      *            the webapp output directory
661      *
662      * @return configured mojo instance
663      *
664      * @throws Exception
665      *             if field injection fails
666      */
667     private YuiCompressorMojo createAndConfigureMojo(File warSourceDirectory, File webappDirectory) throws Exception {
668         final var mojo = new YuiCompressorMojo();
669         final var buildContext = buildDefaultBuildContext();
670         MojoExtension.setVariableValueToObject(mojo, "buildContext", buildContext);
671         MojoExtension.setVariableValueToObject(mojo, "encoding", "UTF-8");
672         MojoExtension.setVariableValueToObject(mojo, "suffix", "-min");
673         MojoExtension.setVariableValueToObject(mojo, "nosuffix", false);
674         MojoExtension.setVariableValueToObject(mojo, "linebreakpos", -1);
675         MojoExtension.setVariableValueToObject(mojo, "nocompress", false);
676         MojoExtension.setVariableValueToObject(mojo, "nomunge", false);
677         MojoExtension.setVariableValueToObject(mojo, "preserveAllSemiColons", false);
678         MojoExtension.setVariableValueToObject(mojo, "disableOptimizations", false);
679         MojoExtension.setVariableValueToObject(mojo, "force", false);
680         MojoExtension.setVariableValueToObject(mojo, "gzip", false);
681         MojoExtension.setVariableValueToObject(mojo, "level", 9);
682         MojoExtension.setVariableValueToObject(mojo, "statistics", false);
683         MojoExtension.setVariableValueToObject(mojo, "preProcessAggregates", false);
684         MojoExtension.setVariableValueToObject(mojo, "useSmallestFile", false);
685         MojoExtension.setVariableValueToObject(mojo, "skip", false);
686         MojoExtension.setVariableValueToObject(mojo, "jswarn", false);
687         MojoExtension.setVariableValueToObject(mojo, "failOnWarning", false);
688         MojoExtension.setVariableValueToObject(mojo, "excludeResources", true);
689         MojoExtension.setVariableValueToObject(mojo, "excludeWarSourceDirectory", false);
690         MojoExtension.setVariableValueToObject(mojo, "warSourceDirectory", warSourceDirectory);
691         MojoExtension.setVariableValueToObject(mojo, "webappDirectory", webappDirectory);
692         MojoExtension.setVariableValueToObject(mojo, "outputDirectory", tempDir.toPath().resolve("classes").toFile());
693         MojoExtension.setVariableValueToObject(mojo, "sourceDirectory",
694                 tempDir.toPath().resolve("nonexistent-source").toFile());
695         MojoExtension.setVariableValueToObject(mojo, "resources", List.of());
696         return mojo;
697     }
698 
699     /**
700      * Creates a bare YuiCompressorMojo with only the build context injected.
701      *
702      * @return mojo with build context
703      *
704      * @throws Exception
705      *             if field injection fails
706      */
707     private YuiCompressorMojo createMojoWithBuildContext() throws Exception {
708         final var mojo = new YuiCompressorMojo();
709         MojoExtension.setVariableValueToObject(mojo, "buildContext", buildDefaultBuildContext());
710         return mojo;
711     }
712 
713     /**
714      * Builds a {@link DefaultBuildContext} backed by a lenient mock of the sonatype legacy {@link BuildContext}.
715      *
716      * @return a ready-to-use DefaultBuildContext
717      *
718      * @throws Exception
719      *             if mocking fails
720      */
721     private DefaultBuildContext buildDefaultBuildContext() throws Exception {
722         final BuildContext legacyCtx = Mockito.mock(BuildContext.class);
723         Mockito.lenient().when(legacyCtx.newFileOutputStream(ArgumentMatchers.any(File.class)))
724                 .thenAnswer(inv -> Files.newOutputStream(((File) inv.getArgument(0)).toPath()));
725         return new DefaultBuildContext(legacyCtx);
726     }
727 
728     /**
729      * Reads a private or protected field value by walking the class hierarchy.
730      *
731      * @param target
732      *            the object to inspect
733      * @param fieldName
734      *            the field name
735      *
736      * @return the field value
737      */
738     @SuppressWarnings("unused")
739     private static Object getField(Object target, String fieldName) {
740         try {
741             Class<?> clazz = target.getClass();
742             Field field = null;
743             while (clazz != null) {
744                 try {
745                     field = clazz.getDeclaredField(fieldName);
746                     break;
747                 } catch (NoSuchFieldException e) {
748                     clazz = clazz.getSuperclass();
749                 }
750             }
751             if (field == null) {
752                 throw new NoSuchFieldException(fieldName);
753             }
754             field.setAccessible(true);
755             return field.get(target);
756         } catch (NoSuchFieldException | IllegalAccessException e) {
757             throw new RuntimeException("Cannot get field '" + fieldName + "'", e);
758         }
759     }
760 }