From ecbb854e039d4039ada1c7f0a20ad83dd6d13500 Mon Sep 17 00:00:00 2001 From: J-N-K Date: Thu, 12 Jan 2023 19:37:42 +0100 Subject: [PATCH] Refactor AbstractScriptFileWatcher (#3255) Signed-off-by: Jan N. Klug --- .../pom.xml | 6 + .../loader/{collection => }/BidiSetBag.java | 2 +- .../loader/DefaultScriptFileWatcher.java | 5 +- .../internal/loader/ScriptFileReference.java | 119 +++ .../AbstractScriptDependencyTracker.java | 2 +- .../loader/AbstractScriptFileWatcher.java | 480 +++++----- .../loader/ScriptFileReference.java | 140 --- .../rulesupport/loader/ScriptFileWatcher.java | 37 + .../loader/AbstractScriptFileWatcherTest.java | 895 +++++++++++------- .../module/script/ScriptEngineManager.java | 3 +- .../internal/ScriptEngineManagerImpl.java | 5 +- 11 files changed, 990 insertions(+), 704 deletions(-) rename bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/{collection => }/BidiSetBag.java (99%) create mode 100644 bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/ScriptFileReference.java delete mode 100644 bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileReference.java create mode 100644 bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileWatcher.java diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/pom.xml b/bundles/org.openhab.core.automation.module.script.rulesupport/pom.xml index b5d0000f4..888d75f68 100644 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/pom.xml +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/pom.xml @@ -25,6 +25,12 @@ org.openhab.core.automation.module.script ${project.version} + + org.openhab.core.bundles + org.openhab.core.test + ${project.version} + test + diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/collection/BidiSetBag.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/BidiSetBag.java similarity index 99% rename from bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/collection/BidiSetBag.java rename to bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/BidiSetBag.java index 5321ecba7..8e4578a9f 100644 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/collection/BidiSetBag.java +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/BidiSetBag.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.core.automation.module.script.rulesupport.internal.loader.collection; +package org.openhab.core.automation.module.script.rulesupport.internal.loader; import java.util.Collections; import java.util.HashMap; diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/DefaultScriptFileWatcher.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/DefaultScriptFileWatcher.java index 2b8367459..4d516b2de 100644 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/DefaultScriptFileWatcher.java +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/DefaultScriptFileWatcher.java @@ -18,6 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.automation.module.script.ScriptEngineManager; import org.openhab.core.automation.module.script.rulesupport.loader.AbstractScriptFileWatcher; import org.openhab.core.service.ReadyService; +import org.openhab.core.service.StartLevelService; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Deactivate; @@ -37,8 +38,8 @@ public class DefaultScriptFileWatcher extends AbstractScriptFileWatcher { @Activate public DefaultScriptFileWatcher(final @Reference ScriptEngineManager manager, - final @Reference ReadyService readyService) { - super(manager, readyService, FILE_DIRECTORY); + final @Reference ReadyService readyService, final @Reference StartLevelService startLevelService) { + super(manager, readyService, startLevelService, FILE_DIRECTORY); } @Activate diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/ScriptFileReference.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/ScriptFileReference.java new file mode 100644 index 000000000..acb886e2b --- /dev/null +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/internal/loader/ScriptFileReference.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.automation.module.script.rulesupport.internal.loader; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Script File wrapper offering various methods to inspect the script + * + * @author Jonathan Gilbert - Initial contribution + */ +@NonNullByDefault +public class ScriptFileReference implements Comparable { + private static final Logger LOGGER = LoggerFactory.getLogger(ScriptFileReference.class); + + private final AtomicBoolean queued = new AtomicBoolean(); + private final AtomicBoolean loaded = new AtomicBoolean(); + + private final Path scriptFilePath; + private final String scriptType; + private final int startLevel; + + /** + * A {@link ScriptFileReference} with a given scriptType + * + * @param scriptFilePath the {@link Path} of the file + * @param scriptType the script type + * @param startLevel the system start level required for this script + */ + public ScriptFileReference(Path scriptFilePath, String scriptType, int startLevel) { + this.scriptFilePath = scriptFilePath; + this.scriptType = scriptType; + this.startLevel = startLevel; + } + + public String getScriptIdentifier() { + return getScriptIdentifier(scriptFilePath); + } + + public Path getScriptFilePath() { + return scriptFilePath; + } + + public String getScriptType() { + return scriptType; + } + + public int getStartLevel() { + return startLevel; + } + + public AtomicBoolean getLoadedStatus() { + return loaded; + } + + public AtomicBoolean getQueueStatus() { + return queued; + } + + @Override + public int compareTo(ScriptFileReference other) { + int startLevelCompare = Integer.compare(startLevel, other.startLevel); + if (startLevelCompare != 0) { + return startLevelCompare; + } + + String name1 = scriptFilePath.getFileName().toString(); + LOGGER.trace("o1 [{}], name1 [{}]", scriptFilePath, name1); + + String name2 = other.scriptFilePath.getFileName().toString(); + LOGGER.trace("o2 [{}], name2 [{}]", other.scriptFilePath, name2); + + int nameCompare = name1.compareToIgnoreCase(name2); + if (nameCompare != 0) { + return nameCompare; + } else { + return scriptFilePath.getParent().toString() + .compareToIgnoreCase(other.scriptFilePath.getParent().toString()); + } + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ScriptFileReference that = (ScriptFileReference) o; + return scriptFilePath.equals(that.scriptFilePath); + } + + @Override + public int hashCode() { + return Objects.hash(scriptFilePath, scriptType, startLevel); + } + + public static String getScriptIdentifier(Path scriptFilePath) { + return scriptFilePath.toString(); + } +} diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptDependencyTracker.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptDependencyTracker.java index 46af2a84b..b1e8b23b8 100644 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptDependencyTracker.java +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptDependencyTracker.java @@ -27,7 +27,7 @@ import java.util.function.Consumer; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.automation.module.script.ScriptDependencyTracker; -import org.openhab.core.automation.module.script.rulesupport.internal.loader.collection.BidiSetBag; +import org.openhab.core.automation.module.script.rulesupport.internal.loader.BidiSetBag; import org.openhab.core.service.AbstractWatchService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcher.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcher.java index d2005f857..6f1b2dae0 100644 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcher.java +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcher.java @@ -14,27 +14,29 @@ package org.openhab.core.automation.module.script.rulesupport.loader; import static java.nio.file.StandardWatchEventKinds.*; -import java.io.BufferedInputStream; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; -import java.net.MalformedURLException; -import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.WatchEvent; import java.nio.file.WatchEvent.Kind; import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.script.ScriptEngine; @@ -44,6 +46,7 @@ import org.openhab.core.OpenHAB; import org.openhab.core.automation.module.script.ScriptDependencyTracker; import org.openhab.core.automation.module.script.ScriptEngineContainer; import org.openhab.core.automation.module.script.ScriptEngineManager; +import org.openhab.core.automation.module.script.rulesupport.internal.loader.ScriptFileReference; import org.openhab.core.common.NamedThreadFactory; import org.openhab.core.service.AbstractWatchService; import org.openhab.core.service.ReadyMarker; @@ -55,137 +58,145 @@ import org.slf4j.LoggerFactory; /** * The {@link AbstractScriptFileWatcher} is default implementation for watching a directory for files. If a new/modified - * file is detected, the script - * is read and passed to the {@link ScriptEngineManager}. It needs to be sub-classed for actual use. + * file is detected, the script is read and passed to the {@link ScriptEngineManager}. It needs to be sub-classed for + * actual use. * * @author Simon Merschjohann - Initial contribution * @author Kai Kreuzer - improved logging and removed thread pool * @author Jonathan Gilbert - added dependency tracking & per-script start levels; made reusable - * @author Jan N. Klug - Refactored dependy tracking to script engine factories + * @author Jan N. Klug - Refactored dependency tracking to script engine factories */ @NonNullByDefault public abstract class AbstractScriptFileWatcher extends AbstractWatchService implements ReadyService.ReadyTracker, - ScriptDependencyTracker.Listener, ScriptEngineManager.FactoryChangeListener { + ScriptDependencyTracker.Listener, ScriptEngineManager.FactoryChangeListener, ScriptFileWatcher { - private static final long RECHECK_INTERVAL = 20; + private static final Set EXCLUDED_FILE_EXTENSIONS = Set.of("txt", "old", "example", "backup", "md", "swp", + "tmp", "bak"); + + private static final List START_LEVEL_PATTERNS = List.of( // + Pattern.compile(".*/sl(\\d{2})/[^/]+"), // script in immediate slXX directory + Pattern.compile(".*/[^/]+\\.sl(\\d{2})\\.[^/.]+") // script named .slXX. + ); private final Logger logger = LoggerFactory.getLogger(AbstractScriptFileWatcher.class); private final ScriptEngineManager manager; private final ReadyService readyService; - private @Nullable ScheduledExecutorService scheduler; - private Supplier executorFactory; + protected ScheduledExecutorService scheduler; - private final Set pending = ConcurrentHashMap.newKeySet(); - private final Set loaded = ConcurrentHashMap.newKeySet(); + private final Map scriptMap = new ConcurrentHashMap<>(); + private final Map scriptLockMap = new ConcurrentHashMap<>(); + private final CompletableFuture<@Nullable Void> initialized = new CompletableFuture<>(); private volatile int currentStartLevel = 0; public AbstractScriptFileWatcher(final ScriptEngineManager manager, final ReadyService readyService, - final String fileDirectory) { + final StartLevelService startLevelService, final String fileDirectory) { super(OpenHAB.getConfigFolder() + File.separator + fileDirectory); this.manager = manager; this.readyService = readyService; - this.executorFactory = () -> Executors - .newSingleThreadScheduledExecutor(new NamedThreadFactory("scriptwatcher")); manager.addFactoryChangeListener(this); readyService.registerTracker(this, new ReadyMarkerFilter().withType(StartLevelService.STARTLEVEL_MARKER_TYPE)); + + this.scheduler = getScheduler(); + + currentStartLevel = startLevelService.getStartLevel(); + if (currentStartLevel > StartLevelService.STARTLEVEL_MODEL) { + initialImport(); + } + } + + @Override + public void activate() { + // TODO: needed to initialize underlying AbstractWatchService, should be removed when we switch to PR + // openhab-core#3004 + super.activate(); + } + + /** + * Can be overridden by subclasses (e.g. for testing) + * + * @return a {@link ScheduledExecutorService} + */ + protected ScheduledExecutorService getScheduler() { + return Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("scriptwatcher")); } @Override public void deactivate() { manager.removeFactoryChangeListener(this); readyService.unregisterTracker(this); - - ScheduledExecutorService localScheduler = scheduler; - if (localScheduler != null) { - localScheduler.shutdownNow(); - scheduler = null; - } - super.deactivate(); + + CompletableFuture.allOf( + Set.copyOf(scriptMap.keySet()).stream().map(this::removeFile).toArray(CompletableFuture[]::new)) + .thenRun(scheduler::shutdownNow); } @Override - public void activate() { - File directory = new File(pathToWatch); - if (!directory.exists()) { - if (!directory.mkdirs()) { - logger.warn("Failed to create watched directory: {}", pathToWatch); - } - } else if (directory.isFile()) { - logger.warn("Trying to watch directory {}, however it is a file", pathToWatch); - } - - super.activate(); + public CompletableFuture<@Nullable Void> ifInitialized() { + return initialized; } /** - * Override the executor service. Can be used for testing. + * Get the scriptType (file-extension or MIME-type) for a given file + *

+ * The scriptType is determined by the file extension. The extensions in {@link #EXCLUDED_FILE_EXTENSIONS} are + * ignored. Implementations should override this + * method if they provide a MIME-type instead of the file extension. * - * @param executorFactory supplier of ScheduledExecutorService + * @param scriptFilePath the {@link Path} to the script + * @return an {@link Optional} containing the script type */ - void setExecutorFactory(Supplier executorFactory) { - this.executorFactory = executorFactory; + protected Optional getScriptType(Path scriptFilePath) { + String fileName = scriptFilePath.toString(); + int index = fileName.lastIndexOf("."); + if (index == -1) { + return Optional.empty(); + } + String fileExtension = fileName.substring(index + 1); + + // ignore known file extensions for "temp" files + if (EXCLUDED_FILE_EXTENSIONS.contains(fileExtension) || fileExtension.endsWith("~")) { + return Optional.empty(); + } + return Optional.of(fileExtension); } /** - * Processes initial import of resources. + * Gets the individual start level for a given file + *

+ * The start level is derived from the name and location of + * the file by {@link #START_LEVEL_PATTERNS}. If no match is found, {@link StartLevelService#STARTLEVEL_RULEENGINE} + * is used. * - * @param rootDirectory the root directory to import initial resources from + * @param scriptFilePath the {@link Path} to the script + * @return the start level for this script */ - private void importInitialResources(File rootDirectory) { - if (rootDirectory.exists()) { - File[] files = rootDirectory.listFiles(); - if (files != null) { - Collection resources = new TreeSet<>(); - for (File f : files) { - if (!f.isHidden()) { - resources.addAll(collectResources(f)); - } - } - resources.forEach(this::importFileWhenReady); - } - } - } - - /** - * Collects all resources from the specified file or directory, - * possibly including subdirectories. - * - * The results will be sorted. - * - * @param file the file or directory to import resources from - */ - private Collection collectResources(File file) { - Collection resources = new TreeSet<>(); - if (!file.exists()) { - return resources; - } - - if (file.isDirectory()) { - if (watchSubDirectories()) { - File[] files = file.listFiles(); - if (files != null) { - for (File f : files) { - if (!f.isHidden()) { - resources.addAll(collectResources(f)); - } - } + protected int getStartLevel(Path scriptFilePath) { + for (Pattern p : START_LEVEL_PATTERNS) { + Matcher m = p.matcher(scriptFilePath.toString()); + if (m.find() && m.groupCount() > 0) { + try { + return Integer.parseInt(m.group(1)); + } catch (NumberFormatException nfe) { + logger.warn("Extracted start level {} from {}, but it's not an integer. Ignoring.", m.group(1), + scriptFilePath); } } - } else { - try { - URL url = file.toURI().toURL(); - resources.add(new ScriptFileReference(url)); - } catch (MalformedURLException e) { - // can't happen for the 'file' protocol handler with a correctly formatted URI - logger.debug("Can't create a URL", e); - } } - return resources; + + return StartLevelService.STARTLEVEL_RULEENGINE; + } + + private List listFiles(Path path, boolean includeSubDirectory) { + try (Stream stream = Files.walk(path, includeSubDirectory ? Integer.MAX_VALUE : 1)) { + return stream.filter(file -> !Files.isDirectory(file)).toList(); + } catch (IOException ignored) { + } + return List.of(); } @Override @@ -202,180 +213,194 @@ public abstract class AbstractScriptFileWatcher extends AbstractWatchService imp protected void processWatchEvent(@Nullable WatchEvent event, @Nullable Kind kind, @Nullable Path path) { File file = path.toFile(); if (!file.isHidden()) { - try { - if (ENTRY_DELETE.equals(kind)) { - if (file.isDirectory()) { - if (watchSubDirectories()) { - synchronized (this) { - String prefix = file.toURI().toURL().getPath(); - Set toRemove = loaded.stream() - .filter(f -> f.getScriptFileURL().getFile().startsWith(prefix)) - .collect(Collectors.toSet()); - toRemove.forEach(this::removeFile); - } + if (ENTRY_DELETE.equals(kind)) { + if (file.isDirectory()) { + if (watchSubDirectories()) { + synchronized (this) { + String prefix = path.getParent().toString(); + Set toRemove = scriptMap.keySet().stream().filter(ref -> ref.startsWith(prefix)) + .collect(Collectors.toSet()); + toRemove.forEach(this::removeFile); } - } else { - removeFile(new ScriptFileReference(file.toURI().toURL())); } + } else { + removeFile(ScriptFileReference.getScriptIdentifier(file.toPath())); } + } - if (file.canRead() && (ENTRY_CREATE.equals(kind) || ENTRY_MODIFY.equals(kind))) { - collectResources(file).forEach(this::importFileWhenReady); - } - } catch (MalformedURLException e) { - logger.error("malformed", e); + if (file.canRead() && (ENTRY_CREATE.equals(kind) || ENTRY_MODIFY.equals(kind))) { + addFiles(listFiles(file.toPath(), watchSubDirectories())); } } } - private void removeFile(ScriptFileReference ref) { - dequeueUrl(ref); + private CompletableFuture addFiles(Collection files) { + return CompletableFuture.allOf(files.stream().map(this::getScriptFileReference).filter(Optional::isPresent) + .map(Optional::get).sorted().map(this::addScriptFileReference).toArray(CompletableFuture[]::new)); + } + + private CompletableFuture addScriptFileReference(ScriptFileReference newRef) { + ScriptFileReference ref = scriptMap.computeIfAbsent(newRef.getScriptIdentifier(), k -> newRef); + // check if we are ready to load the script, otherwise we don't need to queue it + if (currentStartLevel >= ref.getStartLevel() && !ref.getQueueStatus().getAndSet(true)) { + return importFileWhenReady(ref.getScriptIdentifier()); + } + return CompletableFuture.completedFuture(null); + } + + private Optional getScriptFileReference(Path path) { + return getScriptType(path).map(scriptType -> new ScriptFileReference(path, scriptType, getStartLevel(path))); + } + + private CompletableFuture removeFile(String scriptIdentifier) { + return CompletableFuture.runAsync(() -> { + Lock lock = getLockForScript(scriptIdentifier); + try { + ScriptFileReference ref = scriptMap.remove(scriptIdentifier); + + if (ref == null) { + logger.warn("Failed to unload script '{}': script reference not found.", scriptIdentifier); + return; + } + + if (ref.getLoadedStatus().get()) { + manager.removeEngine(scriptIdentifier); + logger.debug("Unloaded script '{}'", scriptIdentifier); + } else { + logger.debug("Dequeued script '{}'", scriptIdentifier); + } + } finally { + scriptLockMap.remove(scriptIdentifier); + lock.unlock(); + } + }, scheduler); + } + + private synchronized Lock getLockForScript(String scriptIdentifier) { + Lock lock = scriptLockMap.computeIfAbsent(scriptIdentifier, k -> new ReentrantLock()); + lock.lock(); + + return lock; + } + + private CompletableFuture importFileWhenReady(String scriptIdentifier) { + return CompletableFuture.runAsync(() -> { + ScriptFileReference ref = scriptMap.get(scriptIdentifier); + if (ref == null) { + logger.warn("Failed to import script '{}': script reference not found", scriptIdentifier); + return; + } + + ref.getQueueStatus().set(false); + + Lock lock = getLockForScript(scriptIdentifier); + try { + if (ref.getLoadedStatus().get()) { + manager.removeEngine(scriptIdentifier); + logger.debug("Unloaded script '{}'", scriptIdentifier); + } + + if (manager.isSupported(ref.getScriptType()) && ref.getStartLevel() <= currentStartLevel) { + logger.info("(Re-)Loading script '{}'", scriptIdentifier); + if (createAndLoad(ref)) { + ref.getLoadedStatus().set(true); + } else { + // make sure script engine is successfully closed and the loading is re-tried + manager.removeEngine(ref.getScriptIdentifier()); + ref.getLoadedStatus().set(false); + } + } else { + ref.getLoadedStatus().set(false); + logger.debug("Enqueued script '{}'", ref.getScriptIdentifier()); + } + } finally { + lock.unlock(); + } + }, scheduler); + } + + private boolean createAndLoad(ScriptFileReference ref) { String scriptIdentifier = ref.getScriptIdentifier(); - manager.removeEngine(scriptIdentifier); - loaded.remove(ref); - } - - private synchronized void importFileWhenReady(ScriptFileReference ref) { - if (loaded.contains(ref)) { - this.removeFile(ref); // if already loaded, remove first - } - - Optional scriptType = ref.getScriptType(); - - scriptType.ifPresent(type -> { - if (ref.getStartLevel() <= currentStartLevel && manager.isSupported(type)) { - importFile(ref); - } else { - enqueue(ref); - } - }); - } - - protected void importFile(ScriptFileReference ref) { - if (createAndLoad(ref)) { - loaded.add(ref); - } - } - - protected boolean createAndLoad(ScriptFileReference ref) { - String fileName = ref.getScriptFileURL().getFile(); - Optional scriptType = ref.getScriptType(); - assert scriptType.isPresent(); - - try (InputStreamReader reader = new InputStreamReader( - new BufferedInputStream(ref.getScriptFileURL().openStream()), StandardCharsets.UTF_8)) { - logger.info("Loading script '{}'", fileName); - - String scriptIdentifier = ref.getScriptIdentifier(); - - ScriptEngineContainer container = manager.createScriptEngine(scriptType.get(), scriptIdentifier); - + try (InputStreamReader reader = new InputStreamReader(Files.newInputStream(ref.getScriptFilePath()), + StandardCharsets.UTF_8)) { + ScriptEngineContainer container = manager.createScriptEngine(ref.getScriptType(), + ref.getScriptIdentifier()); if (container != null) { - container.getScriptEngine().put(ScriptEngine.FILENAME, fileName); - manager.loadScript(container.getIdentifier(), reader); - - logger.debug("Script loaded: {}", fileName); - return true; - } else { - logger.error("Script loading error, ignoring file: {}", fileName); + container.getScriptEngine().put(ScriptEngine.FILENAME, scriptIdentifier); + if (manager.loadScript(container.getIdentifier(), reader)) { + return true; + } } + logger.warn("Script loading error, ignoring file '{}'", scriptIdentifier); } catch (IOException e) { - logger.error("Failed to load file '{}': {}", ref.getScriptFileURL().getFile(), e.getMessage()); + logger.warn("Failed to load file '{}': {}", ref.getScriptFilePath(), e.getMessage()); } return false; } - private void enqueue(ScriptFileReference ref) { - synchronized (pending) { - pending.add(ref); - } + private void initialImport() { + File directory = new File(pathToWatch); - logger.debug("Enqueued {}", ref.getScriptIdentifier()); - } - - private void dequeueUrl(ScriptFileReference ref) { - synchronized (pending) { - pending.remove(ref); - } - - logger.debug("Dequeued {}", ref.getScriptIdentifier()); - } - - private void checkFiles(int forLevel) { - List newlySupported; - - synchronized (pending) { - newlySupported = pending.stream() - .filter(ref -> manager.isSupported(ref.getScriptType().get()) && forLevel >= ref.getStartLevel()) - .sorted().collect(Collectors.toList()); - pending.removeAll(newlySupported); - } - - newlySupported.forEach(this::importFileWhenReady); - } - - private synchronized void onStartLevelChanged(int newLevel) { - int previousLevel = currentStartLevel; - currentStartLevel = newLevel; - - if (previousLevel < StartLevelService.STARTLEVEL_MODEL) { // not yet started - if (newLevel >= StartLevelService.STARTLEVEL_MODEL) { // start - ScheduledExecutorService localScheduler = executorFactory.get(); - scheduler = localScheduler; - localScheduler.submit(() -> importInitialResources(new File(pathToWatch))); - localScheduler.scheduleWithFixedDelay(() -> checkFiles(currentStartLevel), 0, RECHECK_INTERVAL, - TimeUnit.SECONDS); - } - } else { // already started - assert scheduler != null; - if (newLevel < StartLevelService.STARTLEVEL_MODEL) { // stop - scheduler.shutdown(); - scheduler = null; - } else if (newLevel > previousLevel) { - scheduler.submit(() -> checkFiles(newLevel)); + if (!directory.exists()) { + if (!directory.mkdirs()) { + logger.warn("Failed to create watched directory: {}", pathToWatch); } + } else if (directory.isFile()) { + logger.warn("Trying to watch directory {}, however it is a file", pathToWatch); } + + addFiles(listFiles(directory.toPath(), watchSubDirectories())).thenRun(() -> initialized.complete(null)); } @Override - public void onDependencyChange(@Nullable String scriptId) { - logger.debug("Reimporting {}...", scriptId); - try { - ScriptFileReference scriptFileReference = new ScriptFileReference(new URL(scriptId)); - if (loaded.contains(scriptFileReference)) { - importFileWhenReady(scriptFileReference); - } - } catch (MalformedURLException ignored) { + public void onDependencyChange(String scriptIdentifier) { + logger.debug("Reimporting {}...", scriptIdentifier); + ScriptFileReference ref = scriptMap.get(scriptIdentifier); + if (ref != null && !ref.getQueueStatus().getAndSet(true)) { + importFileWhenReady(scriptIdentifier); } - logger.debug("Ignoring dependency change for {} as it is no file or not loaded by this file watcher", scriptId); } @Override public synchronized void onReadyMarkerAdded(ReadyMarker readyMarker) { - int newLevel = Integer.parseInt(readyMarker.getIdentifier()); + int previousLevel = currentStartLevel; + currentStartLevel = Integer.parseInt(readyMarker.getIdentifier()); - if (newLevel > currentStartLevel) { - onStartLevelChanged(newLevel); + if (currentStartLevel < StartLevelService.STARTLEVEL_STATES) { + // ignore start level less than 30 + return; + } + + if (currentStartLevel == StartLevelService.STARTLEVEL_STATES) { + initialImport(); + } else { + scriptMap.values().stream().sorted() + .filter(ref -> needsStartLevelProcessing(ref, previousLevel, currentStartLevel)) + .forEach(ref -> importFileWhenReady(ref.getScriptIdentifier())); } } - @Override - @SuppressWarnings("PMD.EmptyWhileStmt") - public synchronized void onReadyMarkerRemoved(ReadyMarker readyMarker) { - int newLevel = Integer.parseInt(readyMarker.getIdentifier()); + private boolean needsStartLevelProcessing(ScriptFileReference ref, int previousLevel, int newLevel) { + int refStartLevel = ref.getStartLevel(); + return !ref.getLoadedStatus().get() && newLevel >= refStartLevel && previousLevel < refStartLevel + && !ref.getQueueStatus().getAndSet(true); + } - if (currentStartLevel > newLevel) { - while (newLevel-- > 0 && !readyService - .isReady(new ReadyMarker(StartLevelService.STARTLEVEL_MARKER_TYPE, Integer.toString(newLevel)))) { - } - onStartLevelChanged(newLevel); - } + @Override + public void onReadyMarkerRemoved(ReadyMarker readyMarker) { + // we don't need to process this, as openHAB only reduces its start level when the system is shutdown + // in this case the service is de-activated anyway and al scripts are removed by {@link #deactivate} } @Override public void factoryAdded(@Nullable String scriptType) { + scriptMap.forEach((scriptIdentifier, ref) -> { + if (ref.getScriptType().equals(scriptType) && !ref.getQueueStatus().getAndSet(true)) { + importFileWhenReady(scriptIdentifier); + } + }); } @Override @@ -383,6 +408,9 @@ public abstract class AbstractScriptFileWatcher extends AbstractWatchService imp if (scriptType == null) { return; } - loaded.stream().filter(ref -> scriptType.equals(ref.getScriptType().get())).forEach(this::importFileWhenReady); + Set toRemove = scriptMap.values().stream() + .filter(ref -> ref.getLoadedStatus().get() && scriptType.equals(ref.getScriptType())) + .map(ScriptFileReference::getScriptIdentifier).collect(Collectors.toSet()); + toRemove.forEach(this::removeFile); } } diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileReference.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileReference.java deleted file mode 100644 index 8a6ba4df5..000000000 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileReference.java +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Copyright (c) 2010-2023 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.core.automation.module.script.rulesupport.loader; - -import java.net.URISyntaxException; -import java.net.URL; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.openhab.core.service.StartLevelService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Script File wrapper offering various methods to inspect the script - * - * @author Jonathan Gilbert - initial contribution - */ -@NonNullByDefault -public class ScriptFileReference implements Comparable { - - private static final Set EXCLUDED_FILE_EXTENSIONS = Set.of("txt", "old", "example", "backup", "md", "swp", - "tmp", "bak"); - - private static final Pattern[] START_LEVEL_PATTERNS = new Pattern[] { Pattern.compile(".*/sl(\\d{2})/[^/]+"), // script - // in - // immediate - // slXX - // directory - Pattern.compile(".*/[^/]+\\.sl(\\d{2})\\.[^/.]+") // script named .slXX. - }; - - private static final Logger LOGGER = LoggerFactory.getLogger(ScriptFileReference.class); - - private final URL scriptFileURL; - - public ScriptFileReference(URL scriptFileURL) { - this.scriptFileURL = scriptFileURL; - } - - public URL getScriptFileURL() { - return scriptFileURL; - } - - public int getStartLevel() { - for (Pattern p : START_LEVEL_PATTERNS) { - Matcher m = p.matcher(scriptFileURL.getPath()); - if (m.find() && m.groupCount() > 0) { - try { - return Integer.parseInt(m.group(1)); - } catch (NumberFormatException nfe) { - LOGGER.warn("Extracted start level {} from {}, but it's not an integer. Ignoring.", m.group(1), - scriptFileURL.getPath()); - } - } - } - - return StartLevelService.STARTLEVEL_RULEENGINE; - } - - public Optional getScriptType() { - String fileName = scriptFileURL.getPath(); - int index = fileName.lastIndexOf("."); - if (index == -1) { - return Optional.empty(); - } - String fileExtension = fileName.substring(index + 1); - - // ignore known file extensions for "temp" files - if (EXCLUDED_FILE_EXTENSIONS.contains(fileExtension) || fileExtension.endsWith("~")) { - return Optional.empty(); - } - return Optional.of(fileExtension); - } - - public String getScriptIdentifier() { - return scriptFileURL.toString(); - } - - @Override - public int compareTo(ScriptFileReference other) { - try { - Path path1 = Paths.get(scriptFileURL.toURI()); - String name1 = path1.getFileName().toString(); - LOGGER.trace("o1 [{}], path1 [{}], name1 [{}]", scriptFileURL, path1, name1); - - Path path2 = Paths.get(other.scriptFileURL.toURI()); - String name2 = path2.getFileName().toString(); - LOGGER.trace("o2 [{}], path2 [{}], name2 [{}]", other.scriptFileURL, path2, name2); - - int startLevelCompare = Integer.compare(getStartLevel(), other.getStartLevel()); - if (startLevelCompare != 0) { - return startLevelCompare; - } - int nameCompare = name1.compareToIgnoreCase(name2); - if (nameCompare != 0) { - return nameCompare; - } else { - return path1.getParent().toString().compareToIgnoreCase(path2.getParent().toString()); - } - } catch (URISyntaxException e) { - LOGGER.error("URI syntax exception", e); - return 0; - } - } - - @Override - public boolean equals(@Nullable Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - ScriptFileReference that = (ScriptFileReference) o; - return scriptFileURL.equals(that.scriptFileURL); - } - - @Override - public int hashCode() { - return Objects.hash(scriptFileURL); - } -} diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileWatcher.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileWatcher.java new file mode 100644 index 000000000..e0fa918ac --- /dev/null +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/main/java/org/openhab/core/automation/module/script/rulesupport/loader/ScriptFileWatcher.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.automation.module.script.rulesupport.loader; + +import java.util.concurrent.CompletableFuture; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.service.ReadyMarker; + +/** + * The {@link ScriptFileWatcher} interface needs to be implemented by script file watchers. Services that implement this + * interface can be tracked to set a {@link ReadyMarker} once all services have completed their initial loading. + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public interface ScriptFileWatcher { + + /** + * Returns a {@link CompletableFuture} that completes when the {@link ScriptFileWatcher} has completed it's + * initial loading of files. + * + * @return the {@link CompletableFuture} + */ + CompletableFuture<@Nullable Void> ifInitialized(); +} diff --git a/bundles/org.openhab.core.automation.module.script.rulesupport/src/test/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcherTest.java b/bundles/org.openhab.core.automation.module.script.rulesupport/src/test/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcherTest.java index 0555376e4..54cfa0b55 100644 --- a/bundles/org.openhab.core.automation.module.script.rulesupport/src/test/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcherTest.java +++ b/bundles/org.openhab.core.automation.module.script.rulesupport/src/test/java/org/openhab/core/automation/module/script/rulesupport/loader/AbstractScriptFileWatcherTest.java @@ -13,15 +13,21 @@ package org.openhab.core.automation.module.script.rulesupport.loader; import static java.nio.file.StandardWatchEventKinds.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import static org.openhab.core.OpenHAB.CONFIG_DIR_PROG_ARGUMENT; import java.io.File; import java.io.IOException; import java.nio.file.Path; -import java.util.concurrent.Executors; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; import javax.script.ScriptEngine; @@ -31,7 +37,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.io.TempDir; -import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -41,36 +46,49 @@ import org.openhab.core.automation.module.script.ScriptDependencyTracker; import org.openhab.core.automation.module.script.ScriptEngineContainer; import org.openhab.core.automation.module.script.ScriptEngineFactory; import org.openhab.core.automation.module.script.ScriptEngineManager; -import org.openhab.core.automation.module.script.rulesupport.internal.loader.DelegatingScheduledExecutorService; +import org.openhab.core.automation.module.script.rulesupport.internal.loader.ScriptFileReference; import org.openhab.core.service.ReadyMarker; import org.openhab.core.service.ReadyService; import org.openhab.core.service.StartLevelService; +import org.openhab.core.test.java.JavaTest; import org.opentest4j.AssertionFailedError; /** - * Test for Script File Watcher, covering differing start levels and dependency tracking + * Test for {@link AbstractScriptFileWatcher}, covering differing start levels and dependency tracking * - * @author Jonathan Gilbert - initial contribution + * @author Jonathan Gilbert - Initial contribution + * @author Jan N. Klug - Refactoring and improvements */ @NonNullByDefault @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -class AbstractScriptFileWatcherTest { +class AbstractScriptFileWatcherTest extends JavaTest { + private boolean watchSubDirectories = true; private @NonNullByDefault({}) AbstractScriptFileWatcher scriptFileWatcher; private @Mock @NonNullByDefault({}) ScriptEngineManager scriptEngineManagerMock; private @Mock @NonNullByDefault({}) ScriptDependencyTracker scriptDependencyTrackerMock; - private @Mock @NonNullByDefault({}) ReadyService readyService; + private @Mock @NonNullByDefault({}) StartLevelService startLevelServiceMock; + private @Mock @NonNullByDefault({}) ReadyService readyServiceMock; protected @NonNullByDefault({}) @TempDir Path tempScriptDir; + private final AtomicInteger atomicInteger = new AtomicInteger(); + + private int currentStartLevel = 0; + @BeforeEach public void setUp() { - scriptFileWatcher = new AbstractScriptFileWatcher(scriptEngineManagerMock, readyService, - "automation" + File.separator + "jsr223") { - }; - scriptFileWatcher.activate(); + System.setProperty(CONFIG_DIR_PROG_ARGUMENT, tempScriptDir.toString()); + + atomicInteger.set(0); + currentStartLevel = 0; + + // ensure initialize is not called on initialization + when(startLevelServiceMock.getStartLevel()).thenAnswer(invocation -> currentStartLevel); + + scriptFileWatcher = createScriptFileWatcher(); } @AfterEach @@ -78,7 +96,502 @@ class AbstractScriptFileWatcherTest { scriptFileWatcher.deactivate(); } - protected Path getFile(String name) { + @Test + public void testLoadOneDefaultFileAlreadyStarted() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p = getFile("script.js"); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p.toString()); + } + + @Test + public void testSubDirectoryIncludedInInitialImport() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + Path p0 = getFile("script.js"); + Path p1 = getFile("dir/script.js"); + + updateStartLevel(100); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", + ScriptFileReference.getScriptIdentifier(p0)); + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", + ScriptFileReference.getScriptIdentifier(p1)); + } + + @Test + public void testSubDirectoryIgnoredInInitialImport() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + watchSubDirectories = false; + Path p0 = getFile("script.js"); + Path p1 = getFile("dir/script.js"); + + updateStartLevel(100); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", + ScriptFileReference.getScriptIdentifier(p0)); + verify(scriptEngineManagerMock, never()).createScriptEngine("js", ScriptFileReference.getScriptIdentifier(p1)); + } + + @Test + public void testLoadOneDefaultFileWaitUntilStarted() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(20); + + Path p = getFile("script.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + awaitEmptyQueue(); + + // verify not yet called + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + + // verify is called when the start level increases + updateStartLevel(100); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p.toString()); + } + + @Test + public void testLoadOneCustomFileWaitUntilStarted() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + + updateStartLevel(50); + + Path p = getFile("script.sl60.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + awaitEmptyQueue(); + + // verify not yet called + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + + // verify is called when the start level increases + updateStartLevel(100); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p.toString()); + } + + @Test + public void testLoadTwoCustomFilesDifferentStartLevels() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(20); + + Path p1 = getFile("script.sl70.js"); + Path p2 = getFile("script.sl50.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p1); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p2); + + awaitEmptyQueue(); + + // verify not yet called + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + + updateStartLevel(40); + + awaitEmptyQueue(); + + // verify not yet called + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + + updateStartLevel(60); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p2.toString()); + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), eq(p1.toString())); + + updateStartLevel(80); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p1.toString()); + } + + @Test + public void testLoadTwoCustomFilesAlternativePatternDifferentStartLevels() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + + Path p1 = getFile("sl70/script.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p1); + Path p2 = getFile("sl50/script.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p2); + + // verify not yet called + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + + updateStartLevel(40); + + // verify not yet called + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + + updateStartLevel(60); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p2.toString()); + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), eq(p1.toString())); + + updateStartLevel(80); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p1.toString()); + } + + @Test + public void testLoadOneDefaultFileDelayedSupport() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(false); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p = getFile("script.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + // verify not yet called but checked + waitForAssert(() -> verify(scriptEngineManagerMock).isSupported(anyString())); + verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + + // add support is added for .js files + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + scriptFileWatcher.factoryAdded("js"); + + awaitEmptyQueue(); + + // verify script has now been processed + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p.toString()); + } + + @Test + public void testOrderingWithinSingleStartLevel() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(50); + + Path p64 = getFile("script.sl64.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p64); + Path p66 = getFile("script.sl66.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p66); + Path p65 = getFile("script.sl65.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p65); + + updateStartLevel(70); + + awaitEmptyQueue(); + + InOrder inOrder = inOrder(scriptEngineManagerMock); + + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p64.toString()); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p65.toString()); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p66.toString()); + } + + @Test + public void testOrderingStartlevelFolders() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + + Path p50 = getFile("a_script.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p50); + Path p40 = getFile("sl40/b_script.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p40); + Path p30 = getFile("sl30/script.js"); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p30); + + awaitEmptyQueue(); + + updateStartLevel(70); + + awaitEmptyQueue(); + + InOrder inOrder = inOrder(scriptEngineManagerMock); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p30.toString()); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p40.toString()); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p50.toString()); + } + + @Test + public void testReloadActiveWhenDependencyChanged() { + ScriptEngineFactory scriptEngineFactoryMock = mock(ScriptEngineFactory.class); + when(scriptEngineFactoryMock.getDependencyTracker()).thenReturn(scriptDependencyTrackerMock); + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineContainer.getFactory()).thenReturn(scriptEngineFactoryMock); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + + updateStartLevel(100); + + Path p = getFile("script.js"); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", p.toString()); + + scriptFileWatcher.onDependencyChange(p.toString()); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000).times(2)).createScriptEngine("js", p.toString()); + } + + @Test + public void testNotReloadInactiveWhenDependencyChanged() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p = getFile("script.js"); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + awaitEmptyQueue(); + + scriptFileWatcher.onDependencyChange(p.toString()); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, never()).createScriptEngine("js", p.toString()); + } + + @Test + public void testRemoveBeforeReAdd() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p = getFile("script.js"); + + InOrder inOrder = inOrder(scriptEngineManagerMock); + String scriptIdentifier = ScriptFileReference.getScriptIdentifier(p); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + awaitEmptyQueue(); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", scriptIdentifier); + + scriptFileWatcher.processWatchEvent(null, ENTRY_MODIFY, p); + + awaitEmptyQueue(); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).removeEngine(scriptIdentifier); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", scriptIdentifier); + } + + @Test + public void testDirectoryAdded() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p1 = getFile("dir/script.js"); + Path p2 = getFile("dir/script2.js"); + Path d = p1.getParent(); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, d); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p1.toString()); + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p2.toString()); + } + + @Test + public void testDirectoryAddedSubDirIncluded() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p1 = getFile("dir/script.js"); + Path p2 = getFile("dir/sub/script.js"); + Path d = p1.getParent(); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, d); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", + ScriptFileReference.getScriptIdentifier(p1)); + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", + ScriptFileReference.getScriptIdentifier(p2)); + } + + @Test + public void testDirectoryAddedSubDirIgnored() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + watchSubDirectories = false; + updateStartLevel(100); + + Path p1 = getFile("dir/script.js"); + Path p2 = getFile("dir/sub/script.js"); + Path d = p1.getParent(); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, d); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", + ScriptFileReference.getScriptIdentifier(p1)); + verify(scriptEngineManagerMock, never()).createScriptEngine("js", ScriptFileReference.getScriptIdentifier(p2)); + } + + @Test + public void testSortsAllFilesInNewDirectory() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p20 = getFile("dir/script.sl20.js"); + Path p10 = getFile("dir/script2.sl10.js"); + Path d = p10.getParent(); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, d); + + awaitEmptyQueue(); + + InOrder inOrder = inOrder(scriptEngineManagerMock); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p10.toString()); + inOrder.verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p20.toString()); + } + + @Test + public void testDirectoryRemoved() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(true); + updateStartLevel(100); + + Path p1 = getFile("dir/script.js"); + Path p2 = getFile("dir/script2.js"); + Path d = p1.getParent(); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p1); + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p2); + scriptFileWatcher.processWatchEvent(null, ENTRY_DELETE, d); + + awaitEmptyQueue(); + + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p1.toString()); + verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p2.toString()); + verify(scriptEngineManagerMock, timeout(10000)).removeEngine(p1.toString()); + verify(scriptEngineManagerMock, timeout(10000)).removeEngine(p2.toString()); + } + + @Test + public void testScriptEngineRemovedOnFailedLoad() { + when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); + ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); + when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); + when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + when(scriptEngineManagerMock.loadScript(any(), any())).thenReturn(false); + updateStartLevel(100); + + Path p = getFile("script.js"); + + when(scriptEngineContainer.getIdentifier()).thenReturn(ScriptFileReference.getScriptIdentifier(p)); + + scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + + awaitEmptyQueue(); + + InOrder inOrder = inOrder(scriptEngineManagerMock); + inOrder.verify(scriptEngineManagerMock, timeout(1000)).createScriptEngine("js", + ScriptFileReference.getScriptIdentifier(p)); + inOrder.verify(scriptEngineManagerMock, timeout(1000)) + .loadScript(eq(ScriptFileReference.getScriptIdentifier(p)), any()); + inOrder.verify(scriptEngineManagerMock, timeout(1000)).removeEngine(ScriptFileReference.getScriptIdentifier(p)); + } + + @Test + public void testIfInitializedForEarlyInitialization() { + CompletableFuture initialized = scriptFileWatcher.ifInitialized(); + + assertThat(initialized.isDone(), is(false)); + + updateStartLevel(StartLevelService.STARTLEVEL_STATES); + waitForAssert(() -> assertThat(initialized.isDone(), is(true))); + } + + @Test + public void testIfInitializedForLateInitialization() { + when(startLevelServiceMock.getStartLevel()).thenReturn(StartLevelService.STARTLEVEL_RULEENGINE); + AbstractScriptFileWatcher watcher = createScriptFileWatcher(); + + waitForAssert(() -> assertThat(watcher.ifInitialized().isDone(), is(true))); + + watcher.deactivate(); + } + + private Path getFile(String name) { Path tempFile = tempScriptDir.resolve(name); try { File parent = tempFile.getParent().toFile(); @@ -96,336 +609,56 @@ class AbstractScriptFileWatcherTest { return Path.of(tempFile.toUri()); } - void updateStartLevel(int level) { - scriptFileWatcher - .onReadyMarkerAdded(new ReadyMarker(StartLevelService.STARTLEVEL_MARKER_TYPE, Integer.toString(level))); + /** + * Increase the start level in steps of 10 + * + * @param level the target start-level + */ + private void updateStartLevel(int level) { + while (currentStartLevel < level) { + currentStartLevel += 10; + scriptFileWatcher.onReadyMarkerAdded( + new ReadyMarker(StartLevelService.STARTLEVEL_MARKER_TYPE, Integer.toString(currentStartLevel))); + } } - @Test - public void testLoadOneDefaultFileAlreadyStarted() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + private AbstractScriptFileWatcher createScriptFileWatcher() { + return new AbstractScriptFileWatcher(scriptEngineManagerMock, readyServiceMock, startLevelServiceMock, "") { - updateStartLevel(100); + @Override + protected ScheduledExecutorService getScheduler() { + return new CountingScheduledExecutor(atomicInteger); + } - Path p = getFile("script.js"); - - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); - - verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p.toFile().toURI().toString()); + @Override + protected boolean watchSubDirectories() { + return watchSubDirectories; + } + }; } - @Test - public void testLoadOneDefaultFileWaitUntilStarted() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - Path p = getFile("script.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); - - // verify not yet called - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); - - // verify is called when the start level increases - updateStartLevel(100); - verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p.toFile().toURI().toString()); + private void awaitEmptyQueue() { + waitForAssert(() -> assertThat(atomicInteger.get(), is(0))); } - @Test - public void testLoadOneCustomFileWaitUntilStarted() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); + private static class CountingScheduledExecutor extends ScheduledThreadPoolExecutor { - updateStartLevel(50); + private final AtomicInteger counter; - Path p = getFile("script.sl60.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); + public CountingScheduledExecutor(AtomicInteger counter) { + super(1); - // verify not yet called - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); + this.counter = counter; + } - // verify is called when the start level increases - updateStartLevel(100); - verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p.toFile().toURI().toString()); - } - - @Test - public void testLoadTwoCustomFilesDifferentStartLevels() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - Path p1 = getFile("script.sl70.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p1); - Path p2 = getFile("script.sl50.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p2); - - // verify not yet called - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); - - updateStartLevel(40); - - // verify not yet called - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); - - updateStartLevel(60); - - verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p2.toFile().toURI().toString()); - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), eq(p1.toFile().toURI().toString())); - - updateStartLevel(80); - - verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p1.toFile().toURI().toString()); - } - - @Test - public void testLoadTwoCustomFilesAlternativePatternDifferentStartLevels() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - Path p1 = getFile("sl70/script.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p1); - Path p2 = getFile("sl50/script.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p2); - - // verify not yet called - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); - - updateStartLevel(40); - - // verify not yet called - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); - - updateStartLevel(60); - - verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p2.toFile().toURI().toString()); - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), eq(p1.toFile().toURI().toString())); - - updateStartLevel(80); - - verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p1.toFile().toURI().toString()); - } - - @Test - public void testLoadOneDefaultFileDelayedSupport() { - // set an executor which captures the scheduled task - ScheduledExecutorService scheduledExecutorService = spy( - new DelegatingScheduledExecutorService(Executors.newSingleThreadScheduledExecutor())); - ArgumentCaptor scheduledTask = ArgumentCaptor.forClass(Runnable.class); - scriptFileWatcher.setExecutorFactory(() -> scheduledExecutorService); - - when(scriptEngineManagerMock.isSupported("js")).thenReturn(false); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - updateStartLevel(100); - - verify(scheduledExecutorService).scheduleWithFixedDelay(scheduledTask.capture(), anyLong(), anyLong(), any()); - - Path p = getFile("script.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); - - // verify not yet called - verify(scriptEngineManagerMock, never()).createScriptEngine(anyString(), anyString()); - - // add support is added for .js files - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - // update (in current thread) - scheduledTask.getValue().run(); - - // verify script has now been processed - verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p.toFile().toURI().toString()); - } - - @Test - public void testOrderingWithinSingleStartLevel() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - Path p64 = getFile("script.sl64.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p64); - Path p66 = getFile("script.sl66.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p66); - Path p65 = getFile("script.sl65.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p65); - - updateStartLevel(70); - - InOrder inOrder = inOrder(scriptEngineManagerMock); - - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p64.toFile().toURI().toString()); - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p65.toFile().toURI().toString()); - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p66.toFile().toURI().toString()); - } - - @Test - public void testOrderingStartlevelFolders() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - Path p50 = getFile("a_script.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p50); - Path p40 = getFile("sl40/b_script.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p40); - Path p30 = getFile("sl30/script.js"); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p30); - - updateStartLevel(70); - - InOrder inOrder = inOrder(scriptEngineManagerMock); - - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p30.toFile().toURI().toString()); - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p40.toFile().toURI().toString()); - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p50.toFile().toURI().toString()); - } - - @Test - public void testReloadActiveWhenDependencyChanged() { - ScriptEngineFactory scriptEngineFactoryMock = mock(ScriptEngineFactory.class); - when(scriptEngineFactoryMock.getDependencyTracker()).thenReturn(scriptDependencyTrackerMock); - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineContainer.getFactory()).thenReturn(scriptEngineFactoryMock); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - updateStartLevel(100); - - Path p = getFile("script.js"); - - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); - - scriptFileWatcher.onDependencyChange(p.toFile().toURI().toString()); - - verify(scriptEngineManagerMock, timeout(10000).times(2)).createScriptEngine("js", - p.toFile().toURI().toString()); - } - - @Test - public void testNotReloadInactiveWhenDependencyChanged() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - Path p = getFile("script.js"); - - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); - - scriptFileWatcher.onDependencyChange(p.toFile().toURI().toString()); - - verify(scriptEngineManagerMock, never()).createScriptEngine("js", p.toFile().toURI().toString()); - } - - @Test - public void testRemoveBeforeReAdd() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - updateStartLevel(100); - - Path p = getFile("script.js"); - - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p); - scriptFileWatcher.processWatchEvent(null, ENTRY_MODIFY, p); - - verify(scriptEngineManagerMock).removeEngine(p.toFile().toURI().toString()); - verify(scriptEngineManagerMock, timeout(10000).times(2)).createScriptEngine("js", - p.toFile().toURI().toString()); - } - - @Test - public void testDirectoryAdded() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - updateStartLevel(100); - - Path p1 = getFile("dir/script.js"); - Path p2 = getFile("dir/script2.js"); - Path d = p1.getParent(); - - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, d); - - verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p1.toFile().toURI().toString()); - verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p2.toFile().toURI().toString()); - } - - @Test - public void testSortsAllFilesInNewDirectory() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - updateStartLevel(100); - - Path p20 = getFile("dir/script.sl20.js"); - Path p10 = getFile("dir/script2.sl10.js"); - Path d = p10.getParent(); - - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, d); - - InOrder inOrder = inOrder(scriptEngineManagerMock); - - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p10.toFile().toURI().toString()); - inOrder.verify(scriptEngineManagerMock, timeout(10000).times(1)).createScriptEngine("js", - p20.toFile().toURI().toString()); - } - - @Test - public void testDirectoryRemoved() { - when(scriptEngineManagerMock.isSupported("js")).thenReturn(true); - ScriptEngineContainer scriptEngineContainer = mock(ScriptEngineContainer.class); - when(scriptEngineContainer.getScriptEngine()).thenReturn(mock(ScriptEngine.class)); - when(scriptEngineManagerMock.createScriptEngine(anyString(), anyString())).thenReturn(scriptEngineContainer); - - updateStartLevel(100); - - Path p1 = getFile("dir/script.js"); - Path p2 = getFile("dir/script2.js"); - Path d = p1.getParent(); - - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p1); - scriptFileWatcher.processWatchEvent(null, ENTRY_CREATE, p2); - scriptFileWatcher.processWatchEvent(null, ENTRY_DELETE, d); - - verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p1.toFile().toURI().toString()); - verify(scriptEngineManagerMock, timeout(10000)).createScriptEngine("js", p2.toFile().toURI().toString()); - verify(scriptEngineManagerMock, timeout(10000)).removeEngine(p1.toFile().toURI().toString()); - verify(scriptEngineManagerMock, timeout(10000)).removeEngine(p2.toFile().toURI().toString()); + @Override + public Future submit(@NonNullByDefault({}) Runnable runnable) { + counter.getAndIncrement(); + Runnable wrappedRunnable = () -> { + runnable.run(); + counter.getAndDecrement(); + }; + return super.submit(wrappedRunnable); + } } } diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptEngineManager.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptEngineManager.java index 29e356019..e6fb4e9bf 100644 --- a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptEngineManager.java +++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptEngineManager.java @@ -42,8 +42,9 @@ public interface ScriptEngineManager { * * @param engineIdentifier the unique identifier for the ScriptEngine (script file path or UUID) * @param scriptData the content of the script + * @return true if the script was successfully loaded, false otherwise */ - void loadScript(String engineIdentifier, InputStreamReader scriptData); + boolean loadScript(String engineIdentifier, InputStreamReader scriptData); /** * Unloads the ScriptEngine loaded with the engineIdentifier diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java index 2fdeaf5b6..9d8d7e801 100644 --- a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java +++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineManagerImpl.java @@ -174,13 +174,12 @@ public class ScriptEngineManagerImpl implements ScriptEngineManager { } @Override - public void loadScript(String engineIdentifier, InputStreamReader scriptData) { + public boolean loadScript(String engineIdentifier, InputStreamReader scriptData) { ScriptEngineContainer container = loadedScriptEngineInstances.get(engineIdentifier); if (container == null) { logger.error("Could not load script, as no ScriptEngine has been created"); } else { ScriptEngine engine = container.getScriptEngine(); - try { engine.eval(scriptData); if (engine instanceof Invocable) { @@ -193,11 +192,13 @@ public class ScriptEngineManagerImpl implements ScriptEngineManager { } else { logger.trace("ScriptEngine does not support Invocable interface"); } + return true; } catch (Exception ex) { logger.error("Error during evaluation of script '{}': {}", engineIdentifier, ex.getMessage()); logger.debug("", ex); } } + return false; } @Override