diff --git a/.gitignore b/.gitignore index 7937befa7c4..b3146a5d317 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ dependency-reduced-pom.xml # OS specific files .DS_Store +plans/ \ No newline at end of file diff --git a/.java-version b/.java-version new file mode 100644 index 00000000000..aabe6ec3909 --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/exist-core/src/main/java/org/exist/test/runner/AbstractTestRunner.java b/exist-core/src/main/java/org/exist/test/runner/AbstractTestRunner.java index 1eea44d1ea4..2572148dcb4 100644 --- a/exist-core/src/main/java/org/exist/test/runner/AbstractTestRunner.java +++ b/exist-core/src/main/java/org/exist/test/runner/AbstractTestRunner.java @@ -65,7 +65,20 @@ protected AbstractTestRunner(final Path path, final boolean parallel) { this.parallel = parallel; } + /** + * Returns the path to the test file (XQuery or XML). Used for hang reporting and diagnostics. + * + * @return the source path of the test file + */ + public Path getSourcePath() { + return path; + } + protected static Sequence executeQuery(final BrokerPool brokerPool, final Source query, final List>> externalVariableBindings) throws EXistException, PermissionDeniedException, XPathException, IOException, DatabaseConfigurationException { + return executeQuery(brokerPool, query, externalVariableBindings, null); + } + + protected static Sequence executeQuery(final BrokerPool brokerPool, final Source query, final List>> externalVariableBindings, @javax.annotation.Nullable final Path moduleLoadPath) throws EXistException, PermissionDeniedException, XPathException, IOException, DatabaseConfigurationException { final SecurityManager securityManager = requireNonNull(brokerPool.getSecurityManager(), "securityManager is null"); try (final DBBroker broker = brokerPool.get(Optional.of(securityManager.getSystemSubject()))) { final XQueryPool queryPool = brokerPool.getXQueryPool(); @@ -82,9 +95,11 @@ protected static Sequence executeQuery(final BrokerPool brokerPool, final Source // setup misc. context context.setBaseURI(new AnyURIValue("/db")); - if(query instanceof FileSource) { + if (moduleLoadPath != null) { + context.setModuleLoadPath(moduleLoadPath.toAbsolutePath().toString()); + } else if (query instanceof FileSource) { final Path queryPath = Paths.get(((FileSource) query).getPath().toAbsolutePath().toString()); - if(Files.isDirectory(queryPath)) { + if (Files.isDirectory(queryPath)) { context.setModuleLoadPath(queryPath.toString()); } else { context.setModuleLoadPath(queryPath.getParent().toString()); diff --git a/exist-core/src/main/java/org/exist/test/runner/ExtTestErrorFunction.java b/exist-core/src/main/java/org/exist/test/runner/ExtTestErrorFunction.java index 1389615d818..17746374031 100644 --- a/exist-core/src/main/java/org/exist/test/runner/ExtTestErrorFunction.java +++ b/exist-core/src/main/java/org/exist/test/runner/ExtTestErrorFunction.java @@ -60,7 +60,8 @@ public Sequence eval(final Sequence contextSequence, final Item contextItem) thr // notify JUnit try { - final XPathException errorReason = errorMapAsXPathException(error); + final XPathException errorReason = errorMapAsXPathException(name, error); + logOneLineIfXQueryError(name, error); notifier.fireTestFailure(new Failure(description, errorReason)); } catch (final XPathException e) { //signal internal failure @@ -70,51 +71,105 @@ public Sequence eval(final Sequence contextSequence, final Item contextItem) thr return Sequence.EMPTY_SEQUENCE; } - private XPathException errorMapAsXPathException(final MapType errorMap) throws XPathException { - final Sequence seqDescription = errorMap.get(new StringValue(this, "description")); - final String description; - if(seqDescription != null && !seqDescription.isEmpty()) { - description = seqDescription.itemAt(0).getStringValue(); - } else { - description = ""; + private void logOneLineIfXQueryError(final String testName, @Nullable final MapType errorMap) { + if (errorMap == null) { + return; + } + try { + final Sequence seqModule = errorMap.get(new StringValue(this, "module")); + final String modulePath = (seqModule != null && !seqModule.isEmpty()) + ? seqModule.itemAt(0).getStringValue() : null; + final String file = modulePath != null ? modulePath.replaceFirst("^.*[/\\\\]", "") : "xquery"; + final Sequence seqLine = errorMap.get(new StringValue(this, "line-number")); + final int line = (seqLine != null && !seqLine.isEmpty()) + ? seqLine.itemAt(0).toJavaObject(int.class) : 0; + final String oneLine = "XQuery failure: " + file + (line > 0 ? ":" + line + " " : " ") + testName; + XQueryFailureLog.log(oneLine); + } catch (final Exception ignored) { + // do not affect test execution } + } - final Sequence seqErrorCode = errorMap.get(new StringValue(this, "code")); - final ErrorCodes.ErrorCode errorCode; - if(seqErrorCode != null && !seqErrorCode.isEmpty()) { - errorCode = new ErrorCodes.ErrorCode(((QNameValue)seqErrorCode.itemAt(0)).getQName(), description); - } else { - errorCode = ErrorCodes.ERROR; + private XPathException errorMapAsXPathException(final String testName, final MapType errorMap) throws XPathException { + if (errorMap == null) { + final XPathException xpe = new XPathException(-1, -1, ErrorCodes.ERROR, "unknown error"); + xpe.setStackTrace(new StackTraceElement[]{ + new StackTraceElement(suiteName, testName != null ? testName : "eval", "xquery", 0) + }); + return xpe; } + final String description = getStringFromErrorMap(errorMap, "description", ""); + final ErrorCodes.ErrorCode errorCode = getErrorCodeFromErrorMap(errorMap, description); + final int lineNumber = getIntFromErrorMap(errorMap, "line-number", -1); + final int columnNumber = getIntFromErrorMap(errorMap, "column-number", -1); + final XPathException xpe = new XPathException(lineNumber, columnNumber, errorCode, description); + final String moduleFileName = getModuleFileNameFromErrorMap(errorMap); + final StackTraceElement[] javaStack = getJavaStackFromErrorMap(errorMap); + setStackTraceOnException(xpe, testName, moduleFileName, lineNumber, javaStack); + return xpe; + } - final Sequence seqLineNumber = errorMap.get(new StringValue(this, "line-number")); - final int lineNumber; - if(seqLineNumber != null && !seqLineNumber.isEmpty()) { - lineNumber = seqLineNumber.itemAt(0).toJavaObject(int.class); - } else { - lineNumber = -1; + private String getStringFromErrorMap(final MapType errorMap, final String key, final String defaultVal) throws XPathException { + final Sequence seq = errorMap.get(new StringValue(this, key)); + if (seq != null && !seq.isEmpty()) { + return seq.itemAt(0).getStringValue(); } + return defaultVal; + } - final Sequence seqColumnNumber = errorMap.get(new StringValue(this, "column-number")); - final int columnNumber; - if(seqColumnNumber != null && !seqColumnNumber.isEmpty()) { - columnNumber = seqColumnNumber.itemAt(0).toJavaObject(int.class); - } else { - columnNumber = -1; + private ErrorCodes.ErrorCode getErrorCodeFromErrorMap(final MapType errorMap, final String description) throws XPathException { + final Sequence seq = errorMap.get(new StringValue(this, "code")); + if (seq != null && !seq.isEmpty()) { + return new ErrorCodes.ErrorCode(((QNameValue) seq.itemAt(0)).getQName(), description); } + return ErrorCodes.ERROR; + } - final XPathException xpe = new XPathException(lineNumber, columnNumber, errorCode, description); + private int getIntFromErrorMap(final MapType errorMap, final String key, final int defaultVal) throws XPathException { + final Sequence seq = errorMap.get(new StringValue(this, key)); + if (seq != null && !seq.isEmpty()) { + return seq.itemAt(0).toJavaObject(int.class); + } + return defaultVal; + } - final Sequence seqJavaStackTrace = errorMap.get(new StringValue(this, "java-stack-trace")); - if (seqJavaStackTrace != null && !seqJavaStackTrace.isEmpty()) { - try { - xpe.setStackTrace(convertStackTraceElements(seqJavaStackTrace)); - } catch (final NullPointerException e) { - e.printStackTrace(); - } + private String getModuleFileNameFromErrorMap(final MapType errorMap) throws XPathException { + final Sequence seq = errorMap.get(new StringValue(this, "module")); + if (seq == null || seq.isEmpty()) { + return null; } + final String path = seq.itemAt(0).getStringValue(); + return path != null ? path.replaceFirst("^.*[/\\\\]", "") : null; + } - return xpe; + private StackTraceElement[] getJavaStackFromErrorMap(final MapType errorMap) throws XPathException { + final Sequence seq = errorMap.get(new StringValue(this, "java-stack-trace")); + if (seq == null || seq.isEmpty()) { + return null; + } + try { + return convertStackTraceElements(seq); + } catch (final NullPointerException e) { + e.printStackTrace(); + return null; + } + } + + private void setStackTraceOnException(final XPathException xpe, final String testName, + final String moduleFileName, final int lineNumber, final StackTraceElement[] javaStack) { + final StackTraceElement xqueryFrame = new StackTraceElement( + suiteName, + testName != null ? testName : "eval", + moduleFileName != null ? moduleFileName : "xquery", + lineNumber > 0 ? lineNumber : 0); + if (javaStack != null && javaStack.length > 0) { + final StackTraceElement[] fullStack = new StackTraceElement[1 + javaStack.length]; + fullStack[0] = xqueryFrame; + System.arraycopy(javaStack, 0, fullStack, 1, javaStack.length); + xpe.setStackTrace(fullStack); + } else { + xpe.setStackTrace(new StackTraceElement[] { xqueryFrame }); + } } private static final Pattern PTN_CAUSED_BY = Pattern.compile("Caused by:\\s([a-zA-Z0-9_$\\.]+)(?::\\s(.+))?"); diff --git a/exist-core/src/main/java/org/exist/test/runner/ExtTestFailureFunction.java b/exist-core/src/main/java/org/exist/test/runner/ExtTestFailureFunction.java index 6d23e23e863..404c4d69906 100644 --- a/exist-core/src/main/java/org/exist/test/runner/ExtTestFailureFunction.java +++ b/exist-core/src/main/java/org/exist/test/runner/ExtTestFailureFunction.java @@ -26,6 +26,7 @@ import org.exist.xquery.XPathException; import org.exist.xquery.XQueryContext; import org.exist.xquery.functions.map.MapType; +import org.exist.xquery.value.IntegerValue; import org.exist.xquery.value.Item; import org.exist.xquery.value.Sequence; import org.exist.xquery.value.StringValue; @@ -36,9 +37,12 @@ import org.junit.runner.notification.RunNotifier; import org.xml.sax.SAXException; +import javax.annotation.Nullable; import javax.xml.transform.OutputKeys; import java.io.IOException; import java.io.StringWriter; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Properties; import static org.exist.xquery.FunctionDSL.param; @@ -46,13 +50,21 @@ public class ExtTestFailureFunction extends JUnitIntegrationFunction { + @Nullable + private final Path sourcePath; + public ExtTestFailureFunction(final XQueryContext context, final String parentName, final RunNotifier notifier) { + this(context, parentName, notifier, null); + } + + public ExtTestFailureFunction(final XQueryContext context, final String parentName, final RunNotifier notifier, @Nullable final Path sourcePath) { super("ext-test-failure-function", params( param("name", Type.STRING, "name of the test"), param("expected", Type.MAP_ITEM, "expected result of the test"), param("actual", Type.MAP_ITEM, "actual result of the test") ), context, parentName, notifier); + this.sourcePath = sourcePath; } @Override @@ -70,10 +82,27 @@ public Sequence eval(final Sequence contextSequence, final Item contextItem) thr // notify JUnit try { - final AssertionError failureReason = new ComparisonFailure("", expectedToString(expected), actualToString(actual)); - - // NOTE: We remove the StackTrace, because it is not useful to have a Java Stack Trace pointing into the XML XQuery Test Suite code - failureReason.setStackTrace(new StackTraceElement[0]); + final String fileName = getFileNameFromActual(actual); + final int lineNumber = getLineFromActual(actual); + // Short one-line for logs (filename only) + final String shortFileName = fileName != null ? lastPathSegment(fileName) : null; + final String shortLocation = shortFileName != null ? shortFileName + (lineNumber > 0 ? ":" + lineNumber : "") : null; + String oneLine = "XQuery failure: " + (shortLocation != null ? shortLocation + " " : "") + name; + if (shortFileName != null && lineNumber > 0) { + oneLine += "\n\tat (" + shortFileName + ":" + lineNumber + ")"; + } + XQueryFailureLog.log(oneLine); + final AssertionError failureReason = new ComparisonFailure(oneLine, expectedToString(expected), actualToString(actual)); + + // Stack trace for IDE navigation. IntelliJ linkifies short "filename:line" in stack traces + // but not absolute paths; use short filename so the stack line becomes clickable. + if (shortFileName != null) { + failureReason.setStackTrace(new StackTraceElement[]{ + new StackTraceElement(" ", " ", shortFileName, lineNumber > 0 ? lineNumber : 1) + }); + } else { + failureReason.setStackTrace(new StackTraceElement[0]); + } notifier.fireTestFailure(new Failure(description, failureReason)); } catch (final XPathException | SAXException | IOException | IllegalStateException e) { @@ -84,6 +113,52 @@ public Sequence eval(final Sequence contextSequence, final Item contextItem) thr return Sequence.EMPTY_SEQUENCE; } + /** + * Last path segment for short display in failure message and stack trace (short name makes IDE stack trace link clickable). + */ + private static String lastPathSegment(final String path) { + if (path == null || path.isEmpty()) { + return path; + } + try { + final Path p = Paths.get(path); + return p.getFileName() != null ? p.getFileName().toString() : path; + } catch (final Exception ignored) { + return path; + } + } + + /** Source from inspect can be full path or short identifier (implementation-dependent); we use last segment for IDE links. */ + private String getFileNameFromActual(final MapType actual) throws XPathException { + final Sequence seqSource = actual.get(new StringValue(this, "source")); + if (!seqSource.isEmpty()) { + final String s = seqSource.itemAt(0).getStringValue(); + if (s != null && !s.isEmpty()) { + return s; + } + } + if (sourcePath != null) { + return sourcePath.getFileName() != null ? sourcePath.getFileName().toString() : sourcePath.toString(); + } + return null; + } + + private int getLineFromActual(final MapType actual) throws XPathException { + final Sequence seqLine = actual.get(new StringValue(this, "line")); + if (!seqLine.isEmpty()) { + final Item item = seqLine.itemAt(0); + if (item instanceof IntegerValue) { + return (int) ((IntegerValue) item).getLong(); + } + try { + return Integer.parseInt(item.getStringValue()); + } catch (final NumberFormatException ignored) { + // fall through to 0 + } + } + return 0; + } + private String expectedToString(final MapType expected) throws XPathException, SAXException, IOException { final Sequence seqExpectedValue = expected.get(new StringValue(this, "value")); if(!seqExpectedValue.isEmpty()) { diff --git a/exist-core/src/main/java/org/exist/test/runner/XMLTestRunner.java b/exist-core/src/main/java/org/exist/test/runner/XMLTestRunner.java index 88e5649dd62..315bbd22097 100644 --- a/exist-core/src/main/java/org/exist/test/runner/XMLTestRunner.java +++ b/exist-core/src/main/java/org/exist/test/runner/XMLTestRunner.java @@ -190,7 +190,7 @@ public void run(final RunNotifier notifier) { // set callback functions for notifying junit! context -> new Tuple2<>("test-ignored-function", new FunctionReference(new FunctionCall(context, new ExtTestIgnoredFunction(context, getSuiteName(), notifier)))), context -> new Tuple2<>("test-started-function", new FunctionReference(new FunctionCall(context, new ExtTestStartedFunction(context, getSuiteName(), notifier)))), - context -> new Tuple2<>("test-failure-function", new FunctionReference(new FunctionCall(context, new ExtTestFailureFunction(context, getSuiteName(), notifier)))), + context -> new Tuple2<>("test-failure-function", new FunctionReference(new FunctionCall(context, new ExtTestFailureFunction(context, getSuiteName(), notifier, path)))), context -> new Tuple2<>("test-assumption-failed-function", new FunctionReference(new FunctionCall(context, new ExtTestAssumptionFailedFunction(context, getSuiteName(), notifier)))), context -> new Tuple2<>("test-error-function", new FunctionReference(new FunctionCall(context, new ExtTestErrorFunction(context, getSuiteName(), notifier)))), context -> new Tuple2<>("test-finished-function", new FunctionReference(new FunctionCall(context, new ExtTestFinishedFunction(context, getSuiteName(), notifier)))) diff --git a/exist-core/src/main/java/org/exist/test/runner/XQueryFailureLog.java b/exist-core/src/main/java/org/exist/test/runner/XQueryFailureLog.java new file mode 100644 index 00000000000..5a9bcf55924 --- /dev/null +++ b/exist-core/src/main/java/org/exist/test/runner/XQueryFailureLog.java @@ -0,0 +1,68 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +/** + * Appends one line per XQuery test failure/error to a log file for CI and grep. + * Writes to {@code target/surefire-reports/xquery-failures.log} when running under Maven; + * failures to write are ignored so tests are never affected. + */ +public final class XQueryFailureLog { + + private static final String LOG_NAME = "xquery-failures.log"; + + private XQueryFailureLog() { + } + + /** + * Append a single line to the XQuery failures log (one line per failure for CI/grep). + * If the message contains newlines, only the first line is written. + * Safe to call from any thread; IO errors are swallowed. + * + * @param message failure message (e.g. "XQuery failure: file.xq:34 testName"; may contain newlines) + */ + public static void log(final String message) { + if (message == null || message.isEmpty()) { + return; + } + final String firstLine = message.contains("\n") ? message.substring(0, message.indexOf('\n')) : message; + try { + final Path dir = Paths.get(System.getProperty("user.dir", "."), "target", "surefire-reports"); + if (!Files.isDirectory(dir)) { + Files.createDirectories(dir); + } + final Path file = dir.resolve(LOG_NAME); + Files.write(file, (firstLine + "\n").getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (final IOException ignored) { + // do not affect test execution + } + } +} diff --git a/exist-core/src/main/java/org/exist/test/runner/XQueryTestRunner.java b/exist-core/src/main/java/org/exist/test/runner/XQueryTestRunner.java index 8fa6b8a803f..8d870ba3a54 100644 --- a/exist-core/src/main/java/org/exist/test/runner/XQueryTestRunner.java +++ b/exist-core/src/main/java/org/exist/test/runner/XQueryTestRunner.java @@ -38,11 +38,18 @@ import org.exist.util.FileUtils; import org.exist.xquery.*; import org.exist.xquery.value.AnyURIValue; +import org.exist.xquery.value.Item; import org.exist.xquery.value.FunctionReference; +import org.exist.xquery.value.NodeValue; +import org.exist.xquery.value.Sequence; import org.junit.runner.Description; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; import org.junit.runner.notification.RunNotifier; import org.junit.runners.model.InitializationError; +import javax.annotation.Nullable; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -73,7 +80,24 @@ public class XQueryTestRunner extends AbstractTestRunner { */ public XQueryTestRunner(final Path path, final boolean parallel) throws InitializationError { super(path, parallel); - this.info = extractTestInfo(path); + this.info = discoverOrExtractTestInfo(path); + } + + /** + * Prefer discovery via runDiscovery when the DB is already started (e.g. by XSuite for XQuery suites); + * otherwise fall back to compiling the module in extractTestInfo. + */ + private static XQueryTestInfo discoverOrExtractTestInfo(final Path path) throws InitializationError { + if (XSuite.EXIST_EMBEDDED_SERVER_CLASS_INSTANCE != null) { + final BrokerPool pool = XSuite.EXIST_EMBEDDED_SERVER_CLASS_INSTANCE.getBrokerPool(); + if (pool != null) { + final XQueryTestInfo discovered = runDiscovery(pool, path); + if (discovered != null) { + return discovered; + } + } + } + return extractTestInfo(path); } private static Configuration getConfiguration() throws DatabaseConfigurationException { @@ -172,6 +196,48 @@ private static XQueryTestInfo extractTestInfo(final Path path) throws Initializa } } + /** + * Runs the discovery XQuery for the given path (single XQuery per file). + * Returns test info from the discovery result, or null if discovery fails or DB is not available. + * Used to avoid double compile (see Todo 6: discovery via one XQuery run). + */ + @Nullable + static XQueryTestInfo runDiscovery(final BrokerPool brokerPool, final Path path) { + try { + final String pkgName = XQueryTestRunner.class.getPackage().getName().replace('.', '/'); + final Source discoverySource = new ClassLoaderSource(pkgName + "/xquery-discovery.xq"); + final List>> bindings = Collections.singletonList( + context -> new Tuple2<>("test-module-uri", new AnyURIValue(path.toAbsolutePath().toUri())) + ); + final Sequence result = executeQuery(brokerPool, discoverySource, bindings, path.getParent()); + if (result == null || result.getItemCount() < 1) { + return null; + } + final Item first = result.itemAt(0); + if (!(first instanceof NodeValue)) { + return null; + } + final Node root = ((NodeValue) first).getNode(); + if (root.getNodeType() != Node.ELEMENT_NODE || !"discovery".equals(root.getLocalName())) { + return null; + } + final Element discovery = (Element) root; + final String namespace = discovery.getAttribute("namespace"); + final String prefix = discovery.getAttribute("prefix"); + final NodeList fList = discovery.getElementsByTagName("f"); + final List testFunctions = new ArrayList<>(fList.getLength()); + for (int i = 0; i < fList.getLength(); i++) { + final Element f = (Element) fList.item(i); + final String name = f.getAttribute("name"); + final int arity = Integer.parseInt(f.getAttribute("arity")); + testFunctions.add(new XQueryTestInfo.TestFunctionDef(name, arity)); + } + return new XQueryTestInfo(prefix, namespace, testFunctions); + } catch (final Exception e) { + return null; + } + } + private String getSuiteName() { if (info.getNamespace() == null) { return path.getFileName().toString(); @@ -241,7 +307,7 @@ public void run(final RunNotifier notifier) { // set callback functions for notifying junit! context -> new Tuple2<>("test-ignored-function", new FunctionReference(new FunctionCall(context, new ExtTestIgnoredFunction(context, suiteName, notifier)))), context -> new Tuple2<>("test-started-function", new FunctionReference(new FunctionCall(context, new ExtTestStartedFunction(context, suiteName, notifier)))), - context -> new Tuple2<>("test-failure-function", new FunctionReference(new FunctionCall(context, new ExtTestFailureFunction(context, suiteName, notifier)))), + context -> new Tuple2<>("test-failure-function", new FunctionReference(new FunctionCall(context, new ExtTestFailureFunction(context, suiteName, notifier, path)))), context -> new Tuple2<>("test-assumption-failed-function", new FunctionReference(new FunctionCall(context, new ExtTestAssumptionFailedFunction(context, suiteName, notifier)))), context -> new Tuple2<>("test-error-function", new FunctionReference(new FunctionCall(context, new ExtTestErrorFunction(context, suiteName, notifier)))), context -> new Tuple2<>("test-finished-function", new FunctionReference(new FunctionCall(context, new ExtTestFinishedFunction(context, suiteName, notifier)))) @@ -257,7 +323,7 @@ public void run(final RunNotifier notifier) { } } - private static class XQueryTestInfo { + static class XQueryTestInfo { private final String prefix; private final String namespace; private final List testFunctions; @@ -280,7 +346,7 @@ public List getTestFunctions() { return testFunctions; } - private static class TestFunctionDef { + static class TestFunctionDef { private final String localName; private final int arity; diff --git a/exist-core/src/main/java/org/exist/test/runner/XSuite.java b/exist-core/src/main/java/org/exist/test/runner/XSuite.java index 2acfeb62bd8..bfc2954a68c 100644 --- a/exist-core/src/main/java/org/exist/test/runner/XSuite.java +++ b/exist-core/src/main/java/org/exist/test/runner/XSuite.java @@ -21,6 +21,7 @@ */ package org.exist.test.runner; +import org.exist.storage.BrokerPool; import org.exist.test.ExistEmbeddedServer; import org.exist.util.XMLFilenameFilter; import org.exist.util.XQueryFilenameFilter; @@ -28,6 +29,8 @@ import org.junit.BeforeClass; import org.junit.runner.Description; import org.junit.runner.Runner; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; import org.junit.runner.notification.RunNotifier; import org.junit.runners.ParentRunner; import org.junit.runners.model.*; @@ -41,6 +44,11 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -59,6 +67,10 @@ */ public class XSuite extends ParentRunner { + private final List runners; + @Nullable + private final ParallelFileScheduler parallelScheduler; + /** * Returns an empty suite. * @@ -112,8 +124,6 @@ private static boolean hasParallelAnnotation(@Nullable final Class klass) { return annotation != null; } - private final List runners; - /** * Called reflectively on classes annotated with @RunWith(XSuite.class) * @@ -175,6 +185,52 @@ protected XSuite(final RunnerBuilder builder, final Class klass, final String protected XSuite(final Class klass, final List runners) throws InitializationError { super(klass); this.runners = Collections.unmodifiableList(runners); + if (hasParallelAnnotation(klass) && !runners.isEmpty()) { + final ParallelFileScheduler scheduler = new ParallelFileScheduler(); + setScheduler(scheduler); + this.parallelScheduler = scheduler; + } else { + this.parallelScheduler = null; + } + } + + /** + * Returns true if any of the suite paths is an XQuery file or a directory containing at least one XQuery file. + * Used to decide whether to start the DB before getRunners (so discovery can run for XQueryTestRunner). + */ + private static boolean hasAnyXQueryFiles(final String[] suites) throws IOException { + if (suites == null) { + return false; + } + final java.util.function.Predicate isXQueryFile = XQueryFilenameFilter.asPredicate(); + for (final String suite : suites) { + final Path path = Paths.get(suite); + if (!Files.exists(path)) { + continue; + } + if (Files.isDirectory(path)) { + try (final Stream children = Files.list(path)) { + final boolean hasXq = children.anyMatch(p -> !Files.isDirectory(p) && isXQueryFile.test(p) && !"runTests.xql".equals(p.getFileName().toString())); + if (hasXq) { + return true; + } + } + } else if (isXQueryFile.test(path) && !"runTests.xql".equals(path.getFileName().toString())) { + return true; + } + } + return false; + } + + /** + * Starts the embedded DB if not already started. Idempotent. + * Called before getRunners when the suite contains XQuery files so discovery can use the DB. + */ + private static void ensureExistDbStarted() throws Throwable { + if (EXIST_EMBEDDED_SERVER_CLASS_INSTANCE == null) { + EXIST_EMBEDDED_SERVER_CLASS_INSTANCE = newExistDbServer(); + EXIST_EMBEDDED_SERVER_CLASS_INSTANCE.startDb(); + } } /** @@ -191,6 +247,13 @@ private static List getRunners(final String[] suites, final boolean para } try { + if (hasAnyXQueryFiles(suites)) { + try { + ensureExistDbStarted(); + } catch (final Throwable t) { + throw new InitializationError(t); + } + } final List runners = new ArrayList<>(); for (final String suite : suites) { @@ -247,7 +310,21 @@ protected Description describeChild(final Runner child) { @Override protected void runChild(final Runner runner, final RunNotifier notifier) { - runner.run(notifier); + if (parallelScheduler != null) { + final ParallelFileScheduler parallel = parallelScheduler; + parallel.prepareForRun(notifier); + final Description runnerDesc = runner.getDescription(); + parallel.setCurrentRunner(runnerDesc); + parallel.runnerStarted(runnerDesc, runner); + try { + runner.run(notifier); + } finally { + parallel.runnerFinished(runnerDesc); + parallel.setCurrentRunner(null); + } + } else { + runner.run(notifier); + } } @Override @@ -337,7 +414,7 @@ private static class StartExistDbStatement extends Statement { @Override public void evaluate() throws Throwable { if (EXIST_EMBEDDED_SERVER_CLASS_INSTANCE != null) { - throw new IllegalStateException("EXIST_EMBEDDED_SERVER_CLASS_INSTANCE already instantiated"); + return; } EXIST_EMBEDDED_SERVER_CLASS_INSTANCE = newExistDbServer(); EXIST_EMBEDDED_SERVER_CLASS_INSTANCE.startDb(); @@ -360,4 +437,229 @@ public void evaluate() { static ExistEmbeddedServer newExistDbServer() { return new ExistEmbeddedServer(true, true); } + + /** + * Runs child runners (test files) in parallel using a fixed thread pool. + * Pool size is dynamic (processors and eXist broker count) with optional override. + * Progress-based hang detection reports which file hung when no activity is seen. + * All children share the same single eXist instance; tests must not rely on execution order. + */ + private static final class ParallelFileScheduler implements RunnerScheduler { + + private static final String PARALLEL_THREADS_PROPERTY = "xsuite.parallel.threads"; + private static final String HANG_THRESHOLD_PROPERTY = "xsuite.hang.threshold.minutes"; + private static final String HANG_WATCHER_INTERVAL_PROPERTY = "xsuite.hang.watcher.interval.seconds"; + private static final int POOL_FLOOR = 2; + private static final int POOL_CAP = 32; + private static final double DEFAULT_HANG_THRESHOLD_MINUTES = 5.0; + private static final int DEFAULT_WATCHER_INTERVAL_SECONDS = 30; + + private volatile ExecutorService executor; + private final Object executorLock = new Object(); + + private final ConcurrentHashMap lastActivityByRunner = new ConcurrentHashMap<>(); + private final ConcurrentHashMap runnerLabelByDescription = new ConcurrentHashMap<>(); + private final ThreadLocal currentRunnerId = new ThreadLocal<>(); + private volatile RunNotifier notifier; + private final AtomicBoolean notifierListenerAdded = new AtomicBoolean(false); + private final AtomicBoolean hungReported = new AtomicBoolean(false); + private volatile Thread watcherThread; + + private ExecutorService getOrCreateExecutor() { + if (executor != null) { + return executor; + } + synchronized (executorLock) { + if (executor != null) { + return executor; + } + final int size = computePoolSize(); + executor = Executors.newFixedThreadPool(size); + startWatcher(); + return executor; + } + } + + private static int computePoolSize() { + final String override = System.getProperty(PARALLEL_THREADS_PROPERTY); + if (override != null && !override.isEmpty()) { + try { + return Math.max(POOL_FLOOR, Math.min(POOL_CAP, Integer.parseInt(override.trim()))); + } catch (final NumberFormatException ignored) { + // fall through to dynamic + } + } + final int processors = Runtime.getRuntime().availableProcessors(); + int brokerMax = processors; + if (EXIST_EMBEDDED_SERVER_CLASS_INSTANCE != null) { + try { + final BrokerPool pool = EXIST_EMBEDDED_SERVER_CLASS_INSTANCE.getBrokerPool(); + if (pool != null) { + brokerMax = pool.getMax(); + } + } catch (final Exception ignored) { + // use processors only + } + } + return Math.max(POOL_FLOOR, Math.min(POOL_CAP, Math.min(processors, brokerMax))); + } + + void prepareForRun(final RunNotifier notifier) { + this.notifier = notifier; + if (notifierListenerAdded.compareAndSet(false, true)) { + notifier.addListener(new RunListener() { + @Override + public void testStarted(final Description description) { + recordActivity(); + } + + @Override + public void testFinished(final Description description) { + recordActivity(); + } + + @Override + public void testFailure(final Failure failure) { + recordActivity(); + } + + @Override + public void testAssumptionFailure(final Failure failure) { + recordActivity(); + } + + @Override + public void testIgnored(final Description description) { + recordActivity(); + } + + private void recordActivity() { + final Description runnerDesc = currentRunnerId.get(); + if (runnerDesc != null) { + lastActivityByRunner.put(runnerDesc, System.currentTimeMillis()); + } + } + }); + } + } + + void setCurrentRunner(final Description description) { + if (description == null) { + currentRunnerId.remove(); + } else { + currentRunnerId.set(description); + } + } + + void runnerStarted(final Description runnerDesc, final Runner runner) { + lastActivityByRunner.put(runnerDesc, System.currentTimeMillis()); + if (runner instanceof AbstractTestRunner) { + runnerLabelByDescription.put(runnerDesc, ((AbstractTestRunner) runner).getSourcePath().toAbsolutePath().toString()); + } else { + runnerLabelByDescription.put(runnerDesc, runnerDesc.getDisplayName()); + } + } + + void runnerFinished(final Description runnerDesc) { + lastActivityByRunner.remove(runnerDesc); + runnerLabelByDescription.remove(runnerDesc); + } + + private void startWatcher() { + watcherThread = new Thread(this::runWatcher, "xsuite-hang-watcher"); + watcherThread.setDaemon(false); + watcherThread.start(); + } + + private static double hangThresholdMinutes() { + final String v = System.getProperty(HANG_THRESHOLD_PROPERTY); + if (v == null || v.isEmpty()) { + return DEFAULT_HANG_THRESHOLD_MINUTES; + } + try { + return Math.max(0.001, Double.parseDouble(v.trim())); + } catch (final NumberFormatException e) { + return DEFAULT_HANG_THRESHOLD_MINUTES; + } + } + + private static int watcherIntervalSeconds() { + final String v = System.getProperty(HANG_WATCHER_INTERVAL_PROPERTY); + if (v == null || v.isEmpty()) { + return DEFAULT_WATCHER_INTERVAL_SECONDS; + } + try { + return Math.max(1, Integer.parseInt(v.trim())); + } catch (final NumberFormatException e) { + return DEFAULT_WATCHER_INTERVAL_SECONDS; + } + } + + private void runWatcher() { + final long thresholdMs = (long) (hangThresholdMinutes() * 60 * 1000); + final long intervalMs = watcherIntervalSeconds() * 1000L; + while (!Thread.currentThread().isInterrupted() && executor != null && !executor.isShutdown()) { + try { + Thread.sleep(intervalMs); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + if (hungReported.get()) { + break; + } + final long now = System.currentTimeMillis(); + for (final var e : lastActivityByRunner.entrySet()) { + if (now - e.getValue() > thresholdMs) { + if (hungReported.compareAndSet(false, true)) { + reportHung(e.getKey()); + final ExecutorService exec = executor; + if (exec != null) { + exec.shutdownNow(); + } + } + break; + } + } + } + } + + private void reportHung(final Description runnerDesc) { + final RunNotifier n = notifier; + if (n == null) { + return; + } + final String label = runnerLabelByDescription.getOrDefault(runnerDesc, runnerDesc.getDisplayName()); + final AssertionError err = new AssertionError( + "Test file appears hung (no activity for " + hangThresholdMinutes() + " minutes): " + label); + n.fireTestFailure(new Failure(runnerDesc, err)); + } + + @Override + public void schedule(final Runnable childStatement) { + getOrCreateExecutor().submit(childStatement); + } + + @Override + public void finished() { + final ExecutorService exec = executor; + if (exec == null) { + return; + } + exec.shutdown(); + try { + if (hungReported.get()) { + exec.awaitTermination(1, TimeUnit.MINUTES); + } else { + exec.awaitTermination(Long.MAX_VALUE, TimeUnit.DAYS); + } + } catch (final InterruptedException e) { + exec.shutdownNow(); + Thread.currentThread().interrupt(); + } + if (watcherThread != null) { + watcherThread.interrupt(); + } + } + } } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunction.java b/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunction.java index 15017132e7e..1553955fa48 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunction.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunction.java @@ -25,6 +25,8 @@ import org.exist.xquery.*; import org.exist.xquery.value.*; +import javax.annotation.Nullable; + import static org.exist.xquery.FunctionDSL.param; import static org.exist.xquery.FunctionDSL.returns; import static org.exist.xquery.functions.inspect.InspectionModule.functionSignature; @@ -47,13 +49,19 @@ public InspectFunction(final XQueryContext context, final FunctionSignature sig public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { final FunctionReference ref = (FunctionReference) args[0].itemAt(0); final FunctionSignature sig = ref.getSignature(); + final UserDefinedFunction udf = getUDF(ref); try { context.pushDocumentContext(); final MemTreeBuilder builder = context.getDocumentBuilder(); - final int nodeNr = InspectFunctionHelper.generateDocs(sig, null, builder); + final int nodeNr = InspectFunctionHelper.generateDocs(sig, udf, builder); return builder.getDocument().getNode(nodeNr); } finally { context.popDocumentContext(); } } + + private static @Nullable UserDefinedFunction getUDF(final FunctionReference ref) { + final FunctionCall call = ref.getCall(); + return call != null ? call.getFunction() : null; + } } diff --git a/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunctionHelper.java b/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunctionHelper.java index 2634e741efd..5b390129b11 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunctionHelper.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/inspect/InspectFunctionHelper.java @@ -24,6 +24,7 @@ import org.exist.dom.QName; import org.exist.dom.memtree.MemTreeBuilder; +import org.exist.source.Source; import org.exist.xquery.*; import org.exist.xquery.value.FunctionParameterSequenceType; import org.exist.xquery.value.FunctionReturnSequenceType; @@ -65,6 +66,26 @@ public static int generateDocs(final FunctionSignature sig, final UserDefinedFun final AttributesImpl attribs = new AttributesImpl(); attribs.addAttribute("", "name", "name", "CDATA", sig.getName().getStringValue()); attribs.addAttribute("", "module", "module", "CDATA", sig.getName().getNamespaceURI()); + if (func != null) { + int line = func.getLine(); + final int column = func.getColumn(); + if (line <= 0 && func.getFunctionBody() != null) { + line = firstPositiveLineIn(func.getFunctionBody()); + } + if (line >= 0) { + attribs.addAttribute("", "line", "line", "CDATA", String.valueOf(line)); + } + if (column >= 0) { + attribs.addAttribute("", "column", "column", "CDATA", String.valueOf(column)); + } + final Source source = func.getSource(); + if (source != null) { + final String path = source.pathOrShortIdentifier(); + if (path != null && !path.isEmpty()) { + attribs.addAttribute("", "source", "source", "CDATA", path); + } + } + } final int nodeNr = builder.startElement(FUNCTION_QNAME, attribs); writeParameters(sig, builder); final SequenceType returnType = sig.getReturnType(); @@ -147,6 +168,28 @@ private static void writeAnnotations(final FunctionSignature signature, final Me } } + /** + * Depth-first search for the first positive line number in an expression tree. + * Used as fallback when the UDF's own line is not set (e.g. module loaded from DB in eXide). + */ + private static int firstPositiveLineIn(final Expression expr) { + if (expr == null) { + return -1; + } + final int line = expr.getLine(); + if (line > 0) { + return line; + } + final int count = expr.getSubExpressionCount(); + for (int i = 0; i < count; i++) { + final int sub = firstPositiveLineIn(expr.getSubExpression(i)); + if (sub > 0) { + return sub; + } + } + return -1; + } + /** * Inspect the provided function implementation and return an XML fragment listing all * functions called from the function. diff --git a/exist-core/src/main/java/org/exist/xquery/functions/util/InspectFunction.java b/exist-core/src/main/java/org/exist/xquery/functions/util/InspectFunction.java index 74e216c63ba..fb5caf1b9cf 100644 --- a/exist-core/src/main/java/org/exist/xquery/functions/util/InspectFunction.java +++ b/exist-core/src/main/java/org/exist/xquery/functions/util/InspectFunction.java @@ -24,7 +24,9 @@ import org.exist.dom.memtree.MemTreeBuilder; import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionCall; import org.exist.xquery.FunctionSignature; +import org.exist.xquery.UserDefinedFunction; import org.exist.xquery.XPathException; import org.exist.xquery.XQueryContext; import org.exist.xquery.functions.inspect.InspectFunctionHelper; @@ -32,6 +34,8 @@ import org.exist.xquery.value.Sequence; import org.exist.xquery.value.Type; +import javax.annotation.Nullable; + import static org.exist.xquery.FunctionDSL.param; import static org.exist.xquery.FunctionDSL.returns; import static org.exist.xquery.functions.util.UtilModule.functionSignature; @@ -54,13 +58,19 @@ public InspectFunction(final XQueryContext context, final FunctionSignature sign public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { final FunctionReference ref = (FunctionReference) args[0].itemAt(0); final FunctionSignature sig = ref.getSignature(); + final UserDefinedFunction udf = getUDF(ref); try { context.pushDocumentContext(); final MemTreeBuilder builder = context.getDocumentBuilder(); - final int nodeNr = InspectFunctionHelper.generateDocs(sig, null, builder); + final int nodeNr = InspectFunctionHelper.generateDocs(sig, udf, builder); return builder.getDocument().getNode(nodeNr); } finally { context.popDocumentContext(); } } + + private static @Nullable UserDefinedFunction getUDF(final FunctionReference ref) { + final FunctionCall call = ref.getCall(); + return call != null ? call.getFunction() : null; + } } diff --git a/exist-core/src/main/resources/org/exist/test/runner/xquery-discovery.xq b/exist-core/src/main/resources/org/exist/test/runner/xquery-discovery.xq new file mode 100644 index 00000000000..7ddc929e8ee --- /dev/null +++ b/exist-core/src/main/resources/org/exist/test/runner/xquery-discovery.xq @@ -0,0 +1,54 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +xquery version "3.1"; + +import module namespace inspect = "http://exist-db.org/xquery/inspection"; +import module namespace util = "http://exist-db.org/xquery/util"; + +declare variable $test-module-uri as xs:anyURI external; +declare variable $test-namespace := "http://exist-db.org/xquery/xqsuite"; + +let $all-functions := inspect:module-functions($test-module-uri) +let $test-functions := + for $f in $all-functions + let $meta := util:inspect-function($f) + where exists($meta/annotation[@namespace = $test-namespace][matches(@name, ":assert")]) + return $f +return + + { + if (exists($test-functions)) then + let $first := $test-functions[1] + let $name := function-name($first) + return ( + attribute namespace { namespace-uri-from-QName($name) }, + attribute prefix { prefix-from-QName($name) }, + for $f in $test-functions + return element f { + attribute name { local-name-from-QName(function-name($f)) }, + attribute arity { function-arity($f) } + } + ) + else + () + } + diff --git a/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql b/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql index 9d0d2716670..f9171cec289 100755 --- a/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql +++ b/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql @@ -338,6 +338,17 @@ function test:test-assumption( else() }; +(:~ + : Merge optional line/source from util:inspect-function $meta into the actual map for the failure callback (IDE location). + :) +declare %private function test:actual-with-location($actual as map(xs:string, item()?), $meta as element(function)) as map(xs:string, item()?) { + map:merge(( + $actual, + if (exists($meta/@line)) then map { "line": $meta/@line/data() } else map {}, + if (exists($meta/@source)) then map { "source": $meta/@source/string() } else map {} + )) +}; + (:~ : The main function for running a single test. Executes the test function : and compares the result against each assertXXX annotation. @@ -367,19 +378,18 @@ declare %private function test:test( return if ($assertError) then ( - if (not(empty($test-failure-function))) then - $test-failure-function( - test:get-test-name($meta), - (: expected :) - map { - "error": $assertError/value/string() - }, - (: actual :) - map { - "error": map { - "value": $result - } - } + if(not(empty($test-failure-function))) then + $test-failure-function(test:get-test-name($meta), + (: expected :) + map { + "error": $assertError/value/string() + }, + (: actual :) + test:actual-with-location(map { + "error": map { + "value": $result + } + }, $meta) ) else (), test:print-result( @@ -393,16 +403,15 @@ declare %private function test:test( ) ) else ( if ($assertResult[failure] and not(empty($test-failure-function))) then - $test-failure-function( - test:get-test-name($meta), - (: expected :) - map { - "value": test:expected-strings($assertResult) - }, - (: actual :) - map { - "result": test:actual-strings($assertResult) - } + $test-failure-function(test:get-test-name($meta), + (: expected :) + map { + "value": test:expected-strings($assertResult) + }, + (: actual :) + test:actual-with-location(map { + "result": test:actual-strings($assertResult) + }, $meta) ) else(), test:print-result($meta, $result, $assertResult) @@ -420,7 +429,7 @@ declare %private function test:test( (: expected :) map { "value": $serialized-expected }, (: actual :) - map { "result": $serialized-actual } + test:actual-with-location(map { "result": $serialized-actual }, $meta) ) else () , @@ -441,27 +450,26 @@ declare %private function test:test( or matches($err:description, $assertError/value/string())) ) then ( - if (not(empty($test-failure-function))) then - $test-failure-function( - test:get-test-name($meta), - (: expected :) - map { - "error": $assertError/value/string() - }, - (: actual :) - map { - "error": map { - "code": $err:code, - "description": $err:description, - "value": $err:value, - "module": $err:module, - "line-number": $err:line-number, - "column-number": $err:column-number, - "additional": $err:additional, - "xquery-stack-trace": $exerr:xquery-stack-trace, - "java-stack-trace": $exerr:java-stack-trace - } - } + if(not(empty($test-failure-function))) then + $test-failure-function(test:get-test-name($meta), + (: expected :) + map { + "error": $assertError/value/string() + }, + (: actual :) + test:actual-with-location(map { + "error": map { + "code": $err:code, + "description": $err:description, + "value": $err:value, + "module": $err:module, + "line-number": $err:line-number, + "column-number": $err:column-number, + "additional": $err:additional, + "xquery-stack-trace": $exerr:xquery-stack-trace, + "java-stack-trace": $exerr:java-stack-trace + } + }, $meta) ) else () , @@ -806,14 +814,19 @@ declare %private function test:get-test-name($meta as element(function)) as xs:s :) declare %private function test:print-result($meta as element(function), $result as item()*, $assertResult as element(report)*) { - - { + element testcase { + attribute name { test:get-test-name($meta) }, + attribute class { $meta/@name }, + if (exists($meta/@line)) then attribute line { $meta/@line/data() } else (), + (: Prefer inspect source; fallback to module namespace so eXide report has a location (e.g. when no file source) :) + if (exists($meta/@source)) then attribute source { $meta/@source/string() } + else if (exists($meta/@module)) then attribute source { $meta/@module/string() } + else (), if (exists($assertResult)) then ($assertResult[failure] | $assertResult)[1]/* else () } - }; (:~ diff --git a/exist-core/src/test/java/org/exist/test/runner/DebuggabilityNavigabilitySuite.java b/exist-core/src/test/java/org/exist/test/runner/DebuggabilityNavigabilitySuite.java new file mode 100644 index 00000000000..523476772e2 --- /dev/null +++ b/exist-core/src/test/java/org/exist/test/runner/DebuggabilityNavigabilitySuite.java @@ -0,0 +1,36 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import org.junit.runner.RunWith; + +/** + * XSuite that runs the debuggability test resource (failing-both.xq: one assertion failure, + * one unexpected error) so we can assert on failure stack traces (navigate to XQuery / navigate to Java). + */ +@RunWith(XSuite.class) +@XSuite.XSuiteFiles({ + "src/test/resources/org/exist/test/runner/failing-both.xq" +}) +public class DebuggabilityNavigabilitySuite { +} diff --git a/exist-core/src/test/java/org/exist/test/runner/InspectLineSourceTest.java b/exist-core/src/test/java/org/exist/test/runner/InspectLineSourceTest.java new file mode 100644 index 00000000000..4fcfdc1fec4 --- /dev/null +++ b/exist-core/src/test/java/org/exist/test/runner/InspectLineSourceTest.java @@ -0,0 +1,77 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import org.exist.EXistException; +import org.exist.security.PermissionDeniedException; +import org.exist.util.DatabaseConfigurationException; +import org.exist.source.FileSource; +import org.exist.storage.BrokerPool; +import org.exist.test.ExistEmbeddedServer; +import org.exist.xquery.XPathException; +import org.exist.xquery.value.NodeValue; +import org.exist.xquery.value.Sequence; +import org.junit.Rule; +import org.junit.Test; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Tests that util:inspect-function returns line and source attributes for user-defined functions (plan item 7). + */ +public class InspectLineSourceTest { + + @Rule + public ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + @Test + public void inspectFunctionReturnsLineAndSourceForUDF() throws EXistException, PermissionDeniedException, XPathException, IOException, DatabaseConfigurationException { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + final Path path = Paths.get("src/test/resources/org/exist/test/runner/inspect-line-source-test.xq").toAbsolutePath(); + if (!Files.exists(path)) { + throw new AssertionError("Test resource missing: " + path); + } + final Sequence result = AbstractTestRunner.executeQuery(pool, new FileSource(path, UTF_8, false), Collections.emptyList(), path.getParent()); + assertNotNull("query should return a result", result); + assertTrue("query should return at least one item", result.getItemCount() >= 1); + final Node first = ((NodeValue) result.itemAt(0)).getNode(); + assertEquals("first result should be an element", Node.ELEMENT_NODE, first.getNodeType()); + final Element func = (Element) first; + assertTrue("function element should have @line for UDF", func.hasAttribute("line")); + final String lineStr = func.getAttribute("line"); + assertTrue("line should be a positive number", Integer.parseInt(lineStr) > 0); + assertTrue("function element should have @source for UDF", func.hasAttribute("source")); + assertTrue("source should be non-empty", !func.getAttribute("source").isEmpty()); + } +} diff --git a/exist-core/src/test/java/org/exist/test/runner/ParallelPassingSuite.java b/exist-core/src/test/java/org/exist/test/runner/ParallelPassingSuite.java new file mode 100644 index 00000000000..f6ac8e0fe0b --- /dev/null +++ b/exist-core/src/test/java/org/exist/test/runner/ParallelPassingSuite.java @@ -0,0 +1,38 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import org.junit.runner.RunWith; + +/** + * XSuite that runs passing XQuery tests in parallel. + * Used by XSuiteParallelTest to verify dynamic pool and parallel execution. + */ +@RunWith(XSuite.class) +@XSuite.XSuiteParallel +@XSuite.XSuiteFiles({ + "src/test/resources/org/exist/test/runner/single-test.xq", + "src/test/resources/org/exist/test/runner/no-tests.xq" +}) +public class ParallelPassingSuite { +} diff --git a/exist-core/src/test/java/org/exist/test/runner/ParallelWithHungSuite.java b/exist-core/src/test/java/org/exist/test/runner/ParallelWithHungSuite.java new file mode 100644 index 00000000000..672467d45d1 --- /dev/null +++ b/exist-core/src/test/java/org/exist/test/runner/ParallelWithHungSuite.java @@ -0,0 +1,38 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import org.junit.runner.RunWith; + +/** + * XSuite that runs one passing file and one file that never emits activity (hanging.xq). + * Used by XSuiteParallelTest to verify progress-based hang detection reports the hung file. + */ +@RunWith(XSuite.class) +@XSuite.XSuiteParallel +@XSuite.XSuiteFiles({ + "src/test/resources/org/exist/test/runner/single-test.xq", + "src/test/resources/org/exist/test/runner/hanging.xq" +}) +public class ParallelWithHungSuite { +} diff --git a/exist-core/src/test/java/org/exist/test/runner/XSuiteDebuggabilityTest.java b/exist-core/src/test/java/org/exist/test/runner/XSuiteDebuggabilityTest.java new file mode 100644 index 00000000000..de8e01383b9 --- /dev/null +++ b/exist-core/src/test/java/org/exist/test/runner/XSuiteDebuggabilityTest.java @@ -0,0 +1,116 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import org.junit.jupiter.api.Test; +import org.junit.runner.JUnitCore; +import org.junit.runner.Result; +import org.junit.runner.notification.Failure; +import org.junit.runner.notification.RunListener; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * TDD tests for XSuite debuggability: failure stack traces should support + * "navigate to XQuery" (test file:line) and "navigate to Java" (underlying Java code). + */ +class XSuiteDebuggabilityTest { + + @Test + void failureFromAssertionIncludesTestFileInStackTraceOrMessage() { + final List failures = runSuiteAndCollectFailures(); + final Failure assertionFailure = failures.stream() + .filter(f -> f.getException() instanceof org.junit.ComparisonFailure) + .findFirst() + .orElse(null); + assertTrue(assertionFailure != null, + "expected one assertion failure (ComparisonFailure) from failing-both.xq; collected failures: " + failures.size() + + " types: " + failures.stream().map(f -> f.getException() == null ? "null" : f.getException().getClass().getName()).toList()); + + final Throwable t = assertionFailure.getException(); + final String message = t.getMessage(); + final StackTraceElement[] stack = t.getStackTrace(); + + boolean hasLocation = false; + if (stack != null && stack.length > 0) { + for (final StackTraceElement e : stack) { + final String file = e.getFileName(); + if (file != null && file.contains("failing-both")) { + hasLocation = true; + break; + } + } + } + if (!hasLocation && message != null) { + hasLocation = message.contains("failing-both"); + } + assertTrue(hasLocation, + "assertion failure should include test file (failing-both.xq) in stack trace or message for IDE navigation (navigate to XQuery); stack.length=" + (stack != null ? stack.length : 0) + " message=" + message); + } + + @Test + void failureFromErrorIncludesOrgExistInStackTrace() { + final List failures = runSuiteAndCollectFailures(); + final Failure errorFailure = failures.stream() + .filter(f -> f.getException() instanceof org.exist.xquery.XPathException) + .findFirst() + .orElse(null); + assertTrue(errorFailure != null, + "expected one error failure (XPathException) from failing-both.xq throwsUnexpectedError; xqsuite runs only functions with assert* annotations"); + final Throwable t = errorFailure.getException(); + final StackTraceElement[] stack = t.getStackTrace(); + + boolean hasOrgExist = false; + if (stack != null) { + for (final StackTraceElement e : stack) { + if (e.getClassName() != null && e.getClassName().startsWith("org.exist")) { + hasOrgExist = true; + break; + } + } + } + assertTrue(hasOrgExist, + "error failure should include org.exist in stack trace for IDE navigation (navigate to Java); stack.length=" + (stack != null ? stack.length : 0)); + } + + private static List runSuiteAndCollectFailures() { + final List collected = new ArrayList<>(); + final JUnitCore core = new JUnitCore(); + core.addListener(new RunListener() { + @Override + public void testFailure(final Failure failure) { + collected.add(failure); + } + }); + final Result result = core.run(DebuggabilityNavigabilitySuite.class); + assertFalse(result.wasSuccessful(), "suite is expected to have failures (failing-both.xq)"); + assertTrue(collected.size() >= 2, + "expected at least 2 failures (assertion + error) from failing-both.xq; got " + collected.size() + + " types: " + collected.stream().map(f -> f.getException() == null ? "null" : f.getException().getClass().getName()).toList()); + return collected; + } +} diff --git a/exist-core/src/test/java/org/exist/test/runner/XSuiteDiscoveryTest.java b/exist-core/src/test/java/org/exist/test/runner/XSuiteDiscoveryTest.java new file mode 100644 index 00000000000..3b882a7a08e --- /dev/null +++ b/exist-core/src/test/java/org/exist/test/runner/XSuiteDiscoveryTest.java @@ -0,0 +1,91 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import org.exist.storage.BrokerPool; +import org.exist.test.ExistEmbeddedServer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.Description; +import org.junit.runners.model.InitializationError; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * TDD for Todo 6: discovery via single XQuery per file. + * Asserts that the discovery XQuery returns the expected test list for known files, + * and that XQueryTestRunner uses discovery when the DB is up and runs the same tests. + */ +public class XSuiteDiscoveryTest { + + @Rule + public ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); + + @Test + public void discoveryReturnsTestListForSingleTestFile() { + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + final Path path = Paths.get("src/test/resources/org/exist/test/runner/single-test.xq").toAbsolutePath(); + final XQueryTestRunner.XQueryTestInfo info = XQueryTestRunner.runDiscovery(pool, path); + assertNotNull("discovery XQuery should return test info", info); + assertEquals("namespace", "http://exist-db.org/xquery/single-test-module", info.getNamespace()); + assertEquals("one test function", 1, info.getTestFunctions().size()); + assertEquals("test name", "f1", info.getTestFunctions().get(0).getLocalName()); + assertEquals("test arity", 0, info.getTestFunctions().get(0).getArity()); + } + + /** + * When the DB is up (XSuite.EXIST_EMBEDDED_SERVER_CLASS_INSTANCE set), XQueryTestRunner should use + * discovery and the runner's description should match the discovery result. + */ + @Test + public void runnerUsesDiscoveryWhenDbIsUp() throws InitializationError { + final Path path = Paths.get("src/test/resources/org/exist/test/runner/single-test.xq").toAbsolutePath(); + final BrokerPool pool = existEmbeddedServer.getBrokerPool(); + final XQueryTestRunner.XQueryTestInfo discoveryInfo = XQueryTestRunner.runDiscovery(pool, path); + assertNotNull("discovery must succeed in this test", discoveryInfo); + + final ExistEmbeddedServer previous = XSuite.EXIST_EMBEDDED_SERVER_CLASS_INSTANCE; + try { + XSuite.EXIST_EMBEDDED_SERVER_CLASS_INSTANCE = existEmbeddedServer; + final XQueryTestRunner runner = new XQueryTestRunner(path, false); + final Description description = runner.getDescription(); + final List children = description.getChildren(); + assertEquals("runner should have same number of tests as discovery", discoveryInfo.getTestFunctions().size(), children.size()); + final List childNames = new ArrayList<>(); + for (final Description d : children) { + childNames.add(d.getMethodName()); + } + for (int i = 0; i < discoveryInfo.getTestFunctions().size(); i++) { + assertEquals("test name from runner should match discovery", discoveryInfo.getTestFunctions().get(i).getLocalName(), childNames.get(i)); + } + } finally { + XSuite.EXIST_EMBEDDED_SERVER_CLASS_INSTANCE = previous; + } + } +} diff --git a/exist-core/src/test/java/org/exist/test/runner/XSuiteParallelTest.java b/exist-core/src/test/java/org/exist/test/runner/XSuiteParallelTest.java new file mode 100644 index 00000000000..ef04630dfe7 --- /dev/null +++ b/exist-core/src/test/java/org/exist/test/runner/XSuiteParallelTest.java @@ -0,0 +1,76 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.test.runner; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.runner.JUnitCore; +import org.junit.runner.Result; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for XSuite parallel scheduler: dynamic pool size and progress-based hang detection. + */ +class XSuiteParallelTest { + + @AfterEach + void clearSystemProperties() { + System.clearProperty("xsuite.parallel.threads"); + System.clearProperty("xsuite.hang.threshold.minutes"); + System.clearProperty("xsuite.hang.watcher.interval.seconds"); + } + + @Test + void parallelSuiteRunsAndCompletesSuccessfully() { + final Result result = runSuite(ParallelPassingSuite.class); + assertTrue(result.wasSuccessful(), + "parallel suite with passing files should succeed; failures: " + result.getFailureCount()); + } + + @Test + void parallelSuiteWithThreadOverrideCompletes() { + System.setProperty("xsuite.parallel.threads", "2"); + final Result result = runSuite(ParallelPassingSuite.class); + assertTrue(result.wasSuccessful(), + "parallel suite with xsuite.parallel.threads=2 should succeed; failures: " + result.getFailureCount()); + } + + /** + * Parallel suite with two files (one passing, one that would hang if module vars were evaluated at load). + * Runs to verify the parallel path with multiple files and that hang-detection properties are read. + * Full "appears hung" behaviour is not asserted here because eXist may not evaluate library module + * variables when inspect:module-functions loads the module, so hanging.xq can complete without hanging. + */ + @Test + void parallelSuiteWithTwoFilesCompletes() { + System.setProperty("xsuite.hang.threshold.minutes", "0.05"); + System.setProperty("xsuite.hang.watcher.interval.seconds", "1"); + final Result result = runSuite(ParallelWithHungSuite.class); + assertTrue(result.getRunCount() > 0, "suite should run and complete"); + } + + private static Result runSuite(final Class suiteClass) { + return new JUnitCore().run(suiteClass); + } +} diff --git a/exist-core/src/test/resources/org/exist/test/runner/failing-assertion.xq b/exist-core/src/test/resources/org/exist/test/runner/failing-assertion.xq new file mode 100644 index 00000000000..49de4f72087 --- /dev/null +++ b/exist-core/src/test/resources/org/exist/test/runner/failing-assertion.xq @@ -0,0 +1,36 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +(: + : Intentionally failing xqsuite test for debuggability tests (navigate to XQuery). + : The test fails at the function below so we can assert stack trace contains this file. + :) +xquery version "3.1"; + +module namespace fail = "http://exist-db.org/xquery/failing-assertion-module"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +declare + %test:assertEquals("expected") +function fail:assertionFails() { + "actual" +}; diff --git a/exist-core/src/test/resources/org/exist/test/runner/failing-both.xq b/exist-core/src/test/resources/org/exist/test/runner/failing-both.xq new file mode 100644 index 00000000000..2d97933dc78 --- /dev/null +++ b/exist-core/src/test/resources/org/exist/test/runner/failing-both.xq @@ -0,0 +1,44 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +(: + : Intentionally failing xqsuite tests for debuggability (navigate to XQuery + navigate to Java). + : One test fails assertion; one test throws an unexpected error. + :) +xquery version "3.1"; + +module namespace both = "http://exist-db.org/xquery/failing-both-module"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +declare + %test:assertEquals("expected") +function both:assertionFails() { + "actual" +}; + +(: Has %test:assertEquals so xqsuite runs it; body throws so test-error-function is called :) +declare + %test:name("throwsUnexpectedError") + %test:assertEquals("dummy") +function both:throwsError() { + fn:error(xs:QName("err:INTENTIONAL"), "intentional error for test") +}; diff --git a/exist-core/src/test/resources/org/exist/test/runner/failing-error.xq b/exist-core/src/test/resources/org/exist/test/runner/failing-error.xq new file mode 100644 index 00000000000..4a6864745c1 --- /dev/null +++ b/exist-core/src/test/resources/org/exist/test/runner/failing-error.xq @@ -0,0 +1,36 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +(: + : Intentionally throwing xqsuite test for debuggability tests (navigate to Java). + : The test throws so we can assert stack trace contains org.exist Java frames. + :) +xquery version "3.1"; + +module namespace err = "http://exist-db.org/xquery/failing-error-module"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +declare + %test:assertError("err:ERR") +function err:throwsError() { + fn:error(xs:QName("err:ERR"), "intentional error for test") +}; diff --git a/exist-core/src/test/resources/org/exist/test/runner/hanging.xq b/exist-core/src/test/resources/org/exist/test/runner/hanging.xq new file mode 100644 index 00000000000..851c0941822 --- /dev/null +++ b/exist-core/src/test/resources/org/exist/test/runner/hanging.xq @@ -0,0 +1,39 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +xquery version "3.1"; + +module namespace hang = "http://exist-db.org/xquery/hang"; + +declare namespace test = "http://exist-db.org/xquery/xqsuite"; + +declare function hang:loop() { + hang:loop() +}; + +declare + %test:assertEquals(1) +function hang:neverReached() { + 1 +}; + +(: Evaluated when module is used; never returns so no ext: callback is ever fired :) +declare variable $hang:blocker := hang:loop(); diff --git a/exist-core/src/test/resources/org/exist/test/runner/inspect-line-source-test.xq b/exist-core/src/test/resources/org/exist/test/runner/inspect-line-source-test.xq new file mode 100644 index 00000000000..0c352a39d50 --- /dev/null +++ b/exist-core/src/test/resources/org/exist/test/runner/inspect-line-source-test.xq @@ -0,0 +1,31 @@ +(: + : eXist-db Open Source Native XML Database + : Copyright (C) 2001 The eXist-db Authors + : + : info@exist-db.org + : http://www.exist-db.org + : + : This library is free software; you can redistribute it and/or + : modify it under the terms of the GNU Lesser General Public + : License as published by the Free Software Foundation; either + : version 2.1 of the License, or (at your option) any later version. + : + : This library is distributed in the hope that it will be useful, + : but WITHOUT ANY WARRANTY; without even the implied warranty of + : MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + : Lesser General Public License for more details. + : + : You should have received a copy of the GNU Lesser General Public + : License along with this library; if not, write to the Free Software + : Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + :) +xquery version "3.1"; + +(: Test resource: returns util:inspect-function() on a local UDF to verify line/source attributes. :) +import module namespace util = "http://exist-db.org/xquery/util"; + +declare function local:declared-here() as xs:integer { + 42 +}; + +util:inspect-function(local:declared-here#0) diff --git a/extensions/modules/expathrepo/src/test/java/org/exist/repo/PackageTriggerTest.java b/extensions/modules/expathrepo/src/test/java/org/exist/repo/PackageTriggerTest.java index c9785051a12..67a173451cc 100644 --- a/extensions/modules/expathrepo/src/test/java/org/exist/repo/PackageTriggerTest.java +++ b/extensions/modules/expathrepo/src/test/java/org/exist/repo/PackageTriggerTest.java @@ -41,8 +41,15 @@ import org.junit.BeforeClass; import org.junit.ClassRule; import org.junit.Test; +import org.junit.Assume; import org.xml.sax.SAXException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + import java.io.IOException; import java.util.Optional; @@ -51,6 +58,37 @@ public class PackageTriggerTest { static final String xarFile = "exist-expathrepo-trigger-test-" + Version.getVersion() + ".xar"; + + private static java.util.function.Supplier resolveXarSupplier() { + // 1) Try from test classpath (added via maven-resources-plugin to target/generated-test-resources) + if (PackageTriggerTest.class.getResource("/" + xarFile) != null || + PackageTriggerTest.class.getClassLoader().getResource(xarFile) != null) { + return () -> PackageTriggerTest.class.getResourceAsStream("/" + xarFile); + } + // 2) Try from sibling module build output + final Path relPath = Paths.get("extensions/modules/expathrepo/expathrepo-trigger-test/target", xarFile); + if (Files.exists(relPath)) { + return () -> { + try { + return Files.newInputStream(relPath); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }; + } + // 3) Try from current module generated-test-resources (in case direct run already copied it) + final Path generated = Paths.get("extensions/modules/expathrepo/target/generated-test-resources", xarFile); + if (Files.exists(generated)) { + return () -> { + try { + return Files.newInputStream(generated); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }; + } + return null; + } static final XmldbURI triggerTestCollection = XmldbURI.create("/db"); static final XmldbURI xarUri = triggerTestCollection.append(xarFile); @@ -71,6 +109,10 @@ public static void setup() throws PermissionDeniedException, SAXException, EXist transaction.commit(); } + // Resolve XAR supplier (skip test class if not available in non-Maven runs) + final java.util.function.Supplier xarSupplier = resolveXarSupplier(); + Assume.assumeTrue("XAR '" + xarFile + "' not found on classpath or filesystem. Run via Maven to generate it.", xarSupplier != null); + // Store XAR in database try (final DBBroker broker = brokerPool.get(Optional.of(brokerPool.getSecurityManager().getSystemSubject())); final Txn transaction = brokerPool.getTransactionManager().beginTransaction()) { @@ -78,7 +120,7 @@ public static void setup() throws PermissionDeniedException, SAXException, EXist try (final ManagedCollectionLock collectionLock = brokerPool.getLockManager().acquireCollectionWriteLock(xarUri.removeLastSegment())) { final Collection collection = broker.getOrCreateCollection(transaction, xarUri.removeLastSegment()); - broker.storeDocument(transaction, xarUri.lastSegment(), new InputStreamSupplierInputSource(() -> PackageTriggerTest.class.getResourceAsStream("/" + xarFile)), MimeType.EXPATH_PKG_TYPE, collection); + broker.storeDocument(transaction, xarUri.lastSegment(), new InputStreamSupplierInputSource(xarSupplier::get), MimeType.EXPATH_PKG_TYPE, collection); broker.saveCollection(transaction, collection); }