source: josm/trunk/test/unit/org/openstreetmap/josm/plugins/PluginHandlerTestIT.java@ 18636

Last change on this file since 18636 was 18636, checked in by taylor.smock, 17 months ago

Fix #21423: Prevent error codes from clashing

This works by creating a unique code using the test class name. The new
format for ignore entries will look like UNIQUECODE_CODE_MESSAGE_STATE.
The switchover date for the new entries is set at 2024-01-01 in order to
give users sufficient time to update, such that if a user has multiple
installations of JOSM, all versions will be able to use the same ignore
list. The compatibility code is intended to be removed in 2025.

File size: 15.8 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.plugins;
3
4import static org.junit.jupiter.api.Assertions.assertEquals;
5import static org.junit.jupiter.api.Assertions.assertFalse;
6import static org.junit.jupiter.api.Assertions.assertNotNull;
7import static org.junit.jupiter.api.Assertions.assertTrue;
8
9import java.awt.GraphicsEnvironment;
10import java.awt.HeadlessException;
11import java.io.IOException;
12import java.util.ArrayList;
13import java.util.Arrays;
14import java.util.Collection;
15import java.util.HashMap;
16import java.util.Iterator;
17import java.util.List;
18import java.util.Map;
19import java.util.Map.Entry;
20import java.util.Objects;
21import java.util.Set;
22import java.util.function.Consumer;
23import java.util.logging.Handler;
24import java.util.logging.LogRecord;
25import java.util.stream.Collectors;
26
27import org.junit.jupiter.api.BeforeAll;
28import org.junit.jupiter.api.Test;
29import org.junit.jupiter.api.extension.RegisterExtension;
30import org.junit.platform.commons.util.ReflectionUtils;
31import org.openstreetmap.josm.TestUtils;
32import org.openstreetmap.josm.data.Preferences;
33import org.openstreetmap.josm.data.gpx.GpxData;
34import org.openstreetmap.josm.data.osm.DataSet;
35import org.openstreetmap.josm.gui.MainApplication;
36import org.openstreetmap.josm.gui.layer.GpxLayer;
37import org.openstreetmap.josm.gui.layer.Layer;
38import org.openstreetmap.josm.gui.layer.OsmDataLayer;
39import org.openstreetmap.josm.gui.progress.NullProgressMonitor;
40import org.openstreetmap.josm.spi.preferences.Config;
41import org.openstreetmap.josm.testutils.JOSMTestRules;
42import org.openstreetmap.josm.tools.Destroyable;
43import org.openstreetmap.josm.tools.Logging;
44import org.openstreetmap.josm.tools.Utils;
45
46import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
47
48/**
49 * Integration tests of {@link PluginHandler} class.
50 */
51public class PluginHandlerTestIT {
52
53 private static final List<String> errorsToIgnore = new ArrayList<>();
54 /**
55 * Setup test.
56 */
57 @RegisterExtension
58 @SuppressFBWarnings(value = "URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD")
59 public static JOSMTestRules test = new JOSMTestRules().main().projection().preferences().https()
60 .territories().timeout(10 * 60 * 1000);
61
62 /**
63 * Setup test
64 *
65 * @throws IOException in case of I/O error
66 */
67 @BeforeAll
68 public static void beforeClass() throws IOException {
69 errorsToIgnore.addAll(TestUtils.getIgnoredErrorMessages(PluginHandlerTestIT.class));
70 }
71
72 /**
73 * Test that available plugins rules can be loaded.
74 */
75 @Test
76 void testValidityOfAvailablePlugins() {
77 loadAllPlugins();
78
79 Map<String, Throwable> loadingExceptions = PluginHandler.pluginLoadingExceptions.entrySet().stream()
80 .filter(e -> !(Utils.getRootCause(e.getValue()) instanceof HeadlessException))
81 .collect(Collectors.toMap(Map.Entry::getKey, e -> Utils.getRootCause(e.getValue())));
82
83 List<PluginInformation> loadedPlugins = PluginHandler.getPlugins();
84 Map<String, List<String>> invalidManifestEntries = loadedPlugins.stream().filter(pi -> !pi.invalidManifestEntries.isEmpty())
85 .collect(Collectors.toMap(pi -> pi.name, pi -> pi.invalidManifestEntries));
86
87 // Add/remove layers twice to test basic plugin good behaviour
88 Map<String, Throwable> layerExceptions = new HashMap<>();
89 for (int i = 0; i < 2; i++) {
90 OsmDataLayer layer = new OsmDataLayer(new DataSet(), "Layer "+i, null);
91 testPlugin(MainApplication.getLayerManager()::addLayer, layer, layerExceptions, loadedPlugins);
92 testPlugin(MainApplication.getLayerManager()::removeLayer, layer, layerExceptions, loadedPlugins);
93 }
94 for (int i = 0; i < 2; i++) {
95 GpxLayer layer = new GpxLayer(new GpxData(), "Layer "+i);
96 testPlugin(MainApplication.getLayerManager()::addLayer, layer, layerExceptions, loadedPlugins);
97 testPlugin(MainApplication.getLayerManager()::removeLayer, layer, layerExceptions, loadedPlugins);
98 }
99
100 Map<String, String> testCodeHashCollisions = checkForHashCollisions();
101
102 Map<String, Throwable> noRestartExceptions = new HashMap<>();
103 testCompletelyRestartlessPlugins(loadedPlugins, noRestartExceptions);
104
105 debugPrint(invalidManifestEntries);
106 debugPrint(loadingExceptions);
107 debugPrint(layerExceptions);
108 debugPrint(noRestartExceptions);
109 debugPrint(testCodeHashCollisions);
110
111 invalidManifestEntries = filterKnownErrors(invalidManifestEntries);
112 loadingExceptions = filterKnownErrors(loadingExceptions);
113 layerExceptions = filterKnownErrors(layerExceptions);
114 noRestartExceptions = filterKnownErrors(noRestartExceptions);
115 testCodeHashCollisions = filterKnownErrors(testCodeHashCollisions);
116
117 String msg = errMsg("invalidManifestEntries", invalidManifestEntries) + '\n' +
118 errMsg("loadingExceptions", loadingExceptions) + '\n' +
119 errMsg("layerExceptions", layerExceptions) + '\n' +
120 errMsg("noRestartExceptions", noRestartExceptions) + '\n' +
121 errMsg("testCodeHashCollisions", testCodeHashCollisions);
122 assertTrue(invalidManifestEntries.isEmpty()
123 && loadingExceptions.isEmpty()
124 && layerExceptions.isEmpty()
125 && noRestartExceptions.isEmpty()
126 && testCodeHashCollisions.isEmpty(), msg);
127 }
128
129 private static String errMsg(String type, Map<String, ?> map) {
130 return type + ": " + Arrays.toString(map.entrySet().toArray());
131 }
132
133 private static void testCompletelyRestartlessPlugins(List<PluginInformation> loadedPlugins,
134 Map<String, Throwable> noRestartExceptions) {
135 final List<LogRecord> records = new ArrayList<>();
136 Handler tempHandler = new Handler() {
137 @Override
138 public void publish(LogRecord record) {
139 records.add(record);
140 }
141
142 @Override
143 public void flush() { /* Do nothing */ }
144
145 @Override
146 public void close() throws SecurityException { /* Do nothing */ }
147 };
148 Logging.getLogger().addHandler(tempHandler);
149 try {
150 List<PluginInformation> restartable = loadedPlugins.parallelStream()
151 .filter(info -> PluginHandler.getPlugin(info.name) instanceof Destroyable)
152 .collect(Collectors.toList());
153 // ensure good plugin behavior with regards to Destroyable (i.e., they can be
154 // removed and readded)
155 for (int i = 0; i < 2; i++) {
156 assertFalse(PluginHandler.removePlugins(restartable), () -> Logging.getLastErrorAndWarnings().toString());
157 List<PluginInformation> notRemovedPlugins = restartable.stream()
158 .filter(info -> PluginHandler.getPlugins().contains(info)).collect(Collectors.toList());
159 assertTrue(notRemovedPlugins.isEmpty(), notRemovedPlugins::toString);
160 loadPlugins(restartable);
161 }
162
163 assertTrue(PluginHandler.removePlugins(loadedPlugins), () -> Logging.getLastErrorAndWarnings().toString());
164 assertTrue(restartable.parallelStream().noneMatch(info -> PluginHandler.getPlugins().contains(info)));
165 } catch (Exception | LinkageError t) {
166 Throwable root = Utils.getRootCause(t);
167 root.printStackTrace();
168 noRestartExceptions.put(findFaultyPlugin(loadedPlugins, root), root);
169 records.removeIf(record -> Objects.equals(Utils.getRootCause(record.getThrown()), root));
170 } catch (AssertionError assertionError) {
171 noRestartExceptions.put("Plugin load/unload failed", assertionError);
172 } finally {
173 Logging.getLogger().removeHandler(tempHandler);
174 for (LogRecord record : records) {
175 if (record.getThrown() != null) {
176 Throwable root = Utils.getRootCause(record.getThrown());
177 root.printStackTrace();
178 noRestartExceptions.put(findFaultyPlugin(loadedPlugins, root), root);
179 }
180 }
181 }
182 }
183
184 private static Map<String, String> checkForHashCollisions() {
185 Map<Integer, List<String>> codes = new HashMap<>();
186 for (Class<?> clazz : ReflectionUtils.findAllClassesInPackage("org.openstreetmap",
187 org.openstreetmap.josm.data.validation.Test.class::isAssignableFrom, s -> true)) {
188 if (org.openstreetmap.josm.data.validation.Test.class.isAssignableFrom(clazz)
189 && !Objects.equals(org.openstreetmap.josm.data.validation.Test.class, clazz)) {
190 // clazz.getName().hashCode() is how the base error codes are calculated since xxx
191 // We want to avoid cases where the hashcode is too close, so we want to
192 // ensure that there is at least 1m available codes after the hashCode.
193 // This is needed since some plugins pick some really large number, and count up from there.
194 int hashCeil = (int) Math.ceil(clazz.getName().hashCode() / 1_000_000d);
195 int hashFloor = (int) Math.floor(clazz.getName().hashCode() / 1_000_000d);
196 codes.computeIfAbsent(hashCeil, k -> new ArrayList<>()).add(clazz.getName());
197 codes.computeIfAbsent(hashFloor, k -> new ArrayList<>()).add(clazz.getName());
198 }
199 }
200 return codes.entrySet().stream().filter(entry -> entry.getValue().size() > 1).collect(
201 Collectors.toMap(entry -> entry.getKey().toString(), entry -> String.join(", ", entry.getValue())));
202 }
203
204 private static <T> Map<String, T> filterKnownErrors(Map<String, T> errorMap) {
205 return errorMap.entrySet().parallelStream()
206 .filter(entry -> !errorsToIgnore.contains(convertEntryToString(entry)))
207 .collect(Collectors.toMap(Entry::getKey, Entry::getValue));
208 }
209
210 private static void debugPrint(Map<String, ?> invalidManifestEntries) {
211 System.out.println(invalidManifestEntries.entrySet()
212 .stream()
213 .map(PluginHandlerTestIT::convertEntryToString)
214 .collect(Collectors.joining(", ")));
215 }
216
217 private static String convertEntryToString(Entry<String, ?> entry) {
218 return entry.getKey() + "=\"" + entry.getValue() + "\"";
219 }
220
221 /**
222 * Downloads and loads all JOSM plugins.
223 */
224 public static void loadAllPlugins() {
225 // Download complete list of plugins
226 ReadRemotePluginInformationTask pluginInfoDownloadTask = new ReadRemotePluginInformationTask(
227 Preferences.main().getOnlinePluginSites());
228 pluginInfoDownloadTask.run();
229 List<PluginInformation> plugins = pluginInfoDownloadTask.getAvailablePlugins();
230 System.out.println("Original plugin list contains " + plugins.size() + " plugins");
231 assertFalse(plugins.isEmpty(), plugins::toString);
232 PluginInformation info = plugins.get(0);
233 assertFalse(info.getName().isEmpty(), info::toString);
234 assertFalse(info.getClass().getName().isEmpty(), info::toString);
235
236 // Filter deprecated and unmaintained ones, or those not responsive enough to match our continuous integration needs
237 List<String> uncooperatingPlugins = Arrays.asList("ebdirigo", "scoutsigns", "josm-config");
238 Set<String> deprecatedPlugins = PluginHandler.getDeprecatedAndUnmaintainedPlugins();
239 for (Iterator<PluginInformation> it = plugins.iterator(); it.hasNext();) {
240 PluginInformation pi = it.next();
241 if (deprecatedPlugins.contains(pi.name) || uncooperatingPlugins.contains(pi.name)) {
242 System.out.println("Ignoring " + pi.name + " (deprecated, unmaintained, or uncooperative)");
243 it.remove();
244 }
245 }
246
247 // On Java < 11 and headless mode, filter plugins requiring JavaFX as Monocle is not available
248 int javaVersion = Utils.getJavaVersion();
249 if (GraphicsEnvironment.isHeadless() && javaVersion < 11) {
250 for (Iterator<PluginInformation> it = plugins.iterator(); it.hasNext();) {
251 PluginInformation pi = it.next();
252 if (pi.getRequiredPlugins().contains("javafx")) {
253 System.out.println("Ignoring " + pi.name + " (requiring JavaFX and we're using Java < 11 in headless mode)");
254 it.remove();
255 }
256 }
257 }
258
259 // Skip unofficial plugins in headless mode, too much work for us for little added-value
260 if (GraphicsEnvironment.isHeadless()) {
261 for (Iterator<PluginInformation> it = plugins.iterator(); it.hasNext();) {
262 PluginInformation pi = it.next();
263 if (pi.isExternal()) {
264 System.out.println("Ignoring " + pi.name + " (unofficial plugin in headless mode)");
265 it.remove();
266 }
267 }
268 }
269
270 System.out.println("Filtered plugin list contains " + plugins.size() + " plugins");
271
272 // Download plugins
273 downloadPlugins(plugins);
274
275 loadPlugins(plugins);
276 }
277
278 static void loadPlugins(List<PluginInformation> plugins) {
279 // Load early plugins
280 PluginHandler.loadEarlyPlugins(null, plugins, null);
281
282 // Load late plugins
283 PluginHandler.loadLatePlugins(null, plugins, null);
284 }
285
286 void testPlugin(Consumer<Layer> consumer, Layer layer,
287 Map<String, Throwable> layerExceptions, Collection<PluginInformation> loadedPlugins) {
288 try {
289 consumer.accept(layer);
290 } catch (Exception | LinkageError t) {
291 Throwable root = Utils.getRootCause(t);
292 root.printStackTrace();
293 layerExceptions.put(findFaultyPlugin(loadedPlugins, root), root);
294 }
295 }
296
297 private static String findFaultyPlugin(Collection<PluginInformation> plugins, Throwable root) {
298 for (PluginInformation p : plugins) {
299 try {
300 ClassLoader cl = PluginHandler.getPluginClassLoader(p.getName());
301 assertNotNull(cl);
302 String pluginPackage = cl.loadClass(p.className).getPackage().getName();
303 for (StackTraceElement e : root.getStackTrace()) {
304 try {
305 String stackPackage = cl.loadClass(e.getClassName()).getPackage().getName();
306 if (stackPackage.startsWith(pluginPackage)) {
307 return p.name;
308 }
309 } catch (ClassNotFoundException ex) {
310 System.err.println(ex.getMessage());
311 continue;
312 }
313 }
314 } catch (ClassNotFoundException ex) {
315 System.err.println(ex.getMessage());
316 continue;
317 }
318 }
319 return "<unknown>";
320 }
321
322 /**
323 * Download plugins
324 * @param plugins plugins to download
325 */
326 public static void downloadPlugins(Collection<PluginInformation> plugins) {
327 // Update the locally installed plugins
328 PluginDownloadTask pluginDownloadTask = new PluginDownloadTask(NullProgressMonitor.INSTANCE, plugins, null);
329 // Increase default timeout to avoid random network errors on big jar files
330 int defTimeout = Config.getPref().getInt("socket.timeout.read", 30);
331 Config.getPref().putInt("socket.timeout.read", 2 * defTimeout);
332 pluginDownloadTask.run();
333 // Restore default timeout
334 Config.getPref().putInt("socket.timeout.read", defTimeout);
335 assertTrue(pluginDownloadTask.getFailedPlugins().isEmpty(), pluginDownloadTask.getFailedPlugins()::toString);
336 assertEquals(plugins.size(), pluginDownloadTask.getDownloadedPlugins().size());
337
338 // Update Plugin info for downloaded plugins
339 PluginHandler.refreshLocalUpdatedPluginInfo(pluginDownloadTask.getDownloadedPlugins());
340 }
341}
Note: See TracBrowser for help on using the repository browser.