View Javadoc
1   /*
2    * Copyright (c) 2002-2025 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit;
16  
17  import java.io.File;
18  import java.io.IOException;
19  import java.lang.reflect.Constructor;
20  import java.lang.reflect.Method;
21  import java.lang.reflect.Modifier;
22  import java.nio.charset.Charset;
23  import java.nio.charset.StandardCharsets;
24  import java.nio.file.Files;
25  import java.nio.file.Paths;
26  import java.time.LocalDate;
27  import java.util.ArrayList;
28  import java.util.Calendar;
29  import java.util.Iterator;
30  import java.util.List;
31  import java.util.Locale;
32  import java.util.regex.Matcher;
33  import java.util.regex.Pattern;
34  
35  import org.apache.commons.io.FileUtils;
36  import org.apache.commons.lang3.StringUtils;
37  import org.apache.commons.logging.Log;
38  import org.junit.jupiter.api.AfterAll;
39  import org.junit.jupiter.api.AfterEach;
40  import org.junit.jupiter.api.Assertions;
41  import org.junit.jupiter.api.BeforeAll;
42  import org.junit.jupiter.api.BeforeEach;
43  import org.junit.jupiter.api.RepeatedTest;
44  import org.junit.jupiter.api.Test;
45  import org.junitpioneer.jupiter.RetryingTest;
46  
47  /**
48   * Test of coding style for issues which cannot be detected by Checkstyle.
49   *
50   * @author Ahmed Ashour
51   * @author Ronald Brill
52   * @author Sven Strickroth
53   * @author Kristof Neirynck
54   */
55  public class CodeStyleTest {
56  
57      private static final Charset SOURCE_ENCODING = StandardCharsets.UTF_8;
58      private static final Pattern LEADING_WHITESPACE = Pattern.compile("^\\s+");
59      private static final Pattern LOG_STATIC_STRING =
60                                      Pattern.compile("^\\s*LOG\\.[a-z]+\\(\"[^\"]*\"(, [a-zA-Z_]+)?\\);");
61      private List<String> failures_ = new ArrayList<>();
62      private String title_ = "unknown";
63  
64      /**
65       * After.
66       * @throws IOException in case of error
67       */
68      @AfterEach
69      public void after() throws IOException {
70          final StringBuilder sb = new StringBuilder();
71          for (final String error : failures_) {
72              sb.append('\n').append(error);
73          }
74  
75          if (System.getenv("EXPORT_FAILURES") != null) {
76              Files.write(Paths.get("target", title_ + ".txt"), failures_);
77          }
78  
79          final int errorsNumber = failures_.size();
80          if (errorsNumber == 1) {
81              Assertions.fail(title_ + " error: " + sb);
82          }
83          else if (errorsNumber > 1) {
84              Assertions.fail(title_ + " " + errorsNumber + " errors: " + sb);
85          }
86      }
87  
88      private void addFailure(final String file, final int line, final String error) {
89          failures_.add(file + ", line " + (line <= 0 ? 1 : line) + ": " + error);
90      }
91  
92      /**
93       * @throws IOException if the test fails
94       */
95      @Test
96      public void codeStyle() throws IOException {
97          title_ = "CodeStyle";
98          final List<File> files = new ArrayList<>();
99          addAll(new File("src/main"), files);
100         addAll(new File("src/test"), files);
101         final List<String> classNames = getClassNames(files);
102         process(files, classNames);
103         // for (final String className : classNames) {
104         //     addFailure("Not used " + className);
105         // }
106 
107         licenseYear();
108         versionYear();
109         parentInPom();
110     }
111 
112     private static List<String> getClassNames(final List<File> files) {
113         final List<String> list = new ArrayList<>();
114         for (final File file : files) {
115             String fileName = file.getName();
116             if (fileName.endsWith(".java")) {
117                 fileName = fileName.substring(0, fileName.length() - 5);
118                 fileName = fileName.substring(fileName.lastIndexOf('.') + 1);
119                 list.add(fileName);
120             }
121         }
122         return list;
123     }
124 
125     private void addAll(final File dir, final List<File> files) throws IOException {
126         final File[] children = dir.listFiles();
127         if (children != null) {
128             for (final File child : children) {
129                 if (child.isDirectory()
130                         && !".git".equals(child.getName())
131                         && !"brotli".equals(child.getName())
132                         && !("test".equals(dir.getName()) && "resources".equals(child.getName()))) {
133                     addAll(child, files);
134                 }
135                 else {
136                     files.add(child);
137                 }
138             }
139         }
140     }
141 
142     private void process(final List<File> files, final List<String> classNames) throws IOException {
143         for (final File file : files) {
144             final String relativePath = file.getAbsolutePath().substring(new File(".").getAbsolutePath().length() - 1);
145             if (file.getName().endsWith(".java")) {
146                 final List<String> lines = FileUtils.readLines(file, SOURCE_ENCODING);
147                 openingCurlyBracket(lines, relativePath);
148                 year(lines, relativePath);
149                 javaDocFirstLine(lines, relativePath);
150                 classJavaDoc(lines, relativePath);
151                 methodFirstLine(lines, relativePath);
152                 methodLastLine(lines, relativePath);
153                 lineBetweenMethods(lines, relativePath);
154                 runWith(lines, relativePath);
155                 vs85aspx(lines, relativePath);
156                 deprecated(lines, relativePath);
157                 staticJSMethod(lines, relativePath);
158                 singleAlert(lines, relativePath);
159                 staticLoggers(lines, relativePath);
160                 loggingEnabled(lines, relativePath);
161                 alerts(lines, relativePath);
162                 className(lines, relativePath);
163                 classNameUsed(lines, classNames, relativePath);
164                 spaces(lines, relativePath);
165                 indentation(lines, relativePath);
166             }
167         }
168     }
169 
170     /**
171      * Ensures that no opening curly bracket exists by itself in a single line.
172      */
173     private void openingCurlyBracket(final List<String> lines, final String path) {
174         int index = 1;
175         for (final String line : lines) {
176             if ("{".equals(line.trim())) {
177                 addFailure(path, index, "Opening curly bracket is alone");
178             }
179             index++;
180         }
181     }
182 
183     /**
184      * Checks the year in the source.
185      */
186     private void year(final List<String> lines, final String path) {
187         final int year = Calendar.getInstance(Locale.ROOT).get(Calendar.YEAR);
188         if (lines.size() < 2 || !lines.get(1).contains("Copyright (c) 2002-" + year)) {
189             addFailure(path, lines.size() < 2 ? 0 : 1, "Incorrect year");
190         }
191     }
192 
193     /**
194      * Checks the JavaDoc first line, it should not be empty, and should not start with lower-case.
195      */
196     private void javaDocFirstLine(final List<String> lines, final String relativePath) {
197         for (int index = 1; index < lines.size(); index++) {
198             final String previousLine = lines.get(index - 1);
199             final String currentLine = lines.get(index);
200             if ("/**".equals(previousLine.trim())) {
201                 if ("*".equals(currentLine.trim()) || currentLine.contains("*/")) {
202                     addFailure(relativePath, index + 1, "Empty first line in JavaDoc");
203                 }
204                 if (currentLine.trim().startsWith("*")) {
205                     final String text = currentLine.trim().substring(1).trim();
206                     if (!text.isEmpty() && Character.isLowerCase(text.charAt(0))) {
207                         addFailure(relativePath, index + 1, "Lower case start of text in JavaDoc");
208                     }
209                 }
210             }
211         }
212     }
213 
214     /**
215      * Checks the JavaDoc for class should be superseded with an empty line.
216      */
217     private void classJavaDoc(final List<String> lines, final String relativePath) {
218         for (int index = 1; index < lines.size(); index++) {
219             final String previousLine = lines.get(index - 1);
220             final String currentLine = lines.get(index);
221             if (currentLine.startsWith("/**") && !previousLine.isEmpty()) {
222                 addFailure(relativePath, index, "No empty line the beginning of JavaDoc");
223             }
224         }
225     }
226 
227     /**
228      * Checks the method first line, it should not be empty.
229      */
230     private void methodFirstLine(final List<String> lines, final String relativePath) {
231         for (int index = 0; index < lines.size() - 1; index++) {
232             final String line = lines.get(index);
233             if (StringUtils.isBlank(lines.get(index + 1))
234                 && line.length() > 4 && index > 0 && lines.get(index - 1).startsWith("    ")
235                 && Character.isWhitespace(line.charAt(0)) && line.endsWith("{")
236                 && !line.contains(" class ") && !line.contains(" interface ") && !line.contains(" @interface ")
237                 && (!Character.isWhitespace(line.charAt(4))
238                     || line.trim().startsWith("public") || line.trim().startsWith("protected")
239                     || line.trim().startsWith("private"))) {
240                 addFailure(relativePath, index + 2, "Empty line");
241             }
242         }
243     }
244 
245     /**
246      * Checks the method last line, it should not be empty.
247      */
248     private void methodLastLine(final List<String> lines, final String relativePath) {
249         for (int index = 0; index < lines.size() - 1; index++) {
250             final String line = lines.get(index);
251             final String nextLine = lines.get(index + 1);
252             if (StringUtils.isBlank(line) && "    }".equals(nextLine)) {
253                 addFailure(relativePath, index + 1, "Empty line");
254             }
255         }
256     }
257 
258     /**
259      * Checks that empty line must exist between consecutive methods.
260      */
261     private void lineBetweenMethods(final List<String> lines, final String relativePath) {
262         for (int index = 0; index < lines.size() - 1; index++) {
263             final String line = lines.get(index);
264             final String nextLine = lines.get(index + 1);
265             if ("    }".equals(line) && !nextLine.isEmpty() && !"}".equals(nextLine)) {
266                 addFailure(relativePath, index + 1, "Non-empty line");
267             }
268             if (nextLine.trim().equals("/**") && line.trim().equals("}")) {
269                 addFailure(relativePath, index + 2, "Non-empty line");
270             }
271         }
272     }
273 
274     /**
275      * @throws Exception if an error occurs
276      */
277     @Test
278     public void xmlStyle() throws Exception {
279         title_ = "XMLStyle";
280         processXML(new File("."), false);
281         processXML(new File("src/main/resources"), true);
282         processXML(new File("src/assembly"), true);
283         processXML(new File("src/changes"), true);
284     }
285 
286     private void processXML(final File dir, final boolean recursive) throws Exception {
287         final File[] files = dir.listFiles();
288         if (files != null) {
289             for (final File file : files) {
290                 if (file.isDirectory() && !".git".equals(file.getName())) {
291                     if (recursive) {
292                         processXML(file, true);
293                     }
294                 }
295                 else {
296                     if (file.getName().endsWith(".xml")) {
297                         final List<String> lines = FileUtils.readLines(file, SOURCE_ENCODING);
298                         final String relativePath = file.getAbsolutePath().substring(
299                                 new File(".").getAbsolutePath().length() - 1);
300                         mixedIndentation(lines, relativePath);
301                         trailingWhitespace(lines, relativePath);
302                         badIndentationLevels(lines, relativePath);
303                     }
304                 }
305             }
306         }
307     }
308 
309     /**
310      * Verifies that no XML files have mixed indentation (tabs and spaces, mixed).
311      */
312     private void mixedIndentation(final List<String> lines, final String relativePath) {
313         for (int i = 0; i < lines.size(); i++) {
314             final String line = lines.get(i);
315             if (line.indexOf('\t') != -1) {
316                 addFailure(relativePath, i + 1, "Mixed indentation");
317             }
318         }
319     }
320 
321     /**
322      * Verifies that no XML files have trailing whitespace.
323      */
324     private void trailingWhitespace(final List<String> lines, final String relativePath) {
325         for (int i = 0; i < lines.size(); i++) {
326             final String line = lines.get(i);
327             if (!line.isEmpty()) {
328                 final char last = line.charAt(line.length() - 1);
329                 if (Character.isWhitespace(last)) {
330                     addFailure(relativePath, i + 1, "Trailing whitespace");
331                 }
332             }
333         }
334     }
335 
336     /**
337      * Verifies that no XML files have bad indentation levels (each indentation level is 4 spaces).
338      */
339     private void badIndentationLevels(final List<String> lines, final String relativePath) {
340         for (int i = 0; i < lines.size(); i++) {
341             final int indentation = getIndentation(lines.get(i));
342             if (indentation % 4 != 0) {
343                 addFailure(relativePath, i + 1, "Bad indentation level (" + indentation + ")");
344             }
345         }
346     }
347 
348     /**
349      * Checks the year in {@code LICENSE.txt}.
350      */
351     private void licenseYear() throws IOException {
352         final List<String> lines = FileUtils.readLines(new File("checkstyle.xml"), SOURCE_ENCODING);
353         boolean check = false;
354         final String copyright = "Copyright (c) 2002-" + LocalDate.now().getYear();
355         for (final String line : lines) {
356             if (line.contains("<property name=\"header\"")) {
357                 if (!line.contains(copyright)) {
358                     addFailure("checkstyle.xml", 0, "Incorrect year in checkstyle.xml");
359                 }
360                 check = true;
361             }
362         }
363         if (!check) {
364             addFailure("checkstyle.xml", 0, "No \"header\" found");
365         }
366     }
367 
368     /**
369      * Checks the year in the {@link Version}.
370      */
371     private void versionYear() throws IOException {
372         final List<String> lines =
373                 FileUtils.readLines(new File("src/main/java/org/htmlunit/Version.java"),
374                         SOURCE_ENCODING);
375         for (final String line : lines) {
376             if (line.contains("return \"Copyright (c) 2002-" + Calendar.getInstance(Locale.ROOT).get(Calendar.YEAR))) {
377                 return;
378             }
379         }
380         addFailure("src/main/java/org/htmlunit/Version.java", 0, "Incorrect year in Version.getCopyright()");
381     }
382 
383     /**
384      * Verifies no &lt;parent&gt; tag in {@code pom.xml}.
385      */
386     private void parentInPom() throws IOException {
387         final List<String> lines = FileUtils.readLines(new File("pom.xml"), SOURCE_ENCODING);
388         for (int i = 0; i < lines.size(); i++) {
389             if (lines.get(i).contains("<parent>")) {
390                 addFailure("pom.xml", i + 1, "'pom.xml' should not have <parent> tag");
391                 break;
392             }
393         }
394     }
395 
396     /**
397      * Verifies that no direct instantiation of WebClient from a test that runs with BrowserRunner.
398      */
399     private void runWith(final List<String> lines, final String relativePath) {
400         if (relativePath.replace('\\', '/').contains("src/test/java")
401                 && !relativePath.contains("CodeStyleTest")
402                 && !relativePath.contains("WebClient9Test")
403                 && !relativePath.contains("FaqTest")) {
404             boolean runWith = false;
405             boolean browserNone = true;
406             int index = 1;
407             for (final String line : lines) {
408                 if (line.contains("@RunWith(BrowserRunner.class)")) {
409                     runWith = true;
410                 }
411                 if (line.contains("@Test")) {
412                     browserNone = false;
413                 }
414                 if (relativePath.contains("JavaScriptEngineTest") && line.contains("nonStandardBrowserVersion")) {
415                     browserNone = true;
416                 }
417                 if (runWith) {
418                     if (!browserNone && line.contains("new WebClient(") && !line.contains("getBrowserVersion()")) {
419                         addFailure(relativePath, index,
420                                 "Never directly instantiate WebClient, please use getWebClient() instead.");
421                     }
422                     if (line.contains("notYetImplemented()")) {
423                         addFailure(relativePath, index, "Use @NotYetImplemented instead of notYetImplemented()");
424                     }
425                 }
426                 index++;
427             }
428         }
429     }
430 
431     /**
432      * Verifies that no "(VS.85).aspx" token exists (which is sometimes used in MSDN documentation).
433      */
434     private void vs85aspx(final List<String> lines, final String relativePath) {
435         if (!relativePath.contains("CodeStyleTest")) {
436             int i = 0;
437             for (final String line : lines) {
438                 if (line.contains("(VS.85).aspx")) {
439                     addFailure(relativePath, i + 1, "Please remove \"(VS.85)\" from the URL");
440                 }
441                 i++;
442             }
443         }
444     }
445 
446     /**
447      * Verifies that deprecated tag is followed by "As of " or "since ", and '@Deprecated' annotation follows.
448      */
449     private void deprecated(final List<String> lines, final String relativePath) {
450         int i = 0;
451         for (String line : lines) {
452             line = line.trim().toLowerCase(Locale.ROOT);
453             if (line.startsWith("* @deprecated")) {
454                 if (!line.startsWith("* @deprecated as of ") && !line.startsWith("* @deprecated since ")) {
455                     addFailure(relativePath, i + 1,
456                             "@deprecated must be immediately followed by \"As of \" or \"since \"");
457                 }
458                 if (!getAnnotations(lines, i).contains("@Deprecated")) {
459                     addFailure(relativePath, i + 1, "No \"@Deprecated\" annotation");
460                 }
461             }
462             i++;
463         }
464     }
465 
466     /**
467      * Returns all annotation lines that comes after the given 'javadoc' line.
468      * @param lines source code lines
469      * @param index the index to start searching from, must be a 'javadoc' line.
470      */
471     private static List<String> getAnnotations(final List<String> lines, int index) {
472         final List<String> annotations = new ArrayList<>();
473         while (!lines.get(index++).trim().endsWith("*/")) {
474             //empty;
475         }
476         while (lines.get(index).trim().startsWith("@")) {
477             annotations.add(lines.get(index++).trim());
478         }
479         return annotations;
480     }
481 
482     /**
483      * Verifies that no static JavaScript method exists.
484      */
485     private void staticJSMethod(final List<String> lines, final String relativePath) {
486         if (relativePath.endsWith("Console.java")) {
487             return;
488         }
489         int i = 0;
490         for (final String line : lines) {
491             if (line.contains(" static ")
492                     && (line.contains(" jsxFunction_") || line.contains(" jsxGet_") || line.contains(" jsxSet_"))
493                     && !line.contains(" jsxFunction_write") && !line.contains(" jsxFunction_insertBefore")
494                     && !line.contains(" jsxFunction_drawImage")) {
495                 addFailure(relativePath, i + 1, "Use of static JavaScript function");
496             }
497             i++;
498         }
499     }
500 
501     /**
502      * Single @Alert does not need curly brackets.
503      */
504     private void singleAlert(final List<String> lines, final String relativePath) {
505         int i = 0;
506         for (final String line : lines) {
507             if (line.trim().startsWith("@Alerts") && line.contains("@Alerts({") && line.contains("})")) {
508                 final String alert = line.substring(line.indexOf('{'), line.indexOf('}'));
509                 if (!alert.contains(",") && alert.contains("\"")
510                         && alert.indexOf('"', alert.indexOf('"') + 1) != -1) {
511                     addFailure(relativePath, i + 1, "No need for curly brackets");
512                 }
513             }
514             i++;
515         }
516     }
517 
518     /**
519      * Verifies that only static loggers exist.
520      */
521     private void staticLoggers(final List<String> lines, final String relativePath) {
522         int i = 0;
523         final String logClassName = Log.class.getSimpleName();
524         for (String line : lines) {
525             line = line.trim();
526             if (line.contains(" " + logClassName + " ")
527                     && !line.contains(" LOG ")
528                     && !line.contains(" static ")
529                     && !line.startsWith("//")
530                     && !line.contains("webConsoleLogger_") // this one is there by design
531                     && !line.contains("(final Log logger)") // logger as parameter
532                     && !line.contains("httpclient.wire")) {
533                 addFailure(relativePath, i + 1, "Non-static logger detected");
534             }
535             i++;
536         }
537     }
538 
539     /**
540      * Verifies that there is code to check log enablement.
541      * <p> For example,
542      * <code><pre>
543      *    if (log.isDebugEnabled()) {
544      *        ... do something expensive ...
545      *        log.debug(theResult);
546      *    }
547      * </pre></code>
548      * </p>
549      */
550     private void loggingEnabled(final List<String> lines, final String relativePath) {
551         if (relativePath.contains("CodeStyleTest")) {
552             return;
553         }
554         int i = 0;
555         for (final String line : lines) {
556             if (line.contains("LOG.trace(") && !LOG_STATIC_STRING.matcher(line).matches()) {
557                 loggingEnabled(lines, i, "Trace", relativePath);
558             }
559             else if (line.contains("LOG.debug(") && !LOG_STATIC_STRING.matcher(line).matches()) {
560                 loggingEnabled(lines, i, "Debug", relativePath);
561             }
562             i++;
563         }
564     }
565 
566     private void loggingEnabled(final List<String> lines, final int index, final String method,
567             final String relativePath) {
568         final int indentation = getIndentation(lines.get(index));
569         for (int i = index - 1; i >= 0; i--) {
570             final String line = lines.get(i);
571             if (getIndentation(line) < indentation && line.contains("LOG.is" + method + "Enabled()")) {
572                 return;
573             }
574             if (getIndentation(line) == 4) { // a method
575                 addFailure(relativePath, index + 1, "Must be inside a \"if (LOG.is" + method + "Enabled())\" check");
576                 return;
577             }
578         }
579     }
580 
581     private static int getIndentation(final String line) {
582         final Matcher matcher = LEADING_WHITESPACE.matcher(line);
583         if (matcher.find()) {
584             return matcher.end() - matcher.start();
585         }
586         return 0;
587     }
588 
589     /**
590      * Verifies that \@Alerts is correctly defined.
591      */
592     private void alerts(final List<String> lines, final String relativePath) {
593         for (int i = 0; i < lines.size(); i++) {
594             if (lines.get(i).startsWith("    @Alerts(")) {
595                 final List<String> alerts = alertsToList(lines, i, true);
596                 alertVerify(alerts, relativePath, i);
597             }
598         }
599     }
600 
601     /**
602      * Verifies that the class name is used.
603      */
604     private static void classNameUsed(final List<String> lines, final List<String> classNames,
605             final String relativePath) {
606         String simpleName = relativePath.substring(0, relativePath.length() - 5);
607         simpleName = simpleName.substring(simpleName.lastIndexOf(File.separator) + 1);
608         for (final String line : lines) {
609             for (final Iterator<String> it = classNames.iterator(); it.hasNext();) {
610                 final String className = it.next();
611                 if (line.contains(className) && !className.equals(simpleName)) {
612                     it.remove();
613                 }
614             }
615         }
616     }
617 
618     /**
619      * Verifies that the class name is used.
620      */
621     private void className(final List<String> lines, final String relativePath) {
622         if (relativePath.contains("main") && relativePath.contains("host")) {
623             String fileName = relativePath.substring(0, relativePath.length() - 5);
624             fileName = fileName.substring(fileName.lastIndexOf(File.separator) + 1);
625             String wrongName = null;
626             int i = 0;
627             for (final String line : lines) {
628                 if (line.startsWith(" * ")) {
629                     int p0 = line.indexOf("{@code ");
630                     if (p0 != -1) {
631                         p0 = p0 + "{@code ".length();
632                         final int p1 = line.indexOf('}', p0 + 1);
633                         final String name = line.substring(p0, p1);
634                         if (!name.equals(fileName)) {
635                             wrongName = name;
636                         }
637                     }
638                 }
639                 else if (line.startsWith("@JsxClass")) {
640                     int p0 = line.indexOf("className = \"");
641                     if (p0 != -1) {
642                         p0 = p0 + "className = \"".length();
643                         final int p1 = line.indexOf("\"", p0 + 1);
644                         String name = line.substring(p0, p1);
645                         // JsxClass starts with lower case
646                         if (Character.isLowerCase(name.charAt(0))) {
647                             name = StringUtils.capitalize(name);
648                             if (name.equals(fileName)) {
649                                 wrongName = null;
650                             }
651                         }
652                     }
653                 }
654                 else if (line.startsWith("public class")) {
655                     if (wrongName != null) {
656                         addFailure(relativePath, i + 1, "Incorrect host class '" + wrongName + "'");
657                     }
658                     return;
659                 }
660                 ++i;
661             }
662         }
663     }
664 
665     /**
666      * Returns array of String of the alerts which are in the specified index.
667      *
668      * @param lines the list of strings
669      * @param alertsIndex the index in which the \@Alerts is defined
670      * @return array of alert strings
671      */
672     public static List<String> alertsToList(final List<String> lines, final int alertsIndex) {
673         return alertsToList(lines, alertsIndex, false);
674     }
675 
676     private static List<String> alertsToList(final List<String> lines, final int alertsIndex,
677             final boolean preserveCommas) {
678         if ("    @Alerts".equals(lines.get(alertsIndex))) {
679             lines.set(alertsIndex, "    @Alerts()");
680         }
681         if (!lines.get(alertsIndex).startsWith("    @Alerts(")) {
682             throw new IllegalArgumentException("No @Alerts found in " + (alertsIndex + 1));
683         }
684         final StringBuilder alerts = new StringBuilder();
685         for (int i = alertsIndex;; i++) {
686             final String line = lines.get(i);
687             if (alerts.length() != 0) {
688                 alerts.append('\n');
689             }
690             if (line.startsWith("    @Alerts(")) {
691                 alerts.append(line.substring("    @Alerts(".length()));
692             }
693             else {
694                 alerts.append(line);
695             }
696             if (line.endsWith(")")) {
697                 alerts.deleteCharAt(alerts.length() - 1);
698                 break;
699             }
700         }
701         final List<String> list = alertsToList(alerts.toString());
702         if (!preserveCommas) {
703             for (int i = 0; i < list.size(); i++) {
704                 String value = list.get(i);
705                 if (value.startsWith(",")) {
706                     value = value.substring(1).trim();
707                 }
708                 list.set(i, value);
709             }
710         }
711         return list;
712     }
713 
714     /**
715      * Verifies a specific \@Alerts definition.
716      */
717     private void alertVerify(final List<String> alerts, final String relativePath, final int lineIndex) {
718         if (alerts.size() == 1) {
719             if (alerts.get(0).contains("DEFAULT")) {
720                 addFailure(relativePath, lineIndex + 1, "No need for \"DEFAULT\"");
721             }
722         }
723         else {
724             final List<String> names = new ArrayList<>();
725             for (final String alert : alerts) {
726                 String cleanedAlert = alert;
727                 if (alert.charAt(0) == ',') {
728                     if (alert.charAt(1) != '\n') {
729                         addFailure(relativePath, lineIndex + 1, "Expectation must be in a separate line");
730                     }
731                     cleanedAlert = alert.substring(1).trim();
732                 }
733 
734                 final int quoteIndex = cleanedAlert.indexOf('"');
735                 final int equalsIndex = cleanedAlert.indexOf('=');
736                 if (equalsIndex != -1 && equalsIndex < quoteIndex) {
737                     final String name = cleanedAlert.substring(0, equalsIndex - 1);
738                     alertVerifyOrder(name, names, relativePath, lineIndex);
739                     names.add(name);
740                 }
741             }
742         }
743     }
744 
745     /**
746      * Converts the given alerts definition to an array of expressions.
747      */
748     private static List<String> alertsToList(final String string) {
749         final List<String> list = new ArrayList<>();
750         if ("\"\"".equals(string)) {
751             list.add(string);
752         }
753         else {
754             final StringBuilder currentToken = new StringBuilder();
755 
756             boolean insideString = true;
757             boolean startsWithBraces = false;
758             for (final String token : string.split("(?<!\\\\)\"")) {
759                 insideString = !insideString;
760                 if (currentToken.length() != 0) {
761                     currentToken.append('"');
762                 }
763                 else {
764                     startsWithBraces = token.toString().contains("{");
765                 }
766 
767                 if (!insideString && token.startsWith(",") && !startsWithBraces) {
768                     list.add(currentToken.toString());
769                     currentToken.setLength(0);
770                     startsWithBraces = token.toString().contains("{");
771                 }
772 
773                 if (!insideString && token.contains("}")) {
774                     final int curlyIndex = token.indexOf('}') + 1;
775                     currentToken.append(token, 0, curlyIndex);
776                     list.add(currentToken.toString());
777                     currentToken.setLength(0);
778                     currentToken.append(token, curlyIndex, token.length());
779                 }
780                 else {
781                     if (!insideString && token.contains(",") && !startsWithBraces) {
782                         final String[] expressions = token.split(",");
783                         currentToken.append(expressions[0]);
784                         if (currentToken.length() != 0) {
785                             list.add(currentToken.toString());
786                         }
787                         for (int i = 1; i < expressions.length - 1; i++) {
788                             list.add(',' + expressions[i]);
789                         }
790                         currentToken.setLength(0);
791                         currentToken.append(',' + expressions[expressions.length - 1]);
792                     }
793                     else {
794                         currentToken.append(token);
795                     }
796                 }
797             }
798             if (currentToken.length() != 0) {
799                 if (!currentToken.toString().contains("\"")) {
800                     currentToken.insert(0, '"');
801                 }
802                 int totalQuotes = 0;
803                 for (int i = 0; i < currentToken.length(); i++) {
804                     if (currentToken.charAt(i) == '"' && (i == 0 || currentToken.charAt(i - 1) != '\\')) {
805                         totalQuotes++;
806                     }
807                 }
808                 if (totalQuotes % 2 != 0) {
809                     currentToken.append('"');
810                 }
811 
812                 list.add(currentToken.toString());
813             }
814         }
815         return list;
816     }
817 
818     /**
819      * Verifies \@Alerts specific order.
820      *
821      * @param browserName the browser name
822      * @param previousList the previously defined browser names
823      */
824     private void alertVerifyOrder(final String browserName, final List<String> previousList,
825             final String relativePath, final int lineIndex) {
826         switch (browserName) {
827             case "DEFAULT":
828                 if (!previousList.isEmpty()) {
829                     addFailure(relativePath, lineIndex + 1, "DEFAULT must come first");
830                 }
831                 break;
832 
833             default:
834         }
835     }
836 
837     /**
838      * Verifies that no extra leading spaces (in test code).
839      */
840     private void spaces(final List<String> lines, final String relativePath) {
841         for (int i = 0; i + 1 < lines.size(); i++) {
842             String line = lines.get(i).trim();
843             String next = lines.get(i + 1).trim();
844             if (line.startsWith("+ \"") && next.startsWith("+ \"")) {
845                 line = line.substring(3);
846                 final String lineTrimmed = line.trim();
847                 next = next.substring(3);
848                 if (lineTrimmed.startsWith("<") && next.trim().startsWith("<") || lineTrimmed.startsWith("try")
849                         || lineTrimmed.startsWith("for") || lineTrimmed.startsWith("function")
850                         || lineTrimmed.startsWith("if")) {
851                     final int difference = getInitialSpaces(next) - getInitialSpaces(line);
852                     if (difference > 2) {
853                         addFailure(relativePath, i + 2, "Too many initial spaces");
854                     }
855                     else if (difference == 1) {
856                         addFailure(relativePath, i + 2, "Add one more space");
857                     }
858                 }
859             }
860         }
861     }
862 
863     /**
864      * Verifies the indentation of the expectations.
865      */
866     private void indentation(final List<String> lines, final String relativePath) {
867         for (int i = 0; i + 1 < lines.size(); i++) {
868             final String line = lines.get(i);
869             if (line.startsWith("        CHROME = ")
870                     || line.startsWith("        EDGE = ")
871                     || line.startsWith("        FF = ")
872                     || line.startsWith("        FF_ESR = ")) {
873                 addFailure(relativePath, i + 2, "Incorrect indentation");
874             }
875         }
876     }
877 
878     private static int getInitialSpaces(final String s) {
879         int spaces = 0;
880         while (spaces < s.length() && s.charAt(spaces) == ' ') {
881             spaces++;
882         }
883         return spaces;
884     }
885 
886     /**
887      * Tests if all JUnit 4 candidate test methods declare <tt>@Test</tt> annotation.
888      * @throws Exception if the test fails
889      */
890     @Test
891     public void tests() throws Exception {
892         title_ = "Tests";
893         testTests(new File("src/test/java"));
894     }
895 
896     private void testTests(final File dir) throws Exception {
897         final File[] files = dir.listFiles();
898         if (files == null) {
899             return;
900         }
901 
902         for (final File file : files) {
903             if (file.isDirectory()) {
904                 if (!".git".equals(file.getName())) {
905                     testTests(file);
906                 }
907             }
908             else {
909                 if (file.getName().endsWith(".java")) {
910                     final int index = new File("src/test/java").getAbsolutePath().length();
911                     String name = file.getAbsolutePath();
912                     name = name.substring(index + 1, name.length() - 5);
913                     name = name.replace(File.separatorChar, '.');
914                     final Class<?> clazz;
915                     try {
916                         clazz = Class.forName(name);
917                     }
918                     catch (final Exception e) {
919                         continue;
920                     }
921                     name = file.getName();
922                     if (name.endsWith("Test.java") || name.endsWith("TestCase.java")) {
923                         for (final Constructor<?> ctor : clazz.getConstructors()) {
924                             if (ctor.getParameterTypes().length == 0) {
925                                 for (final Method method : clazz.getDeclaredMethods()) {
926                                     if (Modifier.isPublic(method.getModifiers())
927                                             && method.getAnnotation(BeforeEach.class) == null
928                                             && method.getAnnotation(BeforeAll.class) == null
929                                             && method.getAnnotation(AfterEach.class) == null
930                                             && method.getAnnotation(AfterAll.class) == null
931                                             && method.getAnnotation(Test.class) == null
932                                             && method.getAnnotation(RepeatedTest.class) == null
933                                             && method.getAnnotation(RetryingTest.class) == null
934                                             && method.getReturnType() == Void.TYPE
935                                             && method.getParameterTypes().length == 0) {
936                                         final List<String> lines = FileUtils.readLines(file, SOURCE_ENCODING);
937                                         int line = -1;
938                                         for (int i = 0; i < lines.size(); ++i) {
939                                             if (lines.get(i).contains("public void " + method.getName() + "()")) {
940                                                 line = i + 1;
941                                                 break;
942                                             }
943                                         }
944                                         addFailure(file.toString().replace('\\', '/'), line,
945                                                 "Method '" + method.getName() + "' does not declare @Test annotation");
946                                     }
947                                 }
948                             }
949                         }
950                     }
951                 }
952             }
953         }
954     }
955 
956 }