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