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.javascript.configuration;
16  
17  import static org.htmlunit.BrowserVersion.FIREFOX;
18  import static org.junit.jupiter.api.Assertions.assertEquals;
19  
20  import java.io.File;
21  import java.io.IOException;
22  import java.lang.reflect.Field;
23  import java.net.URISyntaxException;
24  import java.net.URL;
25  import java.text.MessageFormat;
26  import java.util.ArrayList;
27  import java.util.Collections;
28  import java.util.Enumeration;
29  import java.util.HashMap;
30  import java.util.List;
31  import java.util.Locale;
32  import java.util.Map;
33  import java.util.jar.JarEntry;
34  import java.util.jar.JarFile;
35  
36  import org.apache.commons.logging.Log;
37  import org.apache.commons.logging.LogFactory;
38  import org.apache.commons.text.RandomStringGenerator;
39  import org.htmlunit.BrowserVersion;
40  import org.htmlunit.MockWebConnection;
41  import org.htmlunit.WebClient;
42  import org.htmlunit.WebTestCase;
43  import org.htmlunit.javascript.HtmlUnitScriptable;
44  import org.htmlunit.javascript.JavaScriptEngine;
45  import org.junit.jupiter.api.Assertions;
46  import org.junit.jupiter.api.Test;
47  
48  /**
49   * Tests for {@link JavaScriptConfiguration}.
50   *
51   * @author Chris Erskine
52   * @author Ahmed Ashour
53   * @author Ronald Brill
54   * @author Frank Danek
55   * @author Joerg Werner
56   */
57  public class JavaScriptConfigurationTest {
58  
59      private static final Log LOG = LogFactory.getLog(JavaScriptConfigurationTest.class);
60  
61      /**
62       * Test if configuration map expands with each new instance of BrowserVersion used.
63       *
64       * @throws Exception if the test fails
65       */
66      @Test
67      public void configurationMapExpands() throws Exception {
68          // get a reference to the leaky map
69          final Field field = JavaScriptConfiguration.class.getDeclaredField("CONFIGURATION_MAP_");
70          field.setAccessible(true);
71          final Map<?, ?> leakyMap = (Map<?, ?>) field.get(null);
72  
73          leakyMap.clear();
74          final int knownBrowsers = leakyMap.size();
75  
76          BrowserVersion browserVersion = new BrowserVersion.BrowserVersionBuilder(BrowserVersion.FIREFOX_ESR)
77                                                  .setApplicationVersion("App")
78                                                  .setApplicationVersion("Version")
79                                                  .setUserAgent("User agent")
80                                                  .build();
81          JavaScriptConfiguration.getInstance(browserVersion);
82  
83          browserVersion = new BrowserVersion.BrowserVersionBuilder(BrowserVersion.FIREFOX_ESR)
84                              .setApplicationVersion("App2")
85                              .setApplicationVersion("Version2")
86                              .setUserAgent("User agent2")
87                              .build();
88          JavaScriptConfiguration.getInstance(browserVersion);
89  
90          assertEquals(knownBrowsers + 1, leakyMap.size());
91      }
92  
93      /**
94       * Regression test for Bug #899.
95       * This test was throwing an OutOfMemoryError when the bug existed.
96       * @throws Exception if an error occurs
97       */
98      @Test
99      public void memoryLeak() throws Exception {
100         final RandomStringGenerator generator = new RandomStringGenerator.Builder().withinRange('a', 'z').get();
101 
102         long count = 0;
103         while (count++ < 3000) {
104             final BrowserVersion browserVersion = new BrowserVersion.BrowserVersionBuilder(BrowserVersion.FIREFOX_ESR)
105                                                     .setApplicationVersion("App" + generator.generate(20))
106                                                     .setApplicationVersion("Version" + generator.generate(20))
107                                                     .setUserAgent("User Agent" + generator.generate(20))
108                                                     .build();
109             JavaScriptConfiguration.getInstance(browserVersion);
110             if (LOG.isInfoEnabled()) {
111                 LOG.info("count: " + count + "; memory stats: " + getMemoryStats());
112             }
113         }
114         System.gc();
115     }
116 
117     private static String getMemoryStats() {
118         final Runtime rt = Runtime.getRuntime();
119         final long free = rt.freeMemory() / 1024;
120         final long total = rt.totalMemory() / 1024;
121         final long max = rt.maxMemory() / 1024;
122         final long used = total - free;
123         final String format = "used: {0,number,0}K, free: {1,number,0}K, total: {2,number,0}K, max: {3,number,0}K";
124         return MessageFormat.format(format,
125                 Long.valueOf(used), Long.valueOf(free), Long.valueOf(total), Long.valueOf(max));
126     }
127 
128     /**
129      * Tests that all classes in *.javascript.* which have {@link JsxClasses}/{@link JsxClass} annotation,
130      * are included in {@link JavaScriptConfiguration#CLASSES_}.
131      */
132     @Test
133     public void jsxClasses() {
134         final List<String> foundJsxClasses = new ArrayList<>();
135         for (final String className : getClassesForPackage(JavaScriptEngine.class)) {
136             if (!className.contains("$")) {
137                 Class<?> klass = null;
138                 try {
139                     klass = Class.forName(className);
140                 }
141                 catch (final Throwable e) {
142                     continue;
143                 }
144                 if ("org.htmlunit.javascript.host.intl".equals(klass.getPackage().getName())
145 
146                         // Worker
147                         || "WorkerGlobalScope".equals(klass.getSimpleName())
148                         || "DedicatedWorkerGlobalScope".equals(klass.getSimpleName())
149                         || "WorkerLocation".equals(klass.getSimpleName())
150                         || "WorkerNavigator".equals(klass.getSimpleName())
151 
152                         // ProxyConfig
153                         || "ProxyAutoConfig".equals(klass.getSimpleName())) {
154                     continue;
155                 }
156                 if (klass.getAnnotation(JsxClasses.class) != null) {
157                     foundJsxClasses.add(className);
158                 }
159                 else if (klass.getAnnotation(JsxClass.class) != null) {
160                     foundJsxClasses.add(className);
161                 }
162             }
163         }
164 
165         final List<String> definedClasses = new ArrayList<>();
166         for (final Class<?> klass : JavaScriptConfiguration.CLASSES_) {
167             definedClasses.add(klass.getName());
168         }
169         foundJsxClasses.removeAll(definedClasses);
170         if (!foundJsxClasses.isEmpty()) {
171             Assertions.fail("Class " + foundJsxClasses.get(0) + " is not in JavaScriptConfiguration.CLASSES_");
172         }
173     }
174 
175     /**
176      * Return the classes inside the specified package and its sub-packages.
177      * @param klass a class inside that package
178      * @return a list of class names
179      */
180     public static List<String> getClassesForPackage(final Class<?> klass) {
181         final List<String> list = new ArrayList<>();
182 
183         File directory = null;
184         final String relPath = klass.getName().replace('.', '/') + ".class";
185 
186         final URL resource = JavaScriptConfiguration.class.getClassLoader().getResource(relPath);
187 
188         if (resource == null) {
189             throw new RuntimeException("No resource for " + relPath);
190         }
191         final String fullPath = resource.getFile();
192 
193         try {
194             directory = new File(resource.toURI()).getParentFile();
195         }
196         catch (final URISyntaxException e) {
197             throw new RuntimeException(klass.getName() + " (" + resource + ") does not appear to be a valid URL", e);
198         }
199         catch (final IllegalArgumentException e) {
200             directory = null;
201         }
202 
203         if (directory != null && directory.exists()) {
204             addClasses(directory, klass.getPackage().getName(), list);
205         }
206         else {
207             try {
208                 String jarPath = fullPath.replaceFirst("[.]jar[!].*", ".jar").replaceFirst("file:", "");
209                 if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win")) {
210                     jarPath = jarPath.replace("%20", " ");
211                 }
212                 try (JarFile jarFile = new JarFile(jarPath)) {
213                     for (final Enumeration<JarEntry> entries = jarFile.entries(); entries.hasMoreElements();) {
214                         final String entryName = entries.nextElement().getName();
215                         if (entryName.endsWith(".class")) {
216                             list.add(entryName.replace('/', '.').replace('\\', '.').replace(".class", ""));
217                         }
218                     }
219                 }
220             }
221             catch (final IOException e) {
222                 throw new RuntimeException(klass.getPackage().getName() + " does not appear to be a valid package", e);
223             }
224         }
225         return list;
226     }
227 
228     private static void addClasses(final File directory, final String packageName, final List<String> list) {
229         final File[] files = directory.listFiles();
230         if (files != null) {
231             for (final File file : files) {
232                 final String name = file.getName();
233                 if (name.endsWith(".class")) {
234                     list.add(packageName + '.' + name.substring(0, name.length() - 6));
235                 }
236                 else if (file.isDirectory() && !".git".equals(file.getName())) {
237                     addClasses(file, packageName + '.' + file.getName(), list);
238                 }
239             }
240         }
241     }
242 
243     /**
244      * Tests that all classes included in {@link JavaScriptConfiguration#CLASSES_} defining an
245      * {@link JsxClasses}/{@link JsxClass} annotation for at least one browser.
246      */
247     @Test
248     public void obsoleteJsxClasses() {
249         final JavaScriptConfiguration config = JavaScriptConfiguration.getInstance(FIREFOX);
250 
251         for (final Class<? extends HtmlUnitScriptable> klass : config.getClasses()) {
252             boolean found = false;
253             for (final BrowserVersion browser : BrowserVersion.ALL_SUPPORTED_BROWSERS) {
254                 if (AbstractJavaScriptConfiguration.getClassConfiguration(klass, browser) != null) {
255                     found = true;
256                     break;
257                 }
258             }
259             Assertions.assertTrue(found, "Class " + klass
260                     + " is member of JavaScriptConfiguration.CLASSES_ but does not define @JsxClasses/@JsxClass");
261         }
262     }
263 
264     /**
265      * Test of order.
266      */
267     @Test
268     public void treeOrder() {
269         final List<String> defined = new ArrayList<>(JavaScriptConfiguration.CLASSES_.length);
270 
271         final HashMap<Integer, List<String>> levels = new HashMap<>();
272         for (final Class<?> c : JavaScriptConfiguration.CLASSES_) {
273             defined.add(c.getSimpleName());
274 
275             int level = 1;
276             Class<?> parent = c.getSuperclass();
277             while (parent != HtmlUnitScriptable.class) {
278                 level++;
279                 parent = parent.getSuperclass();
280             }
281 
282             List<String> clsAtLevel = levels.get(level);
283             if (clsAtLevel == null) {
284                 clsAtLevel = new ArrayList<>();
285                 levels.put(level, clsAtLevel);
286             }
287             clsAtLevel.add(c.getSimpleName());
288         }
289 
290         final List<String> all = new ArrayList<>(JavaScriptConfiguration.CLASSES_.length);
291         for (int level = 1; level <= levels.size(); level++) {
292             final List<String> clsAtLevel = levels.get(level);
293             Collections.sort(clsAtLevel);
294             all.addAll(clsAtLevel);
295 
296             // dump
297             /*
298             final String indent = "       ";
299             System.out.println(indent + " // level " + level);
300 
301             System.out.print(indent);
302             int chars = indent.length();
303             for (final String cls : clsAtLevel) {
304                 final String toPrint = " " + cls + ".class,";
305                 chars += toPrint.length();
306                 if (chars > 120) {
307                     System.out.println();
308                     System.out.print(indent);
309                     chars = indent.length() + toPrint.length();
310                 }
311                 System.out.print(toPrint);
312             }
313             System.out.println();
314             */
315         }
316         assertEquals(all, defined);
317     }
318 
319     /**
320      * See issue 1890.
321      *
322      * @throws Exception if the test fails
323      */
324     @Test
325     public void original() throws Exception {
326         final BrowserVersion browserVersion = BrowserVersion.CHROME;
327 
328         test(browserVersion);
329     }
330 
331     /**
332      * See issue 1890.
333      *
334      * @throws Exception if the test fails
335      */
336     @Test
337     public void cloned() throws Exception {
338         final BrowserVersion browserVersion = new BrowserVersion.BrowserVersionBuilder(BrowserVersion.FIREFOX)
339                                                     .build();
340 
341         test(browserVersion);
342     }
343 
344     /**
345      * See issue 1890.
346      *
347      * @throws Exception if the test fails
348      */
349     @Test
350     public void clonedAndModified() throws Exception {
351         final BrowserVersion browserVersion = new BrowserVersion.BrowserVersionBuilder(BrowserVersion.FIREFOX)
352                                                     .setUserAgent("foo")
353                                                     .build();
354 
355         test(browserVersion);
356     }
357 
358     private static void test(final BrowserVersion browserVersion) throws IOException {
359         try (WebClient webClient = new WebClient(browserVersion)) {
360             final MockWebConnection conn = new MockWebConnection();
361             conn.setDefaultResponse(WebTestCase.DOCTYPE_HTML
362                     + "<html><body onload='document.body.firstChild'></body></html>");
363             webClient.setWebConnection(conn);
364 
365             webClient.getPage("http://localhost/");
366         }
367     }
368 
369 }