diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7571644..23ee452 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,4 @@ jobs: run: sbt -v assembly - name: Test shell: bash - run: target/scala-2.13/exist-xqts-runner-assembly-*-SNAPSHOT.jar --xqts-version HEAD --test-set fn-current-date + run: java -jar target/scala-2.13/exist-xqts-runner-assembly-*-SNAPSHOT.jar --xqts-version HEAD --test-set fn-current-date diff --git a/build.sbt b/build.sbt index d625a0b..49d3802 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,11 @@ import ReleaseTransformations._ +// When using a custom local Maven repo (-Dmaven.repo.local), disable coursier +// to prevent it from resolving SNAPSHOT artifacts from ~/.m2/repository instead +// of the specified directory. Coursier has a hardcoded ~/.m2/repository fallback +// that cannot be overridden via sbt resolver settings. +ThisBuild / useCoursier := !sys.props.contains("maven.repo.local") + name := "exist-xqts-runner" organization := "org.exist-db" @@ -60,7 +66,8 @@ libraryDependencies ++= { "org.apache.ant" % "ant-junit" % "1.10.15", // used for formatting junit style report "net.sf.saxon" % "Saxon-HE" % "9.9.1-8", - "org.exist-db" % "exist-core" % existV changing() exclude("org.eclipse.jetty.toolchain", "jetty-jakarta-servlet-api"), + "org.exist-db" % "exist-core" % existV changing(), + "org.exist-db" % "exist-expath" % existV changing(), "org.xmlunit" % "xmlunit-core" % "2.11.0", "org.slf4j" % "slf4j-api" % "2.0.17", @@ -70,19 +77,34 @@ libraryDependencies ++= { autoAPIMappings := true -// we prefer Saxon over Xalan +// Exclude transitive dependencies the runner doesn't need. +// Jetty exclusions allow building against both Jetty 11 (develop) and Jetty 12 (next) — +// Ivy can't resolve Jetty 12 Maven POM constructs, and the runner doesn't use Jetty anyway. excludeDependencies ++= Seq( ExclusionRule("xalan", "xalan"), + ExclusionRule("org.eclipse.jetty"), + ExclusionRule("org.eclipse.jetty.toolchain"), + ExclusionRule("org.eclipse.jetty.websocket"), + ExclusionRule("org.eclipse.jetty.ee10"), + ExclusionRule("org.eclipse.jetty.ee10.websocket"), ExclusionRule("org.hamcrest", "hamcrest-core"), ExclusionRule("org.hamcrest", "hamcrest-library") ) -resolvers ++= Seq( - Resolver.mavenLocal, - "eXist-db Releases" at "https://repo.exist-db.org/repository/exist-db/", - "Github Package Registry" at "https://maven.pkg.github.com/exist-db/exist", -) +resolvers ++= { + // Support per-worktree Maven repos: pass -Dmaven.repo.local=/path/to/.m2-repo + // Uses MavenCache (file-based) instead of "at" (URL-based) for proper local resolution. + // When a custom repo is set, skip Resolver.mavenLocal to avoid SNAPSHOT conflicts + // across concurrent builds on the same machine. + val customMavenLocal = sys.props.get("maven.repo.local").map { path => + MavenCache("Custom Local Maven", new java.io.File(path)) + } + customMavenLocal.toSeq ++ (if (customMavenLocal.isDefined) Seq.empty else Seq(Resolver.mavenLocal)) ++ Seq( + "eXist-db Releases" at "https://repo.exist-db.org/repository/exist-db/", + "Github Package Registry" at "https://maven.pkg.github.com/exist-db/exist", + ) +} javacOptions ++= Seq("-source", "21", "-target", "21") @@ -124,6 +146,7 @@ assembly / assemblyMergeStrategy := { case PathList("META-INF", "versions", "9" ,"module-info.class") => MergeStrategy.discard case PathList("org", "exist", "xquery", "lib", "xqsuite", "xqsuite.xql") => MergeStrategy.first case x if x.equals("module-info.class") || x.endsWith(s"${java.io.File.separatorChar}module-info.class") => MergeStrategy.discard + case "version.properties" => MergeStrategy.first case x => val oldStrategy = (assembly / assemblyMergeStrategy).value oldStrategy(x) @@ -132,7 +155,10 @@ assembly / assemblyMergeStrategy := { // make the assembly executable with basic shell scripts import sbtassembly.AssemblyPlugin.defaultUniversalScript -assemblyPrependShellScript := Some(defaultUniversalScript(shebang = false)) +// Skip prepend script in CI — the prepended shell script can corrupt the ZIP +// central directory offsets on certain platforms, causing "An unexpected error +// occurred while trying to open file" from the Java launcher. +assemblyPrependShellScript := (if (sys.env.contains("CI")) None else Some(defaultUniversalScript(shebang = false))) // Add assembly to publish step diff --git a/run-batched.sh b/run-batched.sh new file mode 100755 index 0000000..0afef8e --- /dev/null +++ b/run-batched.sh @@ -0,0 +1,410 @@ +#!/usr/bin/env bash +# +# Batch XQTS Runner — runs the exist-xqts-runner JAR in batches to avoid OOM. +# +# Each batch runs in a fresh JVM, so thread pool / BrokerPool leaks are +# cleaned up between batches. JUnit XML results accumulate in a single +# output directory across batches. +# +# Usage: +# ./run-batched.sh [OPTIONS] +# +# Options: +# --xqts-version VERSION 3.1, HEAD, QT4, or FTTS (default: QT4) +# --batch-size N test sets per batch (default: 50) +# --heap SIZE JVM heap size (default: 4g) +# --timeout SECS per-batch timeout in seconds (default: 180) +# --output-dir DIR output directory (default: target) +# --test-set-pattern PAT regex filter for test set names +# --exclude-test-set SETS comma-separated test sets to exclude +# --enable-feature FEATS comma-separated features to enable +# --parallel N run N batch streams in parallel (default: 1) +# --resume skip test sets that already have result XML +# --dry-run print batches without running +# -- remaining args passed through to runner JAR +# +# Examples: +# ./run-batched.sh --xqts-version QT4 --batch-size 40 --heap 6g +# ./run-batched.sh --xqts-version 3.1 --resume +# ./run-batched.sh --xqts-version QT4 --test-set-pattern 'fn-.*' --batch-size 30 + +set -euo pipefail + +# === Defaults === +XQTS_VERSION="QT4" +BATCH_SIZE=50 +HEAP="4g" +BATCH_TIMEOUT=300 +OUTPUT_DIR="target" +TEST_SET_PATTERN="" +EXCLUDE_TEST_SETS="" +ENABLE_FEATURES="" +PARALLEL=1 +RESUME=false +DRY_RUN=false +EXTRA_ARGS=() +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +JAR="$SCRIPT_DIR/exist-xqts-runner-assembly-2.0.0-SNAPSHOT.jar" +JAVA_HOME="${JAVA_HOME:-/Users/wicentowskijc/.asdf/installs/java/zulu-21.38.21}" + +# === Parse args === +while [[ $# -gt 0 ]]; do + case "$1" in + --xqts-version) XQTS_VERSION="$2"; shift 2 ;; + --batch-size) BATCH_SIZE="$2"; shift 2 ;; + --heap) HEAP="$2"; shift 2 ;; + --timeout) BATCH_TIMEOUT="$2"; shift 2 ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + --test-set-pattern) TEST_SET_PATTERN="$2"; shift 2 ;; + --exclude-test-set) EXCLUDE_TEST_SETS="$2"; shift 2 ;; + --enable-feature) ENABLE_FEATURES="$2"; shift 2 ;; + --parallel) PARALLEL="$2"; shift 2 ;; + --resume) RESUME=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --) shift; EXTRA_ARGS+=("$@"); break ;; + *) EXTRA_ARGS+=("$1"); shift ;; + esac +done + +# === Resolve catalog === +case "$XQTS_VERSION" in + 3.1) CATALOG="$SCRIPT_DIR/work/QT3_1_0/catalog.xml" ;; + HEAD) CATALOG="$SCRIPT_DIR/work/qt3tests-master/catalog.xml" ;; + QT4) CATALOG="$SCRIPT_DIR/work/qt4tests-master/catalog.xml" ;; + FTTS) CATALOG="$SCRIPT_DIR/work/XQFTTS_1_0_4/XQFTTSCatalog.xml" ;; + *) echo "ERROR: Unknown XQTS version: $XQTS_VERSION"; exit 1 ;; +esac + +if [[ ! -f "$CATALOG" ]]; then + echo "ERROR: Catalog not found: $CATALOG" + echo "Run the JAR once with no test sets to trigger download, or check work/ dir." + exit 1 +fi + +if [[ ! -f "$JAR" ]]; then + echo "ERROR: Runner JAR not found: $JAR" + exit 1 +fi + +# === Extract test set names from catalog === +if [[ "$XQTS_VERSION" == "FTTS" ]]; then + # XQFTTS uses a different catalog format + ALL_SETS=$(grep ' "$batch_log" 2>&1 & + local batch_pid=$! + + # Monitor: if still running near timeout, capture jstack + ( + sleep "$jstack_delay" + if kill -0 $batch_pid 2>/dev/null; then + echo " Batch $batch_num approaching timeout — capturing thread dump..." + local java_pid + java_pid=$(pgrep -P $batch_pid java 2>/dev/null | head -1 || true) + if [[ -n "$java_pid" ]]; then + "$JAVA_HOME/bin/jstack" "$java_pid" > "$jstack_file" 2>&1 || true + echo " Thread dump saved to $jstack_file" + fi + fi + ) & + local monitor_pid=$! + + # Wait for the batch to complete (or timeout) + exit_code=0 + wait $batch_pid 2>/dev/null || exit_code=$? + + # Clean up monitor and any lingering Java processes + kill $monitor_pid 2>/dev/null || true + wait $monitor_pid 2>/dev/null || true + + tail -20 "$batch_log" 2>/dev/null || true + rm -f "$batch_log" 2>/dev/null || true + + # Kill any lingering Java processes from this batch (BrokerPool shutdown hangs) + pkill -9 -f "exist.home=$exist_home" 2>/dev/null || true + sleep 1 + rm -rf "$exist_home" 2>/dev/null || true + + batch_end=$(date +%s) + batch_elapsed=$((batch_end - batch_start)) + + if [[ $exit_code -eq 124 || $exit_code -eq 137 ]]; then + echo " WARNING: Batch $batch_num TIMED OUT after ${BATCH_TIMEOUT}s (exit $exit_code) [stream $stream_id]" + if [[ -f "$jstack_file" ]]; then + echo " Thread dump: $jstack_file" + fi + return 1 + elif [[ $exit_code -gt 1 && $exit_code -ne 255 ]]; then + echo " WARNING: Batch $batch_num crashed with code $exit_code (${batch_elapsed}s) [stream $stream_id]" + return 1 + else + # exit 0 = all tests passed, exit 1 = some test failures (normal), exit 255 = runner error (non-fatal) + echo " Batch $batch_num completed in ${batch_elapsed}s (exit $exit_code) [stream $stream_id]" + fi + return 0 +} + +# === Run a stream of batches sequentially === +# Args: stream_id batch_indices... +# Writes failure count to /tmp/xqts-stream-failures-$stream_id +run_stream() { + local stream_id=$1; shift + local failures=0 + local indices=("$@") + + for batch_idx in "${indices[@]}"; do + local start_idx=$((batch_idx * BATCH_SIZE)) + local end_idx=$((start_idx + BATCH_SIZE)) + if (( end_idx > TOTAL )); then end_idx=$TOTAL; fi + local batch_num=$((batch_idx + 1)) + + run_batch "$batch_num" "$BATCHES" "$start_idx" "$end_idx" "$stream_id" || failures=$((failures + 1)) + echo "" + done + + echo "$failures" > "/tmp/xqts-stream-failures-$stream_id" +} + +# === Dispatch batches === +mkdir -p "$OUTPUT_DIR/junit/data" +START_TIME=$(date +%s) +FAILURES=0 + +if [[ "$PARALLEL" -le 1 ]]; then + # Sequential mode (original behavior) + for (( batch_idx=0; batch_idx TOTAL )); then local_end=$TOTAL; fi + + run_batch "$((batch_idx + 1))" "$BATCHES" "$local_start" "$local_end" "1" || FAILURES=$((FAILURES + 1)) + echo "" + done +else + # Parallel mode: distribute batches round-robin across streams + echo "Starting $PARALLEL parallel streams..." + echo "" + + # Build batch index arrays for each stream + declare -a STREAM_PIDS + for (( s=0; s> "$TIMING_LOG" + +# Count result files +if [[ -d "$OUTPUT_DIR/junit/data" ]]; then + RESULT_COUNT=$(ls "$OUTPUT_DIR/junit/data"/TEST-*.xml 2>/dev/null | wc -l | tr -d ' ') + echo "Results: $RESULT_COUNT XML files in $OUTPUT_DIR/junit/data/" + + # Quick aggregate: count pass/fail/error across all XML files + if command -v xmllint &>/dev/null && [[ $RESULT_COUNT -gt 0 ]]; then + TOTAL_TESTS=0 + TOTAL_FAILURES=0 + TOTAL_ERRORS=0 + TOTAL_SKIPPED=0 + for f in "$OUTPUT_DIR/junit/data"/TEST-*.xml; do + T=$(xmllint --xpath 'string(//testsuite/@tests)' "$f" 2>/dev/null || echo 0) + F=$(xmllint --xpath 'string(//testsuite/@failures)' "$f" 2>/dev/null || echo 0) + E=$(xmllint --xpath 'string(//testsuite/@errors)' "$f" 2>/dev/null || echo 0) + S=$(xmllint --xpath 'string(//testsuite/@skipped)' "$f" 2>/dev/null || echo 0) + TOTAL_TESTS=$((TOTAL_TESTS + T)) + TOTAL_FAILURES=$((TOTAL_FAILURES + F)) + TOTAL_ERRORS=$((TOTAL_ERRORS + E)) + TOTAL_SKIPPED=$((TOTAL_SKIPPED + S)) + done + PASSED=$((TOTAL_TESTS - TOTAL_FAILURES - TOTAL_ERRORS - TOTAL_SKIPPED)) + echo "" + echo "Aggregate: $TOTAL_TESTS tests, $PASSED passed, $TOTAL_FAILURES failed, $TOTAL_ERRORS errors, $TOTAL_SKIPPED skipped" + if [[ $TOTAL_TESTS -gt 0 ]]; then + PCT=$(echo "scale=1; $PASSED * 100 / $TOTAL_TESTS" | bc) + echo "Pass rate: ${PCT}% ($PASSED / $TOTAL_TESTS)" + fi + fi +fi + +# Per-test-set timing report (sorted by time, descending) +if [[ -d "$OUTPUT_DIR/junit/data" ]] && command -v python3 &>/dev/null; then + TIMING_REPORT="$OUTPUT_DIR/timing-report.txt" + python3 -c " +import xml.etree.ElementTree as ET, glob, sys +results = [] +for f in sorted(glob.glob('$OUTPUT_DIR/junit/data/TEST-*.xml')): + root = ET.parse(f).getroot() + name = root.get('name','').replace('XQTS_QT4.','').replace('XQTS_3_1.','').replace('XQTS_FTTS_1_0.','') + t = float(root.get('time','0')) + tests = int(root.get('tests','0')) + fails = int(root.get('failures','0')) + errs = int(root.get('errors','0')) + passed = tests - fails - errs - int(root.get('skipped','0')) + results.append((t, name, tests, passed, fails, errs)) +results.sort(reverse=True) +total_time = sum(r[0] for r in results) +print(f'Per-test-set timing report ({len(results)} sets, {total_time:.0f}s total)') +print(f'{\"Time\":>8} {\"Tests\":>6} {\"Pass\":>6} {\"Fail\":>5} {\"Err\":>4} Set') +for t, name, tests, p, f, e in results: + if t >= 1.0: + flag = ' !!!' if t > 60 else ' !' if t > 10 else '' + print(f'{t:>7.1f}s {tests:>6} {p:>6} {f:>5} {e:>4} {name}{flag}') +slow = [r for r in results if r[0] > 60] +if slow: + print(f'\n{len(slow)} test sets >60s — investigate for performance issues') +" 2>/dev/null | tee "$TIMING_REPORT" + echo "" + echo "Timing report saved to: $TIMING_REPORT" +fi + +# List test sets that were expected but produced no results (killed by timeout) +if [[ $FAILURES -gt 0 ]]; then + echo "" + echo "WARNING: $FAILURES batch(es) timed out or failed. Some test sets may have no results." +fi + +echo "" +echo "Done." diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 64fca63..0389575 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -11,10 +11,24 @@ xqtsrunner { { version = HEAD url = "https://github.com/w3c/qt3tests/archive/master.zip" - sha256 = "8807ef98c79c23f25b811194e898e644ed92e6d52741636c219919b3409b2aab" + sha256 = "" has-dir = "qt3tests-master" check-file = "catalog.xml" } + { + version = QT4 + url = "https://github.com/qt4cg/qt4tests/archive/master.zip" + sha256 = "" + has-dir = "qt4tests-master" + check-file = "catalog.xml" + } + { + version = FTTS + url = "https://dev.w3.org/2007/xpath-full-text-10-test-suite/XQFTTS_1_0_4.zip" + sha256 = "" + has-dir = "XQFTTS_1_0_4" + check-file = "XQFTTSCatalog.xml" + } ] local-dir = "work" diff --git a/src/main/resources/conf.xml b/src/main/resources/conf.xml index ac2b738..4f44d2a 100644 --- a/src/main/resources/conf.xml +++ b/src/main/resources/conf.xml @@ -888,6 +888,7 @@ + diff --git a/src/main/scala/org/exist/xqts/runner/AssertTypeParser.scala b/src/main/scala/org/exist/xqts/runner/AssertTypeParser.scala index e9f3f18..2dc21b5 100644 --- a/src/main/scala/org/exist/xqts/runner/AssertTypeParser.scala +++ b/src/main/scala/org/exist/xqts/runner/AssertTypeParser.scala @@ -77,6 +77,13 @@ object AssertTypeParser { name.localName.equals("function") || name.localName.equals("map") || name.localName.equals("array") } + def isNodeKindTest(name: TypeNameNode): Boolean = { + val n = name.localName + n.equals("element") || n.equals("attribute") || n.equals("document-node") || + n.equals("schema-element") || n.equals("schema-attribute") || + n.equals("processing-instruction") || n.equals("namespace-node") + } + val existType = ExistType.getType( typeParameters.flatMap { parametersNode => if (isFunctionType(name) || (parametersNode.parameters.size == 1 && parametersNode.parameters.head == WildcardTypeNode)) { @@ -87,17 +94,18 @@ object AssertTypeParser { }.getOrElse(name.asXdmTypeName) ) - // match { - // case "node()" => - // // NOTE: for some reason eXist-db does not support looking up Node type by name?!? - // ExistType.NODE - // case typeName => - // ExistType.getType(typeName) - // } + // For node kind tests like element(name), attribute(name), the parameters + // are element/attribute names, not type references. Skip parameter resolution + // for these since eXist-db doesn't support parameterized node kind tests anyway. + val resolvedParams = if (isNodeKindTest(name)) { + None + } else { + typeParameters.map(x => x.parameters.map(y => y.asExistTypeDescription)) + } ExplicitExistTypeDescription( existType, - typeParameters.map(x => x.parameters.map(y => y.asExistTypeDescription)), + resolvedParams, cardinality.map(_.cardinality match { case TypeCardinality.* => ExistCardinality.ZERO_OR_MORE case TypeCardinality.+ => ExistCardinality.ONE_OR_MORE diff --git a/src/main/scala/org/exist/xqts/runner/ExistServer.scala b/src/main/scala/org/exist/xqts/runner/ExistServer.scala index 1337b14..d94d1a0 100644 --- a/src/main/scala/org/exist/xqts/runner/ExistServer.scala +++ b/src/main/scala/org/exist/xqts/runner/ExistServer.scala @@ -53,12 +53,29 @@ object ExistServer { def apply(queryResult: QueryResult, compilationTime: CompilationTime, executionTime: ExecutionTime) = new Result(Right(queryResult), compilationTime, executionTime) } - case class Result(result: Either[QueryError, QueryResult], compilationTime: CompilationTime, executionTime: ExecutionTime) + case class Result(result: Either[QueryError, QueryResult], compilationTime: CompilationTime, executionTime: ExecutionTime) { + /** Serialization properties extracted from the query context (e.g. declare option output:method "json") */ + var serializationProperties: Properties = new Properties() + } type QueryResult = Sequence object QueryError { - def apply(xpathException: XPathException) = new QueryError(xpathException.getErrorCode.getErrorQName.getLocalPart, xpathException.getMessage) + private val STANDARD_ERROR_NAMESPACES = Set( + "http://www.w3.org/2005/xqt-errors", + "http://www.exist-db.org/xqt-errors/" + ) + + def apply(xpathException: XPathException) = { + val qname = xpathException.getErrorCode.getErrorQName + val ns = qname.getNamespaceURI + val code = if (ns != null && ns.nonEmpty && !STANDARD_ERROR_NAMESPACES.contains(ns)) { + s"Q{$ns}${qname.getLocalPart}" + } else { + qname.getLocalPart + } + new QueryError(code, xpathException.getMessage) + } } case class QueryError(errorCode: String, message: String) @@ -93,6 +110,12 @@ class ExistServer { private val existServer = new ExistEmbeddedServer(true, true) private val logger = Logger(classOf[ExistServer]) + /** + * Global context attributes to set on every XQuery execution context. + * Used by the XQFTTS catalog parser to provide stop word and thesaurus URI mappings. + */ + @volatile var globalContextAttributes: Map[String, AnyRef] = Map.empty + /** * Starts the eXist-db server. * @@ -106,7 +129,7 @@ class ExistServer { def getConnection(): ExistConnection = { val brokerRes = Resource.make { // build - IO.delay(existServer.getBrokerPool.getBroker) + IO.delay(existServer.getBrokerPool.authenticate("admin", "")) // .flatTap(_ => IOUtil.printlnExecutionContext("Broker/Acquire")) // enable for debugging } { // release @@ -118,19 +141,30 @@ class ExistServer { // .flatTap(_ => IOUtil.printlnExecutionContext("Broker/Release")) // enable for debugging } - ExistConnection(brokerRes) + ExistConnection(brokerRes, () => globalContextAttributes) } /** * Shutdown the eXist-db server. + * + * A timeout thread ensures the process exits even if BrokerPool.stopAll() + * hangs (a known issue with thread pools that accumulate during long runs). */ def stopServer(): Unit = { + val shutdownTimeout = new Thread(() => { + Thread.sleep(30000) + logger.warn("BrokerPool shutdown did not complete within 30 seconds, forcing exit") + Runtime.getRuntime.halt(0) + }, "exist-shutdown-timeout") + shutdownTimeout.setDaemon(true) + shutdownTimeout.start() existServer.stopDb() } } private object ExistConnection { - def apply(brokerRes: Resource[IO, DBBroker]) = new ExistConnection(brokerRes) + def apply(brokerRes: Resource[IO, DBBroker], contextAttributesSupplier: () => Map[String, AnyRef] = () => Map.empty) = + new ExistConnection(brokerRes, contextAttributesSupplier) } /** @@ -138,8 +172,9 @@ private object ExistConnection { * to an eXist-db server, i.e. a [[DBBroker]] * * @param brokerRes the eXist-db broker to wrap. + * @param contextAttributesSupplier supplies context attributes to set on each XQuery execution context. */ -class ExistConnection(brokerRes: Resource[IO, DBBroker]) { +class ExistConnection(brokerRes: Resource[IO, DBBroker], contextAttributesSupplier: () => Map[String, AnyRef] = () => Map.empty) { /** * Execute an XQuery with eXist-db. @@ -337,8 +372,14 @@ class ExistConnection(brokerRes: Resource[IO, DBBroker]) { ): IO[Either[ExistServerException, Result]] = { IO.delay { try { - val resultSequence = xqueryService.execute(broker, compiledQuery.compiledXquery, contextSequence.orNull) - Right(Result(resultSequence, compiledQuery.compilationTime, System.currentTimeMillis() - executionStartTime)) + // Pass outputProperties to execute() so eXist extracts serialization + // options (e.g., declare option output:method "html") BEFORE calling + // context.reset(), which clears them. + val serializationProps = new Properties() + val resultSequence = xqueryService.execute(broker, compiledQuery.compiledXquery, contextSequence.orNull, serializationProps) + val result = Result(resultSequence, compiledQuery.compilationTime, System.currentTimeMillis() - executionStartTime) + result.serializationProperties = serializationProps + Right(result) } catch { // NOTE(AR): bugs in eXist-db's XQuery implementation can produce a StackOverflowError - handle as any other server exception case e: StackOverflowError => @@ -463,7 +504,14 @@ class ExistConnection(brokerRes: Resource[IO, DBBroker]) { } val source = new StringSource(query) - val fnConfigureContext: XQueryContext => XQueryContext = setupContext(_)(staticBaseUri, availableDocuments, availableCollections, availableTextResources, namespaces, externalVariables, decimalFormats, modules, xpath1Compatibility) + val fnConfigureContext: XQueryContext => XQueryContext = { ctx => + val configured = setupContext(ctx)(staticBaseUri, availableDocuments, availableCollections, availableTextResources, namespaces, externalVariables, decimalFormats, modules, xpath1Compatibility) + // Set global context attributes (e.g., ft.stopWordURIMap, ft.thesaurusURIMap from XQFTTS catalog) + for ((key, value) <- contextAttributesSupplier()) { + configured.setAttribute(key, value.asInstanceOf[Object]) + } + configured + } val res: IO[Either[ExistServerException, Result]] = SingleThreadedExecutorPool.newResource().use { singleThreadedExecutor => @@ -533,6 +581,20 @@ class ExistConnection(brokerRes: Resource[IO, DBBroker]) { * @return the result of serializing the sequence. */ def sequenceToString(sequence: Sequence, outputProperties: Properties): String = { + sequenceToStringImpl(sequence, outputProperties, sanitize = true) + } + + /** + * Serializes a Sequence to a raw String + * without any post-processing (no newline replacement). + * Used for serialization-matches assertions where + * the exact serialized output must be preserved. + */ + def sequenceToStringRaw(sequence: Sequence, outputProperties: Properties): String = { + sequenceToStringImpl(sequence, outputProperties, sanitize = false) + } + + private def sequenceToStringImpl(sequence: Sequence, outputProperties: Properties, sanitize: Boolean): String = { val res: IO[String] = SingleThreadedExecutorPool.newResource().use { singleThreadedExecutor => val writerRes = @@ -550,8 +612,8 @@ class ExistConnection(brokerRes: Resource[IO, DBBroker]) { IO.delay { val serializer = new XQuerySerializer(broker, outputProperties, writer) serializer.serialize(sequence) - writer.getBuffer.toString - .replace("\r", "").replace("\n", ", ") // further improves the output for expected value messages + val result = writer.getBuffer.toString + if (sanitize) result.replace("\r", "").replace("\n", ", ") else result }.evalOn(singleThreadedExecutor.executionContext) } } diff --git a/src/main/scala/org/exist/xqts/runner/TestCaseRunnerActor.scala b/src/main/scala/org/exist/xqts/runner/TestCaseRunnerActor.scala index a868863..4f6ad9a 100644 --- a/src/main/scala/org/exist/xqts/runner/TestCaseRunnerActor.scala +++ b/src/main/scala/org/exist/xqts/runner/TestCaseRunnerActor.scala @@ -42,6 +42,8 @@ import org.xmlunit.XMLUnitException import org.xmlunit.builder.{DiffBuilder, Input} import org.xmlunit.diff.{Comparison, ComparisonType, DefaultComparisonFormatter} +import java.util.Properties +import javax.xml.transform.OutputKeys import scala.annotation.unused import scala.util.{Failure, Success} @@ -115,11 +117,47 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac } case None => - // error - invalid test case - //TODO(AR) detect this in catalog parser and inform the manager there! - //TODO(AR) replace these two messages with InvalidTestCase() - and let the manager deal with it - manager ! RunningTestCase(testSetRef, testCase.name) - manager ! RanTestCase(testSetRef, ErrorResult(testSetRef.name, testCase.name, -1, -1, new IllegalStateException("Invalid Test Case: No test defined for test-case"))) + // No main test query - check if this is an update-only test case + if (testCase.updateTests.nonEmpty) { + // Update-only test with inline queries - handle like a normal inline test + // Check if any update test uses external files + val externalQueryFiles = testCase.updateTests.collect { case Right(path) => path } + externalQueryFiles.foreach(queryPath => { + commonResourceCacheActor ! GetResource(queryPath) + awaitingQueryStr = merge1(awaitingQueryStr)((testSetRef.name, testCase.name), queryPath) + }) + + testCase.environment match { + case Some(environment) if (environment.schemas.filter(_.file.nonEmpty).nonEmpty || environment.sources.nonEmpty || environment.resources.nonEmpty || environment.collections.flatMap(_.sources).nonEmpty) => + val requiredSchemas = environment.schemas.filter(_.file.nonEmpty) + requiredSchemas.map(schema => commonResourceCacheActor ! GetResource(schema.file.get)) + awaitingSchemas = merge(awaitingSchemas)((testSetRef.name, testCase.name), requiredSchemas.map(schema => () => schema.file.get)) + + environment.sources.map(source => commonResourceCacheActor ! GetResource(source.file)) + awaitingSources = merge(awaitingSources)((testSetRef.name, testCase.name), environment.sources.map(source => () => source.file)) + environment.resources.map(resource => commonResourceCacheActor ! GetResource(resource.file)) + awaitingResources = merge(awaitingResources)((testSetRef.name, testCase.name), environment.resources.map(resource => () => resource.file)) + environment.collections.map(_.sources.map(source => commonResourceCacheActor ! GetResource(source.file))) + awaitingSources = merge(awaitingSources)((testSetRef.name, testCase.name), environment.collections.flatMap(_.sources).map(source => () => source.file)) + + pendingTestCases = addIfNotPresent(pendingTestCases)(rtc) + + case _ => + if (externalQueryFiles.nonEmpty) { + // we are awaiting external query files + pendingTestCases = addIfNotPresent(pendingTestCases)(rtc) + } else { + // we have everything we need - schedule the test case + self ! RunTestCaseInternal(rtc, ResolvedEnvironment()) + } + } + } else { + // error - truly invalid test case (no test and no updateTests) + //TODO(AR) detect this in catalog parser and inform the manager there! + //TODO(AR) replace these two messages with InvalidTestCase() - and let the manager deal with it + manager ! RunningTestCase(testSetRef, testCase.name) + manager ! RanTestCase(testSetRef, ErrorResult(testSetRef.name, testCase.name, -1, -1, new IllegalStateException("Invalid Test Case: No test defined for test-case"))) + } } @@ -217,6 +255,61 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac */ @throws(classOf[OutOfMemoryError]) private def runTestCase(connection: ExistConnection, testSetName: TestSetName, testCase: TestCase, resolvedEnvironment: ResolvedEnvironment): TestResult = { + // Set up sandpit if the environment defines one + val sandpitTempDir: Option[Path] = testCase.environment.flatMap(_.sandpit).map { sandpit => + val tempDir = Files.createTempDirectory("xqts-sandpit-") + copyDirectory(sandpit.path, tempDir) + tempDir + } + + try { + // If sandpit is active, override the static base URI to point to the temp directory + val effectiveTestCase = sandpitTempDir match { + case Some(tempDir) => + val sandpitBaseUri = tempDir.toUri.toString + testCase.copy(environment = testCase.environment.map(_.copy(staticBaseUri = Some(sandpitBaseUri)))) + case None => testCase + } + + if (effectiveTestCase.updateTests.nonEmpty) { + runUpdateTestCase(connection, testSetName, effectiveTestCase, resolvedEnvironment) + } else { + runNonUpdateTestCase(connection, testSetName, effectiveTestCase, resolvedEnvironment) + } + } finally { + // Clean up sandpit temp directory + sandpitTempDir.foreach(deleteDirectory) + } + } + + /** Recursively copy a directory tree. */ + private def copyDirectory(source: Path, target: Path): Unit = { + Files.walk(source).forEach { sourcePath => + val targetPath = target.resolve(source.relativize(sourcePath)) + if (Files.isDirectory(sourcePath)) { + Files.createDirectories(targetPath) + } else { + Files.copy(sourcePath, targetPath) + } + } + } + + /** Recursively delete a directory tree. */ + private def deleteDirectory(dir: Path): Unit = { + try { + Files.walk(dir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(Files.delete(_)) + } catch { + case _: IOException => // best-effort cleanup + } + } + + /** + * Run a non-update (standard) XQTS test-case. + */ + @throws(classOf[OutOfMemoryError]) + private def runNonUpdateTestCase(connection: ExistConnection, testSetName: TestSetName, testCase: TestCase, resolvedEnvironment: ResolvedEnvironment): TestResult = { testCase.test match { case Some(test) => @@ -258,7 +351,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac case Left(existServerException) => ErrorResult(testSetName, testCase.name, existServerException.compilationTime, existServerException.executionTime, existServerException) - case Right(Result(result, compilationTime, executionTime)) => + case Right(queryResultObj @ Result(result, compilationTime, executionTime)) => result match { // executing query returned an error @@ -270,6 +363,8 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac PassResult(testSetName, testCase.name, compilationTime, executionTime) case anyOf@AnyOf(_) if (anyOfContainsError(anyOf, queryError.errorCode)) => PassResult(testSetName, testCase.name, compilationTime, executionTime) + case allOf@AllOf(_) if (allOfContainsError(allOf, queryError.errorCode)) => + PassResult(testSetName, testCase.name, compilationTime, executionTime) case _ => FailureResult(testSetName, testCase.name, compilationTime, executionTime, failureMessage(expectedResult, queryError)) } @@ -287,7 +382,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac FailureResult(testSetName, testCase.name, compilationTime, executionTime, failureMessage(connection)(expectedError, queryResult)) case (Some(expectedResult)) => - processAssertion(connection, testSetName, testCase.name, compilationTime, executionTime)(expectedResult, queryResult) + processAssertion(connection, testSetName, testCase.name, compilationTime, executionTime, queryResultObj.serializationProperties, baseUri)(expectedResult, queryResult) case None => ErrorResult(testSetName, testCase.name, compilationTime, executionTime, new IllegalStateException("No defined expected result")) @@ -300,6 +395,199 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac } } + /** + * Run an XQuery Update XQTS test-case. + * + * Update tests have two phases: + * 1. Execute the update query (with update="true") which modifies the source document(s) + * 2. Execute the verification query against the modified document(s) and check the result + */ + @throws(classOf[OutOfMemoryError]) + private def runUpdateTestCase(connection: ExistConnection, testSetName: TestSetName, testCase: TestCase, resolvedEnvironment: ResolvedEnvironment): TestResult = { + val baseUri = testCase.environment + .flatMap(_.staticBaseUri.orElse(Some(testCase.file.toUri.toString))) + .filterNot(_ == "#UNDEFINED") + + // Parse source documents once, so the same in-memory document objects are shared between update and verification queries + val parsedSources: Either[ExistServerException, List[(Source, org.exist.dom.memtree.DocumentImpl)]] = { + val initAccum: Either[ExistServerException, List[(Source, org.exist.dom.memtree.DocumentImpl)]] = Right(List.empty) + testCase.environment + .map(_.sources) + .getOrElse(List.empty) + .foldLeft(initAccum) { case (accum, source) => + accum.flatMap { results => + resolveSource(resolvedEnvironment, source) + .flatMap(rs => SAXParser.parseXml(rs.data).map(doc => { + doc.setDocumentURI(rs.path.toUri.toString) + (source, doc) +: results + })) + } + } + } + + parsedSources match { + case Left(error) => + ErrorResult(testSetName, testCase.name, error.compilationTime, error.executionTime, error) + + case Right(sourceDocs) => + // Build variable declarations from parsed source docs (for external variables like $input-context) + val externalVarDocs: List[(String, Sequence)] = sourceDocs.filter { case (source, _) => + source.role.exists(_.isInstanceOf[ExternalVariableRole]) + }.map { case (source, doc) => + (source.role.get.asInstanceOf[ExternalVariableRole].name, doc.asInstanceOf[Sequence]) + } + + // Build context sequence from source with role="." (context item) + // For update tests, also fall back to the first mutable source as context + val contextDoc: Option[Sequence] = sourceDocs.find { case (source, _) => + source.role.exists(Role.isContextItem) + }.map { case (_, doc) => doc.asInstanceOf[Sequence] } + .orElse(sourceDocs.find { case (source, _) => source.mutable }.map { case (_, doc) => doc.asInstanceOf[Sequence] }) + + val hasUpdateFeature = testCase.dependencies.exists(_.`type` == DependencyType.Feature) + + // Phase 1: Execute all update queries sequentially. + // Each step shares the same in-memory documents, so mutations accumulate. + // For copy-modify-return expressions, the update query itself returns a value + // that may be needed for assertion checking (when no verification query exists). + val updateStepsResult: Either[TestResult, (Long, Long, Option[Sequence])] = { + var totalCompilationTime: Long = 0 + var totalExecutionTime: Long = 0 + var earlyResult: Option[TestResult] = None + var lastUpdateResult: Option[Sequence] = None + + val iter = testCase.updateTests.iterator + while (iter.hasNext && earlyResult.isEmpty) { + val step = iter.next() + val queryString: String = step match { + case Left(query) => query + case Right(_) => resolvedEnvironment.resolvedQuery.get + } + + connection.executeQuery( + queryString, false, baseUri, contextDoc, + Seq.empty, Seq.empty, Seq.empty, + testCase.environment.map(_.namespaces).getOrElse(List.empty), + externalVarDocs, + testCase.environment.map(_.decimalFormats).getOrElse(List.empty), + testCase.modules, false + ) match { + case Left(ex) => + earlyResult = Some(ErrorResult(testSetName, testCase.name, ex.compilationTime, ex.executionTime, ex)) + + case Right(Result(result, ct, et)) => + totalCompilationTime += ct + totalExecutionTime += et + result match { + case Left(queryError) => + // An update step raised an error — check if expected + earlyResult = Some(testCase.result match { + case Some(Error(expected)) if expected == queryError.errorCode => + PassResult(testSetName, testCase.name, totalCompilationTime, totalExecutionTime) + case Some(anyOf@AnyOf(_)) if anyOfContainsError(anyOf, queryError.errorCode) => + PassResult(testSetName, testCase.name, totalCompilationTime, totalExecutionTime) + case Some(allOf@AllOf(_)) if allOfContainsError(allOf, queryError.errorCode) => + PassResult(testSetName, testCase.name, totalCompilationTime, totalExecutionTime) + case Some(expectedResult) => + FailureResult(testSetName, testCase.name, totalCompilationTime, totalExecutionTime, failureMessage(expectedResult, queryError)) + case None => + ErrorResult(testSetName, testCase.name, totalCompilationTime, totalExecutionTime, new IllegalStateException("No defined expected result")) + }) + case Right(queryResult) => + // Save result — needed for copy-modify-return assertions + if (queryResult != null && queryResult.getItemCount > 0) { + lastUpdateResult = Some(queryResult) + } + } + } + } + + earlyResult match { + case Some(result) => Left(result) + case None => Right((totalCompilationTime, totalExecutionTime, lastUpdateResult)) + } + } + + updateStepsResult match { + case Left(terminalResult) => terminalResult + + case Right((updateCompTime, updateExecTime, lastUpdateResult)) => + // Phase 2: Execute verification query + testCase.test match { + case Some(verifyTest) => + val verifyQueryString: String = verifyTest match { + case Left(query) => query + case Right(_) => resolvedEnvironment.resolvedQuery.get + } + + // For verification, use the first mutable source as context item (now modified by updates) + val verifyContextDoc: Option[Sequence] = sourceDocs.find { case (source, _) => + source.mutable + }.map { case (_, doc) => doc.asInstanceOf[Sequence] } + .orElse(contextDoc) + + connection.executeQuery( + verifyQueryString, false, baseUri, verifyContextDoc, + Seq.empty, Seq.empty, Seq.empty, + testCase.environment.map(_.namespaces).getOrElse(List.empty), + externalVarDocs, + testCase.environment.map(_.decimalFormats).getOrElse(List.empty), + testCase.modules, false + ) match { + case Left(ex) => + ErrorResult(testSetName, testCase.name, ex.compilationTime, ex.executionTime, ex) + + case Right(Result(verifyResultValue, compilationTime, executionTime)) => + verifyResultValue match { + case Left(queryError) => + testCase.result match { + case Some(Error(expected)) if expected == queryError.errorCode => + PassResult(testSetName, testCase.name, compilationTime, executionTime) + case Some(anyOf@AnyOf(_)) if anyOfContainsError(anyOf, queryError.errorCode) => + PassResult(testSetName, testCase.name, compilationTime, executionTime) + case Some(allOf@AllOf(_)) if allOfContainsError(allOf, queryError.errorCode) => + PassResult(testSetName, testCase.name, compilationTime, executionTime) + case Some(expectedResult) => + FailureResult(testSetName, testCase.name, compilationTime, executionTime, failureMessage(expectedResult, queryError)) + case None => + ErrorResult(testSetName, testCase.name, compilationTime, executionTime, new IllegalStateException("No defined expected result")) + } + + case Right(null) => + ErrorResult(testSetName, testCase.name, compilationTime, executionTime, new IllegalStateException("eXist-db returned null from the verification query")) + + case Right(queryResult) => + testCase.result match { + case Some(expectedError: Error) => + FailureResult(testSetName, testCase.name, compilationTime, executionTime, failureMessage(connection)(expectedError, queryResult)) + case Some(expectedResult) => + processAssertion(connection, testSetName, testCase.name, compilationTime, executionTime, assertionBaseUri = baseUri)(expectedResult, queryResult) + case None => + ErrorResult(testSetName, testCase.name, compilationTime, executionTime, new IllegalStateException("No defined expected result")) + } + } + } + + case None => + // No verification query — update-only test or copy-modify-return + testCase.result match { + case Some(expectedError: Error) => + // Expected an error but the update succeeded — failure + FailureResult(testSetName, testCase.name, updateCompTime, updateExecTime, failureMessage(connection)(expectedError, new org.exist.xquery.value.EmptySequence())) + case Some(expectedResult) if lastUpdateResult.isDefined => + // Copy-modify-return: use the update expression's return value for assertion + processAssertion(connection, testSetName, testCase.name, updateCompTime, updateExecTime, assertionBaseUri = baseUri)(expectedResult, lastUpdateResult.get) + case Some(_) => + // Expected a non-error result with no verification query and no update result + FailureResult(testSetName, testCase.name, updateCompTime, updateExecTime, s"Expected a result but no verification query defined") + case None => + PassResult(testSetName, testCase.name, updateCompTime, updateExecTime) + } + } + } + } + } + /** * Get the context sequence for the XQuery. * @@ -524,6 +812,29 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac .nonEmpty } + /** + * Checks if an XQTS all-of assertion contains a specific error assertion. + * + * @param allOf the all-of assertion. + * @param expectedError the error to search for in the all-of + * @return true if the all-of contains the error, false otherwise. + */ + private def allOfContainsError(allOf: AllOf, expectedError: String): Boolean = { + def expand(result: XQTSParserActor.Result): List[XQTSParserActor.Result] = { + Some(result) + .filter(_.isInstanceOf[Assertions]) + .map(_.asInstanceOf[Assertions]) + .map(_.assertions) + .getOrElse(List(result)) + } + + allOf.assertions.map(expand).flatten + .filter(_.isInstanceOf[Error]) + .map(_.asInstanceOf[Error]) + .find(_.expected == expectedError) + .nonEmpty + } + /** * Processes an expected XQTS assertion to compare * it against the actual result of executing an XQuery. @@ -537,46 +848,46 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actualResult the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def processAssertion(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expectedResult: XQTSParserActor.Result, actualResult: ExistServer.QueryResult): TestResult = { + private def processAssertion(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, serializationProperties: Properties = new Properties(), assertionBaseUri: Option[String] = None)(expectedResult: XQTSParserActor.Result, actualResult: ExistServer.QueryResult): TestResult = { expectedResult match { case AllOf(assertions) => - allOf(connection, testSetName, testCaseName, compilationTime, executionTime)(assertions, actualResult) + allOf(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(assertions, actualResult) case AnyOf(assertions) => - anyOf(connection, testSetName, testCaseName, compilationTime, executionTime)(assertions, actualResult) + anyOf(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(assertions, actualResult) case Not(Some(assertion)) => - not(connection, testSetName, testCaseName, compilationTime, executionTime)(assertion, actualResult) + not(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(assertion, actualResult) case Assert(xpath) => - assert(connection, testSetName, testCaseName, compilationTime, executionTime)(xpath, actualResult) + assert(connection, testSetName, testCaseName, compilationTime, executionTime, assertionBaseUri)(xpath, actualResult) case AssertCount(expectedCount) => assertCount(testSetName, testCaseName, compilationTime, executionTime)(expectedCount, actualResult) case AssertDeepEquals(expected) => - assertDeepEquals(connection, testSetName, testCaseName, compilationTime, executionTime)(expected, actualResult) + assertDeepEquals(connection, testSetName, testCaseName, compilationTime, executionTime, assertionBaseUri)(expected, actualResult) case AssertEq(expected) => - assertEq(connection, testSetName, testCaseName, compilationTime, executionTime)(expected, actualResult) + assertEq(connection, testSetName, testCaseName, compilationTime, executionTime, assertionBaseUri)(expected, actualResult) case AssertPermutation(expected) => - assertPermutation(connection, testSetName, testCaseName, compilationTime, executionTime)(expected, actualResult) + assertPermutation(connection, testSetName, testCaseName, compilationTime, executionTime, assertionBaseUri)(expected, actualResult) case AssertSerializationError(expected) => - assertSerializationError(connection, testSetName, testCaseName, compilationTime, executionTime)(expected, actualResult) + assertSerializationError(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(expected, actualResult) case AssertStringValue(expected, normalizeSpace) => - assertStringValue(connection, testSetName, testCaseName, compilationTime, executionTime)(expected, normalizeSpace, actualResult) + assertStringValue(connection, testSetName, testCaseName, compilationTime, executionTime, assertionBaseUri)(expected, normalizeSpace, actualResult) case AssertType(expectedType) => assertType(testSetName, testCaseName, compilationTime, executionTime)(expectedType, actualResult) - case AssertXml(expectedXml, ignorePrefixes) => - assertXml(connection, testSetName, testCaseName, compilationTime, executionTime)(expectedXml, ignorePrefixes, actualResult) + case AssertXml(expectedXml, ignorePrefixes, normalizeWhitespace) => + assertXml(connection, testSetName, testCaseName, compilationTime, executionTime, assertionBaseUri)(expectedXml, ignorePrefixes, normalizeWhitespace, actualResult) case SerializationMatches(expected, flags) => - serializationMatches(connection, testSetName, testCaseName, compilationTime, executionTime)(expected, flags, actualResult) + serializationMatches(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(expected, flags, actualResult) case AssertEmpty => assertEmpty(connection, testSetName, testCaseName, compilationTime, executionTime)(actualResult) @@ -587,6 +898,10 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac case AssertTrue => assertTrue(testSetName, testCaseName, compilationTime, executionTime)(actualResult) + case AssertInspect => + // "Inspect" comparison: cannot automatically verify, always passes + PassResult(testSetName, testCaseName, compilationTime, executionTime) + case Error(expected) => FailureResult(testSetName, testCaseName, compilationTime, executionTime, s"error: expected='$expected', but no error was raised") @@ -607,12 +922,12 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def allOf(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(assertions: List[XQTSParserActor.Result], actual: ExistServer.QueryResult): TestResult = { + private def allOf(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, serializationProperties: Properties = new Properties(), assertionBaseUri: Option[String] = None)(assertions: List[XQTSParserActor.Result], actual: ExistServer.QueryResult): TestResult = { val problem: Option[Either[ErrorResult, FailureResult]] = assertions.foldLeft(Option.empty[Either[ErrorResult, FailureResult]]) { case (failed, assertion) => if (failed.nonEmpty) { failed } else { - processAssertion(connection, testSetName, testCaseName, compilationTime, executionTime)(assertion, actual) match { + processAssertion(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(assertion, actual) match { case error: ErrorResult => Some(Left(error)) case failure: FailureResult => @@ -641,7 +956,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def anyOf(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(assertions: List[XQTSParserActor.Result], actual: ExistServer.QueryResult): TestResult = { + private def anyOf(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, serializationProperties: Properties = new Properties(), assertionBaseUri: Option[String] = None)(assertions: List[XQTSParserActor.Result], actual: ExistServer.QueryResult): TestResult = { def passOrFails(): Either[Seq[Either[ErrorResult, FailureResult]], PassResult] = { val accum = Either.left[Seq[Either[ErrorResult, FailureResult]], PassResult](Seq.empty[Either[ErrorResult, FailureResult]]) assertions.foldLeft(accum) { case (results, assertion) => @@ -651,7 +966,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac case errors@Left(_) => // evaluate the next assertion - processAssertion(connection, testSetName, testCaseName, compilationTime, executionTime)(assertion, actual) match { + processAssertion(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(assertion, actual) match { case pass: PassResult => Right(pass) @@ -687,8 +1002,8 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def not(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(assertion: XQTSParserActor.Result, actual: ExistServer.QueryResult): TestResult = { - val result = processAssertion(connection, testSetName, testCaseName, compilationTime, executionTime)(assertion, actual) + private def not(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, serializationProperties: Properties = new Properties(), assertionBaseUri: Option[String] = None)(assertion: XQTSParserActor.Result, actual: ExistServer.QueryResult): TestResult = { + val result = processAssertion(connection, testSetName, testCaseName, compilationTime, executionTime, serializationProperties, assertionBaseUri)(assertion, actual) result match { case PassResult(_, _, _, _) => FailureResult(testSetName, testCaseName, compilationTime, executionTime, s"not assertion negated a pass result for: $assertion on result: $actual") @@ -711,13 +1026,18 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def assert(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(xpath: String, actual: ExistServer.QueryResult): TestResult = { - executeQueryWith$Result(connection, xpath, true, None, actual) match { + private def assert(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, assertionBaseUri: Option[String] = None)(xpath: String, actual: ExistServer.QueryResult): TestResult = { + // Set context item only for single-item results (e.g., maps from parse-csv) + // Multi-item sequences (e.g., from csv-to-arrays) would cause the xpath to run per-item + val contextForAssert = if (actual.getItemCount == 1) Some(actual) else None + executeQueryWith$Result(connection, xpath, true, contextForAssert, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) case Right(Result(Left(queryError), errCompilationTime, errExecutionTime)) => - ErrorResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, new IllegalStateException(s"Error whilst comparing XPath: ${queryError.errorCode}: ${queryError.message}")) + // A query error during assertion evaluation means the assertion failed (e.g., type + // mismatch comparing result to expected value), not that the runner itself errored. + FailureResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, s"assert: expected='$xpath' raised ${queryError.errorCode}: ${queryError.message}") case Right(Result(Right(actualQueryResult), resCompilationTime, resExecutionTime)) => val totalCompilationTime = compilationTime + resCompilationTime @@ -762,19 +1082,19 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def assertDeepEquals(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expected: String, actual: ExistServer.QueryResult): TestResult = { + private def assertDeepEquals(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, assertionBaseUri: Option[String] = None)(expected: String, actual: ExistServer.QueryResult): TestResult = { val deepEqualQuery = s""" | declare variable $$result external; | | deep-equal(($expected), $$result) |""".stripMargin - executeQueryWith$Result(connection, deepEqualQuery, true, None, actual) match { + executeQueryWith$Result(connection, deepEqualQuery, true, None, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) case Right(Result(Left(queryError), errCompilationTime, errExecutionTime)) => - ErrorResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, new IllegalStateException(s"Error whilst comparing deep-equals: ${queryError.errorCode}: ${queryError.message}}")) + FailureResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, s"assert-deep-eq: expected='$expected' raised ${queryError.errorCode}: ${queryError.message}") case Right(Result(Right(actualQueryResult), resCompilationTime, resExecutionTime)) => val totalCompilationTime = compilationTime + resCompilationTime @@ -801,19 +1121,19 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def assertEq(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expected: String, actual: ExistServer.QueryResult): TestResult = { + private def assertEq(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, assertionBaseUri: Option[String] = None)(expected: String, actual: ExistServer.QueryResult): TestResult = { val eqQuery = s""" | declare variable $$result external; | | $expected eq $$result |""".stripMargin - executeQueryWith$Result(connection, eqQuery, false, None, actual) match { + executeQueryWith$Result(connection, eqQuery, false, None, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) case Right(Result(Left(queryError), errCompilationTime, errExecutionTime)) => - ErrorResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, new IllegalStateException(s"Error whilst comparing eq: ${queryError.errorCode}: ${queryError.message}")) + FailureResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, s"assert-eq: expected='$expected' raised ${queryError.errorCode}: ${queryError.message}") case Right(Result(Right(actualQueryResult), resCompilationTime, resExecutionTime)) => val totalCompilationTime = compilationTime + resCompilationTime @@ -845,7 +1165,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def assertPermutation(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expected: String, actual: ExistServer.QueryResult): TestResult = { + private def assertPermutation(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, assertionBaseUri: Option[String] = None)(expected: String, actual: ExistServer.QueryResult): TestResult = { val expectedQuery = s""" | declare variable $$result external; @@ -912,12 +1232,12 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac | return | fn:deep-equal(fn:sort(($expected), (), $$sort-key-fun), fn:sort($$result, (), $$sort-key-fun)) |""".stripMargin - executeQueryWith$Result(connection, expectedQuery, true, None, actual) match { + executeQueryWith$Result(connection, expectedQuery, true, None, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) case Right(Result(Left(queryError), errCompilationTime, errExecutionTime)) => - ErrorResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, new IllegalStateException(s"Error whilst comparing permutation: ${queryError.errorCode}: ${queryError.message}")) + FailureResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, s"assert-permutation raised ${queryError.errorCode}: ${queryError.message}") case Right(Result(Right(actualQueryResult), resCompilationTime, resExecutionTime)) => val totalCompilationTime = compilationTime + resCompilationTime @@ -944,8 +1264,39 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def assertSerializationError(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expected: String, actual: ExistServer.QueryResult): TestResult = { - executeQueryWith$Result(connection, QUERY_ASSERT_XML_SERIALIZATION, true, None, actual) match { + private def assertSerializationError(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, serializationProperties: Properties = new Properties(), assertionBaseUri: Option[String] = None)(expected: String, actual: ExistServer.QueryResult): TestResult = { + // Build serialization query that uses the query's own serialization options + val serializationQuery = if (serializationProperties.isEmpty || !serializationProperties.containsKey(OutputKeys.METHOD)) { + QUERY_ASSERT_XML_SERIALIZATION + } else { + // Build a map with all serialization properties from the query context + val mapEntries = new StringBuilder() + val propNames = serializationProperties.propertyNames() + while (propNames.hasMoreElements) { + val key = propNames.nextElement().asInstanceOf[String] + val value = serializationProperties.getProperty(key) + if (mapEntries.nonEmpty) mapEntries.append(", ") + // Boolean-valued properties need xs:boolean, not string + val booleanProps = Set("indent", "omit-xml-declaration", "include-content-type", + "escape-uri-attributes", "undeclare-prefixes", "byte-order-mark", "allow-duplicate-names") + if (booleanProps.contains(key) && (value == "yes" || value == "no")) { + mapEntries.append(s"'$key': ${value == "yes"}") + } else { + mapEntries.append(s"'$key': '${value.replace("'", "''")}'") + } + } + // Always include omit-xml-declaration unless already set + if (!serializationProperties.containsKey("omit-xml-declaration")) { + if (mapEntries.nonEmpty) mapEntries.append(", ") + mapEntries.append("'omit-xml-declaration': true()") + } + s""" + |declare variable $$result external; + | + |fn:serialize($$result, map { $mapEntries }) + |""".stripMargin + } + executeQueryWith$Result(connection, serializationQuery, true, None, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) @@ -1031,10 +1382,10 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def assertStringValue(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expected: String, normalizeSpace: Boolean, actual: ExistServer.QueryResult): TestResult = { + private def assertStringValue(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, assertionBaseUri: Option[String] = None)(expected: String, normalizeSpace: Boolean, actual: ExistServer.QueryResult): TestResult = { if (normalizeSpace) { // normalize the expected - executeQueryWith$Result(connection, QUERY_NORMALIZED_SPACE, true, None, new StringValue(expected)) match { + executeQueryWith$Result(connection, QUERY_NORMALIZED_SPACE, true, None, new StringValue(expected), assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) @@ -1043,7 +1394,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac case Right(Result(Right(expectedQueryResult), expectedQueryCompilationTime, expectedQueryExecutionTime)) => // get the actual string value and normalize - executeQueryWith$Result(connection, QUERY_ASSERT_STRING_VALUE_NORMALIZED_SPACE, true, None, actual) match { + executeQueryWith$Result(connection, QUERY_ASSERT_STRING_VALUE_NORMALIZED_SPACE, true, None, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + expectedQueryCompilationTime + existServerException.compilationTime, executionTime + expectedQueryExecutionTime + existServerException.executionTime, existServerException) @@ -1067,7 +1418,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac } else { // get the actual string value - executeQueryWith$Result(connection, QUERY_ASSERT_STRING_VALUE, true, None, actual) match { + executeQueryWith$Result(connection, QUERY_ASSERT_STRING_VALUE, true, None, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) @@ -1179,14 +1530,18 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def assertXml(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expectedXml: Either[String, Path], @unused ignorePrefixes: Boolean, actual: ExistServer.QueryResult): TestResult = { + private def assertXml(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, assertionBaseUri: Option[String] = None)(expectedXml: Either[String, Path], @unused ignorePrefixes: Boolean, normalizeWhitespace: Boolean, actual: ExistServer.QueryResult): TestResult = { expectedXml.map(readTextFile(_)).fold(Right(_), r => r) match { case Left(t) => ErrorResult(testSetName, testCaseName, compilationTime, executionTime, t) case Right(expectedXmlStr) => + // Trim leading/trailing whitespace from expected XML — test catalog CDATA often + // has formatting newlines (e.g., after before ]]>) that would become + // spurious text nodes inside the ignorable-wrapper, causing child count mismatches. + val trimmedExpectedXmlStr = expectedXmlStr.trim - SAXParser.parseXml(s"<$IGNORABLE_WRAPPER_ELEM_NAME>$expectedXmlStr".getBytes(UTF_8)) match { + SAXParser.parseXml(s"<$IGNORABLE_WRAPPER_ELEM_NAME>$trimmedExpectedXmlStr".getBytes(UTF_8)) match { case Left(e: ExistServerException) => ErrorResult(testSetName, testCaseName, compilationTime, executionTime, e) @@ -1218,7 +1573,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac /* Next we have to serialize the actual xml in the same way as the expectedXml */ - executeQueryWith$Result(connection, QUERY_ASSERT_XML_SERIALIZATION, true, None, actual) match { + executeQueryWith$Result(connection, QUERY_ASSERT_XML_SERIALIZATION, true, None, actual, assertionBaseUri) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + expectedQueryCompilationTime + existServerException.compilationTime, executionTime + expectedQueryExecutionTime + existServerException.executionTime, existServerException) @@ -1244,7 +1599,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac case current@Right(results) => val strExpectedResult = expectedQueryResult.itemAt(itemIdx).asInstanceOf[StringValue].getStringValue - val differences = findDifferences(strExpectedResult, strActualResult) + val differences = findDifferences(strExpectedResult, strActualResult, normalizeWhitespace) differences match { // if we have an error don't process anything else, just perpetuate the error case Left(diffError) => @@ -1292,25 +1647,30 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual result from executing the XQuery. * @return the test result from processing the assertion. */ - private def serializationMatches(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime)(expected: Either[String, Path], flags: Option[String], actual: ExistServer.QueryResult): TestResult = { + private def serializationMatches(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, serializationProperties: Properties = new Properties(), assertionBaseUri: Option[String] = None)(expected: Either[String, Path], flags: Option[String], actual: ExistServer.QueryResult): TestResult = { expected.map(readTextFile(_)).fold(Right(_), r => r) match { case Left(t) => ErrorResult(testSetName, testCaseName, compilationTime, executionTime, t) case Right(expectedRegexStr) => + // Pass regex and flags as external variables to avoid eXist parser issues + // with special characters in backtick string constructors (e.g., new StringValue(actualStr), "regex" -> new StringValue(expectedRegexStr), "flags" -> new StringValue(flags.getOrElse("")))) match { case Left(existServerException) => ErrorResult(testSetName, testCaseName, compilationTime + existServerException.compilationTime, executionTime + existServerException.executionTime, existServerException) case Right(Result(Left(queryError), errCompilationTime, errExecutionTime)) => - ErrorResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, new IllegalStateException(s"Error whilst comparing serialization: ${queryError.errorCode}: ${queryError.message}")) + FailureResult(testSetName, testCaseName, compilationTime + errCompilationTime, executionTime + errExecutionTime, s"assert-serialization raised ${queryError.errorCode}: ${queryError.message}") case Right(Result(Right(actualQueryResult), resCompilationTime, resExecutionTime)) => val totalCompilationTime = compilationTime + resCompilationTime @@ -1333,12 +1693,25 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param actual the actual XML document. * @return Some string describing the differences, or None of there are no differences. */ - private def findDifferences(expected: String, actual: String): Either[XMLUnitException, Option[String]] = { + private def findDifferences(expected: String, actual: String, normalizeWs: Boolean = false): Either[XMLUnitException, Option[String]] = { try { val expectedSource = Input.fromString(s"<$IGNORABLE_WRAPPER_ELEM_NAME>$expected").build() val actualSource = Input.fromString(s"<$IGNORABLE_WRAPPER_ELEM_NAME>$actual").build() - val diff = DiffBuilder.compare(actualSource) - .withTest(expectedSource) + val builder = DiffBuilder.compare(expectedSource) + .withTest(actualSource) + val builderWithWs = if (normalizeWs) builder.normalizeWhitespace() else builder + // Only filter out whitespace-only text nodes when normalizeWs is enabled + // (XQFTTS Fragment comparisons). For QT3/QT4 assertXml, whitespace-only + // text nodes may be significant and must not be silently dropped. + val builderWithFilter = if (normalizeWs) { + builderWithWs.withNodeFilter(new org.xmlunit.util.Predicate[org.w3c.dom.Node] { + override def test(node: org.w3c.dom.Node): Boolean = + !(node.getNodeType == org.w3c.dom.Node.TEXT_NODE && node.getTextContent.trim.isEmpty) + }) + } else { + builderWithWs + } + val diff = builderWithFilter .checkForIdentical() .withComparisonFormatter(ignorableWrapperComparisonFormatter) .checkForSimilar() @@ -1414,8 +1787,8 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @param $result the sequence to be bound to the
$result
variable. * @return the result or executing the query, or an exception. */ - private def executeQueryWith$Result(connection: ExistConnection, query: String, cacheCompiled: Boolean, contextSequence: Option[Sequence], $result: Sequence) = { - connection.executeQuery(query, cacheCompiled, None, contextSequence, Seq.empty, Seq.empty, Seq.empty, Seq.empty, Seq(RESULT_VARIABLE_NAME -> $result)) + private def executeQueryWith$Result(connection: ExistConnection, query: String, cacheCompiled: Boolean, contextSequence: Option[Sequence], $result: Sequence, staticBaseUri: Option[String] = None) = { + connection.executeQuery(query, cacheCompiled, staticBaseUri, contextSequence, Seq.empty, Seq.empty, Seq.empty, Seq.empty, Seq(RESULT_VARIABLE_NAME -> $result)) } /** diff --git a/src/main/scala/org/exist/xqts/runner/XQTSParserActor.scala b/src/main/scala/org/exist/xqts/runner/XQTSParserActor.scala index f7897a9..7aafda1 100644 --- a/src/main/scala/org/exist/xqts/runner/XQTSParserActor.scala +++ b/src/main/scala/org/exist/xqts/runner/XQTSParserActor.scala @@ -77,11 +77,13 @@ object XQTSParserActor { case class ExternalVariableRole(name: String) extends Role - case class Environment(name: String, schemas: List[Schema] = List.empty, sources: List[Source] = List.empty, resources: List[Resource] = List.empty, params: List[Param] = List.empty, contextItem: Option[String] = None, decimalFormats: List[DecimalFormat] = List.empty, namespaces: List[Namespace] = List.empty, collections: List[Collection] = List.empty, staticBaseUri: Option[String] = None, collation: Option[Collation] = None) + case class Sandpit(path: Path) + + case class Environment(name: String, schemas: List[Schema] = List.empty, sources: List[Source] = List.empty, resources: List[Resource] = List.empty, params: List[Param] = List.empty, contextItem: Option[String] = None, decimalFormats: List[DecimalFormat] = List.empty, namespaces: List[Namespace] = List.empty, collections: List[Collection] = List.empty, staticBaseUri: Option[String] = None, collation: Option[Collation] = None, sandpit: Option[Sandpit] = None) case class Schema(uri: Option[AnyURIValue], file: Option[Path], xsdVersion: Float = 1.0f, description: Option[String] = None, created: Option[Created] = None, modifications: List[Modified] = List.empty) - case class Source(role: Option[Role], file: Path, uri: Option[String], validation: Option[Validation.Validation] = None, description: Option[String] = None, created: Option[Created] = None, modifications: List[Modified] = List.empty) + case class Source(role: Option[Role], file: Path, uri: Option[String], validation: Option[Validation.Validation] = None, mutable: Boolean = false, declared: Boolean = false, description: Option[String] = None, created: Option[Created] = None, modifications: List[Modified] = List.empty) case class Resource(file: Path, uri: String, mediaType: Option[String] = None, encoding: Option[String], description: Option[String] = None, created: Option[Created] = None, modifications: List[Modified] = List.empty) @@ -113,7 +115,7 @@ object XQTSParserActor { case class Dependency(`type`: DependencyType, value: String, satisfied: Boolean) - case class TestCase(file: Path, name: TestCaseName, covers: String, description: Option[String] = None, created: Option[Created] = None, modifications: Seq[Modified] = Seq.empty, environment: Option[Environment] = None, modules: Seq[Module] = Seq.empty, dependencies: Seq[Dependency] = Seq.empty, test: Option[Either[String, Path]] = None, result: Option[Result] = None) + case class TestCase(file: Path, name: TestCaseName, covers: String, description: Option[String] = None, created: Option[Created] = None, modifications: Seq[Modified] = Seq.empty, environment: Option[Environment] = None, modules: Seq[Module] = Seq.empty, dependencies: Seq[Dependency] = Seq.empty, test: Option[Either[String, Path]] = None, updateTests: Seq[Either[String, Path]] = Seq.empty, result: Option[Result] = None) sealed trait Result @@ -158,7 +160,7 @@ object XQTSParserActor { case class AssertType(expected: String) extends ValueAssertion[String] - case class AssertXml(expected: Either[String, Path], ignorePrefixes: Boolean = false) extends ValueAssertion[Either[String, Path]] + case class AssertXml(expected: Either[String, Path], ignorePrefixes: Boolean = false, normalizeWhitespace: Boolean = false) extends ValueAssertion[Either[String, Path]] case class SerializationMatches(expected: Either[String, Path], flags: Option[String] = None) extends ValueAssertion[Either[String, Path]] @@ -168,6 +170,9 @@ object XQTSParserActor { case object AssertFalse extends Assertion + /** Assertion for "Inspect" comparisons that always passes (requires manual review). */ + case object AssertInspect extends Assertion + case class Error(expected: String) extends ValueAssertion[String] /** @@ -205,6 +210,8 @@ object XQTSParserActor { val UnicodeNormalizationForm = DependencyTypeVal("unicode-normalization-form") val XmlVersion = DependencyTypeVal("xml-version") val XsdVersion = DependencyTypeVal("xsd-version") + val Revalidation = DependencyTypeVal("revalidation") + val Put = DependencyTypeVal("put") } /** @@ -212,7 +219,7 @@ object XQTSParserActor { */ object Spec extends Enumeration { type Spec = Value - val XP10, XP20, XP30, XP31, XQ10, XQ30, XQ31, XT30 = Value + val XP10, XP20, XP30, XP31, XP40, XQ10, XQ30, XQ31, XQ40, XT30 = Value /** * Returns all specs which implement at @@ -224,20 +231,24 @@ object XQTSParserActor { def atLeast(spec: Spec): Set[Spec] = { spec match { case XP10 => - Set(XP10, XP20, XP30, XP31) + Set(XP10, XP20, XP30, XP31, XP40) case XP20 => - Set(XP20, XP30, XP31) + Set(XP20, XP30, XP31, XP40) case XP30 => - Set(XP30, XP31) + Set(XP30, XP31, XP40) case XP31 => - Set(XP31) + Set(XP31, XP40) + case XP40 => + Set(XP40) case XQ10 => - Set(XQ10, XQ30, XQ31) + Set(XQ10, XQ30, XQ31, XQ40) case XQ30 => - Set(XQ30, XQ31) + Set(XQ30, XQ31, XQ40) case XQ31 => - Set(XQ31) + Set(XQ31, XQ40) + case XQ40 => + Set(XQ40) case XT30 => Set(XT30) @@ -282,6 +293,7 @@ object XQTSParserActor { val XPath_1_0_Compatibility = FeatureVal("xpath-1.0-compatibility") val TransformXSLT = FeatureVal("fn-transform-XSLT") val TransformXSLT_30 = FeatureVal("fn-transform-XSLT30") + val XQUpdate = FeatureVal("XQUpdate") } /** diff --git a/src/main/scala/org/exist/xqts/runner/XQTSRunner.scala b/src/main/scala/org/exist/xqts/runner/XQTSRunner.scala index c9d411d..6a66df4 100644 --- a/src/main/scala/org/exist/xqts/runner/XQTSRunner.scala +++ b/src/main/scala/org/exist/xqts/runner/XQTSRunner.scala @@ -91,7 +91,8 @@ object XQTSRunner { TypedData, XPath_1_0_Compatibility, TransformXSLT, - TransformXSLT_30 + TransformXSLT_30, + XQUpdate ) /** @@ -102,9 +103,11 @@ object XQTSRunner { XP20, XP30, XP31, + XP40, XQ10, XQ30, XQ31, + XQ40, XT30 ) @@ -154,7 +157,7 @@ object XQTSRunner { opt[XQTSVersion]('x', "xqts-version") .text("The version of XQTS to run. These are configured in the application.conf file. We ship with 'W3C' (the final W3C XQTS 3.1) and 'HEAD' (the GitHub community maintained XQTS) pre-configured") - .validate(x => if (x == XQTS_3_1 || x == XQTS_HEAD) success else failure("only version 3.1, or HEAD is currently supported")) + .validate(x => if (x == XQTS_3_1 || x == XQTS_HEAD || x == XQTS_QT4 || x == XQTS_FTTS_1_0) success else failure("only version 3.1, HEAD, QT4, or FTTS is currently supported")) .action((x, c) => c.copy(xqtsVersion = x)) opt[Path]('l', "local-dir") @@ -361,9 +364,11 @@ private class XQTSRunner { @throws[IllegalArgumentException] private def getParserActorClass(xqtsVersion: XQTSVersion): Class[_ <: XQTSParserActor] = { xqtsVersion match { - case XQTS_3_1 | XQTS_HEAD => + case XQTS_3_1 | XQTS_HEAD | XQTS_QT4 => classOf[XQTS3CatalogParserActor] - case _ => throw new IllegalArgumentException(s"We only support XQTS version 3.1 or HEAD, but version: ${XQTSVersion.label(xqtsVersion)} was requested") + case XQTS_FTTS_1_0 => + classOf[xqftts.XQFTTSCatalogParserActor] + case _ => throw new IllegalArgumentException(s"We only support XQTS version 3.1, HEAD, QT4, or FTTS, but version: ${XQTSVersion.label(xqtsVersion)} was requested") } } diff --git a/src/main/scala/org/exist/xqts/runner/XQTSRunnerActor.scala b/src/main/scala/org/exist/xqts/runner/XQTSRunnerActor.scala index 5b75a34..1c555f6 100644 --- a/src/main/scala/org/exist/xqts/runner/XQTSRunnerActor.scala +++ b/src/main/scala/org/exist/xqts/runner/XQTSRunnerActor.scala @@ -60,6 +60,10 @@ class XQTSRunnerActor(xmlParserBufferSize: Int, existServer: ExistServer, parser private case object TimerPrintStats + private case object TimerWatchdogKey + + private case object TimerWatchdogCheck + private case class Stats(unparsedTestSets: Int, testCases: (Int, Int), completedTestCases: (Int, Int), unserializedTestSets: Int) { def asMessage: String = s"XQTSRunnerActor Progress:\nunparsedTestSets=${unparsedTestSets}\ntestCases[sets/cases]=${testCases._1}/${testCases._2}\ncompletedTestCases[sets/cases]=${completedTestCases._1}/${completedTestCases._2}\nunserializedTestSets=${unserializedTestSets}" } @@ -67,16 +71,26 @@ class XQTSRunnerActor(xmlParserBufferSize: Int, existServer: ExistServer, parser private var previousStats: Stats = Stats(0, (0, 0), (0, 0), 0) private var unchangedStatsTicks = 0; + /** Number of consecutive watchdog ticks with no progress before forcing shutdown. 10s tick x 6 = 60s stall timeout. */ + private val STALL_TIMEOUT_TICKS = 6 + private var watchdogPreviousCompletedCount = 0 + private var watchdogStalledTicks = 0 + private var startedTestCases: Map[TestSetRef, Set[String]] = Map.empty + override def receive: Receive = { case RunXQTS(xqtsVersion, xqtsPath, features, specs, xmlVersions, xsdVersions, maxCacheBytes, testSets, testCases, excludeTestSets, excludeTestCases) => started = System.currentTimeMillis() logger.info(s"Running XQTS: ${XQTSVersion.label(xqtsVersion)}") - if (logger.isDebugEnabled()) { - // prints stats about the state of this actor (i.e. test set progress) + { import scala.concurrent.duration._ - timers.startTimerAtFixedRate(TimerStatsKey, TimerPrintStats, 5.seconds) + // watchdog: detect stalls where no test cases complete for 120 seconds + timers.startTimerAtFixedRate(TimerWatchdogKey, TimerWatchdogCheck, 10.seconds) + if (logger.isDebugEnabled()) { + // prints stats about the state of this actor (i.e. test set progress) + timers.startTimerAtFixedRate(TimerStatsKey, TimerPrintStats, 5.seconds) + } } val readFileRouter = context.actorOf(FromConfig.props(Props(classOf[ReadFileActor])), name = "ReadFileRouter") @@ -84,8 +98,14 @@ class XQTSRunnerActor(xmlParserBufferSize: Int, existServer: ExistServer, parser val testCaseRunnerRouter = context.actorOf(FromConfig.props(Props(classOf[TestCaseRunnerActor], existServer, commonResourceCacheActor)), name = "TestCaseRunnerRouter") - val testSetParserRouter = context.actorOf(FromConfig.props(Props(classOf[XQTS3TestSetParserActor], xmlParserBufferSize, testCaseRunnerRouter)), "XQTS3TestSetParserRouter") - val parserActor = context.actorOf(Props(parserActorClass, xmlParserBufferSize, testSetParserRouter), parserActorClass.getSimpleName) + // For XQFTTS, the catalog parser sends directly to the test case runner (no test-set parser needed). + // For QT3/QT4, the catalog parser sends to a test-set parser pool which then sends to test case runners. + val parserActor = if (xqtsVersion == XQTS_FTTS_1_0) { + context.actorOf(Props(parserActorClass, xmlParserBufferSize, testCaseRunnerRouter, existServer), parserActorClass.getSimpleName) + } else { + val testSetParserRouter = context.actorOf(FromConfig.props(Props(classOf[XQTS3TestSetParserActor], xmlParserBufferSize, testCaseRunnerRouter)), "XQTS3TestSetParserRouter") + context.actorOf(Props(parserActorClass, xmlParserBufferSize, testSetParserRouter), parserActorClass.getSimpleName) + } parserActor ! Parse(xqtsVersion, xqtsPath, features, specs, xmlVersions, xsdVersions, testSets, testCases, excludeTestSets, excludeTestCases) @@ -110,6 +130,31 @@ class XQTSRunnerActor(xmlParserBufferSize: Int, existServer: ExistServer, parser } previousStats = stats + case TimerWatchdogCheck => + val currentCompletedCount = this.completedTestCases.values.foldLeft(0)(_ + _.size) + if (currentCompletedCount > watchdogPreviousCompletedCount) { + watchdogStalledTicks = 0 + } else if (this.testCases.nonEmpty) { + // only count stall ticks after we've started receiving test cases + watchdogStalledTicks += 1 + } + watchdogPreviousCompletedCount = currentCompletedCount + + if (watchdogStalledTicks >= STALL_TIMEOUT_TICKS) { + val totalCases = this.testCases.values.foldLeft(0)(_ + _.size) + // Identify which test cases started but never completed (hung tests) + val hungTests = for { + (testSetRef, started) <- startedTestCases + completed = completedTestCases.getOrElse(testSetRef, Map.empty).keySet + testCase <- started -- completed + } yield s"${testSetRef.name}/$testCase" + logger.warn(s"Watchdog: no progress for ${STALL_TIMEOUT_TICKS * 10}s ($currentCompletedCount/$totalCases cases completed, ${unserializedTestSets.size} unserialized). Forcing shutdown.") + if (hungTests.nonEmpty) { + logger.warn(s"Hung test cases (started but never completed): ${hungTests.mkString(", ")}") + } + forceSerializeAndShutdown() + } + case ParseComplete(xqtsVersion, _, matchedTestSets) => logger.info(s"Matched $matchedTestSets Test Sets in XQTS ${XQTSVersion.toVersionName(xqtsVersion)}...") if (matchedTestSets == 0) { @@ -125,7 +170,7 @@ class XQTSRunnerActor(xmlParserBufferSize: Int, existServer: ExistServer, parser unparsedTestSets -= testSetRef // have we completed testing an entire TestSet? NOTE: tests could have finished executing before parse complete message arrives! - if (isTestSetCompleted(testSetRef)) { + if (!unserializedTestSets.contains(testSetRef) && isTestSetCompleted(testSetRef)) { // serialize the TestSet results resultsSerializerRouter ! TestSetResults(testSetRef, completedTestCases(testSetRef).values.toSeq) unserializedTestSets += testSetRef @@ -134,16 +179,24 @@ class XQTSRunnerActor(xmlParserBufferSize: Int, existServer: ExistServer, parser case RunningTestCase(testSetRef, testCase) => logger.info(s"Starting execution of Test Case: ${testSetRef.name}/${testCase}...") testCases = addTestCase(testCases, testSetRef, testCase) + startedTestCases = addTestCase(startedTestCases, testSetRef, testCase) case RanTestCase(testSetRef, testResult) => logger.info(s"Finished execution of Test Case: ${testSetRef.name}/${testResult.testCase}.") completedTestCases = mergeTestCases(completedTestCases, testSetRef, testResult) // have we completed testing an entire TestSet? - if (isTestSetCompleted(testSetRef)) { + if (!unserializedTestSets.contains(testSetRef) && isTestSetCompleted(testSetRef)) { // serialize the TestSet results resultsSerializerRouter ! TestSetResults(testSetRef, completedTestCases(testSetRef).values.toSeq) unserializedTestSets += testSetRef + } else if (!unserializedTestSets.contains(testSetRef) && isTestSetCompletedByStarted(testSetRef)) { + // All started test cases completed, but ParsedTestSet hasn't been processed + // yet (still in unparsedTestSets). This happens when BrokerPool threads block + // the Pekko dispatcher, preventing the ParsedTestSet message from being delivered. + logger.info(s"Test set ${testSetRef.name} completed (all started cases finished, ParsedTestSet pending). Serializing results.") + resultsSerializerRouter ! TestSetResults(testSetRef, completedTestCases(testSetRef).values.toSeq) + unserializedTestSets += testSetRef } case SerializedTestSetResults(testSetRef) => @@ -159,25 +212,62 @@ class XQTSRunnerActor(xmlParserBufferSize: Int, existServer: ExistServer, parser shutdown() } + private def forceSerializeAndShutdown(): Unit = { + // Serialize any completed but unsent test sets before shutting down + for { + (testSetRef, _) <- this.testCases + if !unserializedTestSets.contains(testSetRef) + } { + completedTestCases.get(testSetRef).foreach { results => + resultsSerializerRouter ! TestSetResults(testSetRef, results.values.toSeq) + } + } + shutdown() + } + private def shutdown(): Unit = { + timers.cancel(TimerWatchdogKey) if (logger.isDebugEnabled()) { timers.cancel(TimerStatsKey) } + // Hard deadline: force exit if actor system termination hangs. + // BrokerPool threads can block the Pekko dispatcher, preventing + // CoordinatedShutdown from completing. This standalone thread + // runs outside Pekko and forces JVM exit after 30 seconds. + logger.info("Starting 30-second shutdown deadline thread") + val deadline = new Thread(() => { + try { + Thread.sleep(30000) + logger.warn("Actor system shutdown did not complete within 30 seconds, forcing exit") + Runtime.getRuntime.halt(0) + } catch { + case _: InterruptedException => + logger.info("Shutdown deadline thread interrupted (clean exit)") + } + }, "xqts-shutdown-deadline") + deadline.setDaemon(true) + deadline.start() context.stop(self) context.system.terminate() } private def isTestSetCompleted(testSetRef: TestSetRef): Boolean = { unparsedTestSets.contains(testSetRef) == false && - completedTestCases.get(testSetRef).map(_.keySet) - .flatMap(completed => testCases.get(testSetRef).map(_ == completed)) - .getOrElse(false) + isTestSetCompletedByStarted(testSetRef) + } + + /** Check if all STARTED test cases have completed, ignoring ParsedTestSet status. */ + private def isTestSetCompletedByStarted(testSetRef: TestSetRef): Boolean = { + completedTestCases.get(testSetRef).map(_.keySet) + .flatMap(completed => startedTestCases.get(testSetRef).map(started => started.nonEmpty && started == completed)) + .getOrElse(false) } private def allTestSetsCompleted(): Boolean = { - unserializedTestSets.isEmpty && - unparsedTestSets.isEmpty && - !testCases.keySet.map(isTestSetCompleted(_)).contains(false) + unserializedTestSets.isEmpty && { + val testSetRefs = if (startedTestCases.nonEmpty) startedTestCases.keySet else testCases.keySet + testSetRefs.forall(ref => isTestSetCompleted(ref) || isTestSetCompletedByStarted(ref)) + } } @unused diff --git a/src/main/scala/org/exist/xqts/runner/package.scala b/src/main/scala/org/exist/xqts/runner/package.scala index f7e3954..fb2419e 100644 --- a/src/main/scala/org/exist/xqts/runner/package.scala +++ b/src/main/scala/org/exist/xqts/runner/package.scala @@ -34,6 +34,8 @@ package object runner { object XQTS_3_1 extends XQTSVersion object XQTS_HEAD extends XQTSVersion + object XQTS_QT4 extends XQTSVersion + object XQTS_FTTS_1_0 extends XQTSVersion object XQTSVersion { @@ -50,6 +52,8 @@ package object runner { case "3.0" | "3" => XQTS_3_0 case "3.1" | "31" => XQTS_3_1 case "head" | "HEAD" => XQTS_HEAD + case "qt4" | "QT4" => XQTS_QT4 + case "ftts" | "FTTS" | "XQFTTS" | "xqftts" => XQTS_FTTS_1_0 case _ => throw new IllegalArgumentException(s"No such XQTS version: $s") } } @@ -67,6 +71,8 @@ package object runner { case 3 => XQTS_3_0 case 31 => XQTS_3_1 case -1 => XQTS_HEAD + case -2 => XQTS_QT4 + case -3 => XQTS_FTTS_1_0 case _ => throw new IllegalArgumentException(s"No such XQTS version: $i") } } @@ -84,6 +90,8 @@ package object runner { case 3 | 3.0f => XQTS_3_0 case 3.1f => XQTS_3_1 case -1 => XQTS_HEAD + case -2 => XQTS_QT4 + case -3 => XQTS_FTTS_1_0 case _ => throw new IllegalArgumentException(s"No such XQTS version: $f") } } @@ -104,6 +112,10 @@ package object runner { "XQTS_3_1" case XQTS_HEAD => "XQTS_HEAD" + case XQTS_QT4 => + "XQTS_QT4" + case XQTS_FTTS_1_0 => + "XQTS_FTTS_1_0" } } @@ -123,6 +135,10 @@ package object runner { "3.1" case XQTS_HEAD => "HEAD" + case XQTS_QT4 => + "QT4" + case XQTS_FTTS_1_0 => + "FTTS" } } } diff --git a/src/main/scala/org/exist/xqts/runner/qt3/XQTS3TestSetParserActor.scala b/src/main/scala/org/exist/xqts/runner/qt3/XQTS3TestSetParserActor.scala index 44da0a5..8974f11 100644 --- a/src/main/scala/org/exist/xqts/runner/qt3/XQTS3TestSetParserActor.scala +++ b/src/main/scala/org/exist/xqts/runner/qt3/XQTS3TestSetParserActor.scala @@ -82,6 +82,7 @@ class XQTS3TestSetParserActor(xmlParserBufferSize: Int, testCaseRunnerActor: Act private var currentNormalizeSpace: Option[Boolean] = None private var currentFile: Option[Path] = None + private var currentTestIsUpdate: Boolean = false private var currentFlags: Option[String] = None private var currentIgnorePrefixes: Option[Boolean] = None @@ -289,7 +290,9 @@ class XQTS3TestSetParserActor(xmlParserBufferSize: Int, testCaseRunnerActor: Act val file = asyncReader.getAttributeValue(ATTR_FILE) val validation = asyncReader.getAttributeValueOptNE(ATTR_VALIDATION) val uri = asyncReader.getAttributeValueOptNE(ATTR_URI) - currentSource = Some(Source(role, testSetDir.resolve(file), uri, validation.map(Validation.withName))) + val mutable = asyncReader.getAttributeValueOptNE("mutable").exists(_.equalsIgnoreCase("true")) + val declared = asyncReader.getAttributeValueOptNE("declared").exists(_.equalsIgnoreCase("true")) + currentSource = Some(Source(role, testSetDir.resolve(file), uri, validation.map(Validation.withName), mutable, declared)) case END_ELEMENT if (asyncReader.getLocalName == ELEM_SOURCE && currentEnv.nonEmpty && currentCollection.nonEmpty) => currentCollection = currentSource.map(source => currentCollection.map(collection => collection.copy(sources = source +: collection.sources))) @@ -359,6 +362,11 @@ class XQTS3TestSetParserActor(xmlParserBufferSize: Int, testCaseRunnerActor: Act val uri = asyncReader.getAttributeValueOptNE(ATTR_URI) currentEnv = currentEnv.map(_.copy(staticBaseUri = uri)) + case START_ELEMENT if (asyncReader.getLocalName == ELEM_SANDPIT && currentEnv.nonEmpty) => + val path = asyncReader.getAttributeValue(ATTR_PATH) + val resolvedPath = testSetDir.resolve(path) + currentEnv = currentEnv.map(_.copy(sandpit = Some(Sandpit(resolvedPath)))) + case START_ELEMENT if (asyncReader.getLocalName == ELEM_COLLATION && currentEnv.nonEmpty) => val uri = asyncReader.getAttributeValueOptNE(ATTR_URI).map(new URI(_)) val default = asyncReader.getAttributeValueOptNE(ATTR_DEFAULT).map(_.toBoolean).getOrElse(false) @@ -401,13 +409,20 @@ class XQTS3TestSetParserActor(xmlParserBufferSize: Int, testCaseRunnerActor: Act case START_ELEMENT if (currentTestCase.nonEmpty && asyncReader.getLocalName == ELEM_TEST) => val attrFile = asyncReader.getAttributeValueOpt(ATTR_FILE) currentFile = attrFile.map(file => testSetRef.file.resolveSibling(file)) + currentTestIsUpdate = asyncReader.getAttributeValueOptNE("update").exists(_.equalsIgnoreCase("true")) captureText = true case END_ELEMENT if (currentTestCase.nonEmpty && asyncReader.getLocalName == ELEM_TEST) => - currentTestCase = currentTestCase.map(testCase => testCase.copy(test = currentText.flatMap(text => Some(Left(text))).orElse(currentFile.map(Right(_))))) + val testValue = currentText.flatMap(text => Some(Left(text))).orElse(currentFile.map(Right(_))) + if (currentTestIsUpdate) { + currentTestCase = currentTestCase.map(testCase => testCase.copy(updateTests = testCase.updateTests ++ testValue.toSeq)) + } else { + currentTestCase = currentTestCase.map(testCase => testCase.copy(test = testValue)) + } currentFile = None currentText = None captureText = false + currentTestIsUpdate = false case START_ELEMENT if (currentTestCase.nonEmpty && asyncReader.getLocalName == ELEM_RESULT) => currentResult = Some(Stack.empty) @@ -565,10 +580,7 @@ class XQTS3TestSetParserActor(xmlParserBufferSize: Int, testCaseRunnerActor: Act case END_ELEMENT if (asyncReader.getLocalName == ELEM_ASSERT_TRUE) => currentResult = currentResult.map(addAssertion(_)(AssertTrue)) - case END_ELEMENT if (asyncReader.getLocalName == ELEM_ALL_OF || asyncReader.getLocalName == ELEM_ANY_OF) => - currentResult = currentResult.map(stepOutAssertions) - - case END_ELEMENT if (asyncReader.getLocalName == ELEM_ALL_OF || asyncReader.getLocalName == ELEM_NOT) => + case END_ELEMENT if (asyncReader.getLocalName == ELEM_ALL_OF || asyncReader.getLocalName == ELEM_ANY_OF || asyncReader.getLocalName == ELEM_NOT) => currentResult = currentResult.map(stepOutAssertions) case START_ELEMENT if (currentResult.nonEmpty && asyncReader.getLocalName == ELEM_ERROR) => @@ -649,9 +661,21 @@ class XQTS3TestSetParserActor(xmlParserBufferSize: Int, testCaseRunnerActor: Act def addAssertion(currentAssertions: Stack[Result])(assertion: Result): Stack[Result] = { currentAssertions.peekOption match { - case Some(head) if (head.isInstanceOf[Assertions] && !assertion.isInstanceOf[Assertions]) => + case Some(head: Assertions) if (!assertion.isInstanceOf[Assertions]) => // head of the stack is itself a list of assertions, and the assertion to add is not a list of assertions - currentAssertions.replace(head.asInstanceOf[Assertions] :+ assertion) + // Check if the last element in the list is a Not(None) that needs filling + head.assertions.lastOption match { + case Some(Not(None)) => + // Fill the empty Not with this assertion + val updatedAssertions = head.assertions.init :+ Not(Some(assertion)) + val updatedHead = head match { + case AllOf(_) => AllOf(updatedAssertions) + case AnyOf(_) => AnyOf(updatedAssertions) + } + currentAssertions.replace(updatedHead) + case _ => + currentAssertions.replace(head :+ assertion) + } case Some(Not(None)) => // head of the stack is a Not assertion which is empty, so wrap this assertion in the Not assertion @@ -667,7 +691,8 @@ class XQTS3TestSetParserActor(xmlParserBufferSize: Int, testCaseRunnerActor: Act def stepOutAssertions(currentAssertions: Stack[Result]): Stack[Result] = { if (currentAssertions.size >= 2) { - if (currentAssertions.peek.isInstanceOf[Assertions]) { + val top = currentAssertions.peek + if (top.isInstanceOf[Assertions] || top.isInstanceOf[Not]) { val (prevHead, stack) = currentAssertions.pop() val head = stack.peek if (head.isInstanceOf[Assertions]) { diff --git a/src/main/scala/org/exist/xqts/runner/qt3/package.scala b/src/main/scala/org/exist/xqts/runner/qt3/package.scala index 7e3d903..5135691 100644 --- a/src/main/scala/org/exist/xqts/runner/qt3/package.scala +++ b/src/main/scala/org/exist/xqts/runner/qt3/package.scala @@ -44,6 +44,7 @@ package object qt3 { val ELEM_COLLECTION = "collection" val ELEM_STATIC_BASE_URI = "static-base-uri" val ELEM_COLLATION = "collation" + val ELEM_SANDPIT = "sandpit" val ELEM_TEST_SET = "test-set" val ELEM_LINK = "link" val ELEM_TEST_CASE = "test-case" @@ -106,6 +107,7 @@ package object qt3 { val ATTR_INFINITY = "infinity" val ATTR_NAN = "NaN" val ATTR_DEFAULT = "default" + val ATTR_PATH = "path" private[qt3] val PARSER_FACTORY = new InputFactoryImpl diff --git a/src/main/scala/org/exist/xqts/runner/xqftts/XQFTTSCatalogParserActor.scala b/src/main/scala/org/exist/xqts/runner/xqftts/XQFTTSCatalogParserActor.scala new file mode 100644 index 0000000..a44c98d --- /dev/null +++ b/src/main/scala/org/exist/xqts/runner/xqftts/XQFTTSCatalogParserActor.scala @@ -0,0 +1,441 @@ +/* + * Copyright (C) 2018 The eXist Project + * + * This program 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 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Lesser Public License + * along with this program. If not, see . + */ + +package org.exist.xqts.runner.xqftts + +import java.nio.ByteBuffer +import java.nio.channels.SeekableByteChannel +import java.nio.file.{Files, Path} +import java.util.regex.Pattern +import org.apache.pekko.actor.ActorRef +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import com.fasterxml.aalto.AsyncXMLStreamReader.EVENT_INCOMPLETE +import com.fasterxml.aalto.{AsyncByteBufferFeeder, AsyncXMLStreamReader} + +import javax.xml.stream.XMLStreamConstants.{CHARACTERS, END_DOCUMENT, END_ELEMENT, START_ELEMENT} +import org.exist.xqts.runner._ +import org.exist.xqts.runner.XQTSParserActor._ +import org.exist.xqts.runner.TestCaseRunnerActor.RunTestCase +import org.exist.xqts.runner.XQTSRunnerActor.{ParsedTestSet, ParsingTestSet, RunningTestCase} +import org.exist.xqts.runner.xqftts._ + +import scala.annotation.tailrec + +/** + * Parses an XQFTTS 1.0.4 XQFTTSCatalog.xml file. + * + * Unlike the QT3 parsers which split catalog and test-set parsing, + * this actor handles both since XQFTTS puts everything in one file. + * For each test-case parsed, an execution request will be sent + * to a TestCaseRunnerActor. + * + * @param xmlParserBufferSize the maximum buffer size for XML parsing. + * @param testCaseRunnerRouter a router to test case runner actors. + */ +class XQFTTSCatalogParserActor(xmlParserBufferSize: Int, testCaseRunnerRouter: ActorRef, existServer: ExistServer) extends XQTSParserActor { + + private val logger = Logger(classOf[XQFTTSCatalogParserActor]) + + // Source lookup: ID -> resolved file path + private var sources: Map[String, Path] = Map.empty + + // Stop word URI -> local file path mapping (populated from catalog elements) + private var stopWordURIMap: Map[String, Path] = Map.empty + + // Thesaurus URI -> local file path mapping (populated from catalog elements) + private var thesaurusURIMap: Map[String, Path] = Map.empty + + // Test group tracking + private var groupStack: List[String] = List.empty + private var currentFilePath: List[Option[String]] = List.empty // parallel stack for FilePath + + // Current test-case state + private var currentTestCaseName: Option[String] = None + private var currentTestCaseFilePath: Option[String] = None + private var currentTestCaseScenario: Option[String] = None + private var currentQueryName: Option[String] = None + private var currentInputFiles: List[(String, String)] = List.empty // (variable, sourceID) + private var currentContextItem: Option[String] = None // sourceID for context item + private var currentOutputFiles: List[(String, Path)] = List.empty // (compareType, resolvedPath) + private var currentExpectedErrors: List[String] = List.empty + + // Temporary state for text capture within input-file / output-file / expected-error + private var captureText = false + private var currentText: Option[String] = None + private var currentInputVariable: Option[String] = None + private var currentCompareType: Option[String] = None + + // Offset paths from the catalog root element + private var queryOffsetPath: String = XQUERY_QUERY_OFFSET + private var resultOffsetPath: String = RESULT_OFFSET + private var sourceOffsetPath: String = "./" + private var queryFileExtension: String = XQUERY_FILE_EXT + + // Test set tracking + private var testSetTestCases: Map[String, List[String]] = Map.empty + private var announcedTestSets: Set[String] = Set.empty + private var matchedTestSets: Int = 0 + private var uriMapsRegistered: Boolean = false + + override def receive: Receive = { + + case Parse(xqtsVersion, xqtsPath, features, specs, xmlVersions, xsdVersions, testSets, testCases, excludeTestSets, excludeTestCases) => + val sender = context.sender() + logger.info(s"Parsing XQFTTS Catalog: ${xqtsPath.resolve(CATALOG_FILE)}...") + val matched = parseCatalog(sender, xqtsVersion, xqtsPath, testSets, testCases, excludeTestSets, excludeTestCases) + // Ensure URI maps are registered (in case catalog had no test groups) + if (!uriMapsRegistered) { + registerURIMaps() + uriMapsRegistered = true + } + + logger.info(s"Parsed XQFTTS Catalog OK. Matched $matched test sets.") + sender ! ParseComplete(xqtsVersion, xqtsPath, matched) + context.stop(self) + } + + private def parseCatalog( + xqtsRunner: ActorRef, + xqtsVersion: XQTSVersion, + xqtsPath: Path, + testSets: Either[Set[String], Pattern], + testCases: Either[Set[String], Pattern], + excludeTestSets: Set[String], + excludeTestCases: Set[String] + ): Int = { + + def matchesTestSets(testSetName: String): Boolean = { + if (excludeTestSets.contains(testSetName)) return false + testSets match { + case Left(names) => names.isEmpty || names.contains(testSetName) + case Right(pattern) => pattern.matcher(testSetName).matches() + } + } + + def matchesTestCases(testCaseName: String): Boolean = { + if (excludeTestCases.contains(testCaseName)) return false + testCases match { + case Left(names) => names.isEmpty || names.contains(testCaseName) + case Right(pattern) => pattern.matcher(testCaseName).matches() + } + } + + val catalogPath = xqtsPath.resolve(CATALOG_FILE) + + val bufIO = cats.effect.Resource.make(IO { ByteBuffer.allocate(xmlParserBufferSize) })(buf => IO { buf.clear() }) + val fileIO = cats.effect.Resource.make(IO { Files.newByteChannel(catalogPath) })(channel => IO { channel.close() }) + val asyncParserIO = cats.effect.Resource.make(IO { PARSER_FACTORY.createAsyncForByteBuffer() })(asyncReader => IO { asyncReader.close() }) + val parseIO = bufIO.use(buf => + fileIO.use(channel => + asyncParserIO.use(asyncReader => + IO { + parseAll(asyncReader.next(), asyncReader, xqtsPath, channel, buf, xqtsRunner, matchesTestSets, matchesTestCases) + } + ) + ) + ) + parseIO.unsafeRunSync()(IORuntime.global) + + matchedTestSets + } + + @tailrec + @throws[XQTSParseException] + private def parseAll( + event: Int, + asyncReader: AsyncXMLStreamReader[AsyncByteBufferFeeder], + xqtsPath: Path, + channel: SeekableByteChannel, + buf: ByteBuffer, + xqtsRunner: ActorRef, + matchesTestSets: String => Boolean, + matchesTestCases: String => Boolean + ): Unit = { + event match { + + case END_DOCUMENT => + // Finalize any remaining open test sets + for ((tsName, tcNames) <- testSetTestCases) { + val testSetRef = TestSetRef(XQTS_FTTS_1_0, tsName, xqtsPath.resolve(CATALOG_FILE)) + xqtsRunner ! ParsedTestSet(testSetRef, tcNames) + } + return + + case START_ELEMENT if asyncReader.getLocalName == ELEM_TEST_SUITE => + // Read offset paths from catalog root attributes + asyncReader.getAttributeValueOpt(ATTR_XQUERY_QUERY_OFFSET_PATH).foreach(v => queryOffsetPath = v) + asyncReader.getAttributeValueOpt(ATTR_RESULT_OFFSET_PATH).foreach(v => resultOffsetPath = v) + asyncReader.getAttributeValueOpt(ATTR_SOURCE_OFFSET_PATH).foreach(v => sourceOffsetPath = v) + asyncReader.getAttributeValueOpt(ATTR_XQUERY_FILE_EXTENSION).foreach(v => queryFileExtension = v) + + case START_ELEMENT if asyncReader.getLocalName == ELEM_SOURCE && groupStack.isEmpty => + // Source definition in the section + val id = asyncReader.getAttributeValue(ATTR_ID) + val fileName = asyncReader.getAttributeValue(ATTR_FILE_NAME) + if (id != null && fileName != null) { + sources = sources + (id -> xqtsPath.resolve(fileName)) + } + + case START_ELEMENT if asyncReader.getLocalName == ELEM_STOPWORDS && groupStack.isEmpty => + // Stop word definition: maps URI to local file path + val uri = asyncReader.getAttributeValue(ATTR_URI) + val fileName = asyncReader.getAttributeValue(ATTR_FILE_NAME) + if (uri != null && fileName != null) { + val resolvedPath = xqtsPath.resolve(fileName) + stopWordURIMap = stopWordURIMap + (uri -> resolvedPath) + logger.debug(s"Registered stop word URI mapping: $uri -> $resolvedPath") + } + + case START_ELEMENT if asyncReader.getLocalName == ELEM_THESAURUS && groupStack.isEmpty => + // Thesaurus definition: maps URI to local file path + val uri = asyncReader.getAttributeValue(ATTR_URI) + val fileName = asyncReader.getAttributeValue(ATTR_FILE_NAME) + if (uri != null && fileName != null) { + val resolvedPath = xqtsPath.resolve(fileName) + thesaurusURIMap = thesaurusURIMap + (uri -> resolvedPath) + logger.debug(s"Registered thesaurus URI mapping: $uri -> $resolvedPath") + } + + case START_ELEMENT if asyncReader.getLocalName == ELEM_TEST_GROUP => + // Register stop word and thesaurus URI maps before the first test group + // to ensure they're available before any test cases are sent to the runner. + if (!uriMapsRegistered) { + registerURIMaps() + uriMapsRegistered = true + } + val name = asyncReader.getAttributeValue(ATTR_NAME) + groupStack = groupStack :+ (if (name != null) name else s"group-${groupStack.size}") + currentFilePath = currentFilePath :+ Option(asyncReader.getAttributeValue(ATTR_FILE_PATH)) + + case END_ELEMENT if asyncReader.getLocalName == ELEM_TEST_GROUP => + val groupName = if (groupStack.nonEmpty) groupStack.last else "" + // If this group had test cases, finalize it + if (testSetTestCases.contains(groupName)) { + val testSetRef = TestSetRef(XQTS_FTTS_1_0, groupName, xqtsPath.resolve(CATALOG_FILE)) + xqtsRunner ! ParsedTestSet(testSetRef, testSetTestCases(groupName)) + testSetTestCases = testSetTestCases - groupName + } + if (groupStack.nonEmpty) groupStack = groupStack.init + if (currentFilePath.nonEmpty) currentFilePath = currentFilePath.init + + case START_ELEMENT if asyncReader.getLocalName == ELEM_TEST_CASE => + currentTestCaseName = asyncReader.getAttributeValueOpt(ATTR_NAME) + currentTestCaseFilePath = asyncReader.getAttributeValueOpt(ATTR_FILE_PATH) + .orElse(currentFilePath.reverse.collectFirst { case Some(fp) => fp }) + currentTestCaseScenario = asyncReader.getAttributeValueOpt(ATTR_SCENARIO) + currentQueryName = None + currentInputFiles = List.empty + currentContextItem = None + currentOutputFiles = List.empty + currentExpectedErrors = List.empty + + case START_ELEMENT if asyncReader.getLocalName == ELEM_QUERY && currentTestCaseName.isDefined => + currentQueryName = asyncReader.getAttributeValueOpt(ATTR_NAME) + + case START_ELEMENT if asyncReader.getLocalName == ELEM_INPUT_FILE && currentTestCaseName.isDefined => + currentInputVariable = asyncReader.getAttributeValueOpt(ATTR_VARIABLE).orElse(Some("input-context")) + captureText = true + currentText = None + + case END_ELEMENT if asyncReader.getLocalName == ELEM_INPUT_FILE && currentTestCaseName.isDefined => + captureText = false + val sourceId = currentText.map(_.trim).getOrElse("") + if (sourceId.nonEmpty) { + val variable = currentInputVariable.getOrElse("input-context") + currentInputFiles = currentInputFiles :+ (variable, sourceId) + } + currentText = None + currentInputVariable = None + + case START_ELEMENT if asyncReader.getLocalName == ELEM_CONTEXT_ITEM && currentTestCaseName.isDefined => + captureText = true + currentText = None + + case END_ELEMENT if asyncReader.getLocalName == ELEM_CONTEXT_ITEM && currentTestCaseName.isDefined => + captureText = false + currentText.map(_.trim).filter(_.nonEmpty).foreach { sourceId => + currentContextItem = Some(sourceId) + } + currentText = None + + case START_ELEMENT if asyncReader.getLocalName == ELEM_OUTPUT_FILE && currentTestCaseName.isDefined => + currentCompareType = asyncReader.getAttributeValueOpt(ATTR_COMPARE).orElse(Some(COMPARE_TEXT)) + captureText = true + currentText = None + + case END_ELEMENT if asyncReader.getLocalName == ELEM_OUTPUT_FILE && currentTestCaseName.isDefined => + captureText = false + val fileName = currentText.map(_.trim).getOrElse("") + if (fileName.nonEmpty) { + val filePath = currentTestCaseFilePath.getOrElse("") + val resolvedPath = xqtsPath.resolve(resultOffsetPath + filePath + fileName) + val compareType = currentCompareType.getOrElse(COMPARE_TEXT) + currentOutputFiles = currentOutputFiles :+ (compareType, resolvedPath) + } + currentText = None + currentCompareType = None + + case START_ELEMENT if asyncReader.getLocalName == ELEM_EXPECTED_ERROR && currentTestCaseName.isDefined => + captureText = true + currentText = None + + case END_ELEMENT if asyncReader.getLocalName == ELEM_EXPECTED_ERROR && currentTestCaseName.isDefined => + captureText = false + currentText.map(_.trim).filter(_.nonEmpty).foreach { code => + currentExpectedErrors = currentExpectedErrors :+ code + } + currentText = None + + case END_ELEMENT if asyncReader.getLocalName == ELEM_TEST_CASE && currentTestCaseName.isDefined => + // Build and dispatch the test case + val testCaseName = currentTestCaseName.get + val testSetName = if (groupStack.nonEmpty) groupStack.last else "unknown" + + if (matchesTestSets(testSetName) && matchesTestCases(testCaseName)) { + val testSetRef = TestSetRef(XQTS_FTTS_1_0, testSetName, xqtsPath.resolve(CATALOG_FILE)) + + // Announce test set if first time + if (!announcedTestSets.contains(testSetName)) { + xqtsRunner ! ParsingTestSet(testSetRef) + announcedTestSets = announcedTestSets + testSetName + matchedTestSets = matchedTestSets + 1 + } + + // Build query path + val filePath = currentTestCaseFilePath.getOrElse("") + val queryPath = currentQueryName.map(qn => xqtsPath.resolve(queryOffsetPath + filePath + qn + queryFileExtension)) + + // Build environment from input files and context item + val inputSources = currentInputFiles.flatMap { case (variable, sourceId) => + sources.get(sourceId).map { sourcePath => + Source( + role = Some(ExternalVariableRole(variable)), + file = sourcePath, + uri = None + ) + } + } + val contextSources = currentContextItem.flatMap { sourceId => + sources.get(sourceId).map { sourcePath => + Source( + role = Some(ContextItemRole), + file = sourcePath, + uri = None + ) + } + }.toList + val envSources = contextSources ++ inputSources + val environment = if (envSources.nonEmpty) Some(Environment("env", sources = envSources)) else None + + // Build assertions + val assertions: List[Result] = buildAssertions() + + val result = if (assertions.size == 1) Some(assertions.head) else if (assertions.nonEmpty) Some(AnyOf(assertions)) else None + + val testCase = TestCase( + file = xqtsPath.resolve(CATALOG_FILE), + name = testCaseName, + covers = "", + description = None, + environment = environment, + test = queryPath.map(Right(_)), + result = result + ) + + // Track and dispatch + testSetTestCases = testSetTestCases + (testSetName -> (testSetTestCases.getOrElse(testSetName, List.empty) :+ testCaseName)) + xqtsRunner ! RunningTestCase(testSetRef, testCaseName) + testCaseRunnerRouter ! RunTestCase(testSetRef, testCase, xqtsRunner) + } + + // Reset test case state + currentTestCaseName = None + currentTestCaseFilePath = None + currentTestCaseScenario = None + currentQueryName = None + currentInputFiles = List.empty + currentContextItem = None + currentOutputFiles = List.empty + currentExpectedErrors = List.empty + + case CHARACTERS if captureText => + val text = asyncReader.getText + currentText = Some(currentText.getOrElse("") + text) + + case EVENT_INCOMPLETE => + val bytesRead = channel.read(buf) + if (bytesRead == -1) { + asyncReader.getInputFeeder.endOfInput() + } else { + buf.flip() + asyncReader.getInputFeeder.feedInput(buf) + } + + case _ => // ignore other events + } + + parseAll(asyncReader.next(), asyncReader, xqtsPath, channel, buf, xqtsRunner, matchesTestSets, matchesTestCases) + } + + /** + * Register stop word and thesaurus URI mappings on the ExistServer's global context + * attributes, so they're available via XQueryContext.getAttribute() during test execution. + */ + private def registerURIMaps(): Unit = { + import scala.jdk.CollectionConverters._ + if (stopWordURIMap.nonEmpty) { + val javaMap: java.util.Map[String, Path] = stopWordURIMap.asJava + existServer.globalContextAttributes = existServer.globalContextAttributes + + ("ft.stopWordURIMap" -> javaMap) + logger.info(s"Registered ${stopWordURIMap.size} stop word URI mappings") + } + if (thesaurusURIMap.nonEmpty) { + val javaMap: java.util.Map[String, Path] = thesaurusURIMap.asJava + existServer.globalContextAttributes = existServer.globalContextAttributes + + ("ft.thesaurusURIMap" -> javaMap) + logger.info(s"Registered ${thesaurusURIMap.size} thesaurus URI mappings") + } + } + + /** + * Build assertions from the current test case's output files and expected errors. + */ + private def buildAssertions(): List[Result] = { + val outputAssertions = currentOutputFiles.filter(_._2 != null).flatMap { case (compareType, path) => + compareType match { + case COMPARE_XML => + Some(AssertXml(Right(path))) + case COMPARE_FRAGMENT => + // Fragment comparison normalizes whitespace in text nodes because + // XQFTTS expected outputs were generated by reference implementations + // with different line-wrapping/indentation conventions than eXist-db. + Some(AssertXml(Right(path), normalizeWhitespace = true)) + case COMPARE_TEXT => + Some(AssertXml(Right(path))) + case COMPARE_INSPECT | COMPARE_IGNORE => + Some(AssertInspect) // cannot automatically verify; always passes + case _ => + Some(AssertXml(Right(path))) + } + } + + val errorAssertions = currentExpectedErrors.map(code => Error(code)) + + outputAssertions ++ errorAssertions + } +} diff --git a/src/main/scala/org/exist/xqts/runner/xqftts/package.scala b/src/main/scala/org/exist/xqts/runner/xqftts/package.scala new file mode 100644 index 0000000..8e68ff7 --- /dev/null +++ b/src/main/scala/org/exist/xqts/runner/xqftts/package.scala @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2018 The eXist Project + * + * This program 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 3 of the License, or + * (at your option) any later version. + * + * This program 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 General Lesser Public License + * along with this program. If not, see . + */ + +package org.exist.xqts.runner + +import com.fasterxml.aalto.stax.InputFactoryImpl +import com.fasterxml.aalto.{AsyncInputFeeder, AsyncXMLStreamReader} + +import javax.xml.XMLConstants + +package object xqftts { + + // Catalog file constants + val CATALOG_FILE = "XQFTTSCatalog.xml" + val XQUERY_QUERY_OFFSET = "Queries/XQuery/" + val RESULT_OFFSET = "ExpectedTestResults/" + val XQUERY_FILE_EXT = ".xq" + + // Element names (XQFTTS namespace: http://www.w3.org/2005/02/query-test-full-text) + val ELEM_TEST_SUITE = "test-suite" + val ELEM_SOURCES = "sources" + val ELEM_SOURCE = "source" + val ELEM_TEST_GROUP = "test-group" + val ELEM_GROUP_INFO = "GroupInfo" + val ELEM_TITLE = "title" + val ELEM_DESCRIPTION = "description" + val ELEM_TEST_CASE = "test-case" + val ELEM_QUERY = "query" + val ELEM_INPUT_FILE = "input-file" + val ELEM_INPUT_URI = "input-URI" + val ELEM_OUTPUT_FILE = "output-file" + val ELEM_CONTEXT_ITEM = "contextItem" + val ELEM_EXPECTED_ERROR = "expected-error" + val ELEM_SPEC_CITATION = "spec-citation" + val ELEM_STOPWORDS = "stopwords" + val ELEM_THESAURUS = "thesaurus" + + // Attribute names + val ATTR_NAME = "name" + val ATTR_ID = "ID" + val ATTR_FILE_NAME = "FileName" + val ATTR_FILE_PATH = "FilePath" + val ATTR_SCENARIO = "scenario" + val ATTR_IS_XPATH2 = "is-XPath2" + val ATTR_CREATOR = "Creator" + val ATTR_COMPARE = "compare" + val ATTR_ROLE = "role" + val ATTR_VARIABLE = "variable" + val ATTR_DATE = "date" + val ATTR_XQUERY_QUERY_OFFSET_PATH = "XQueryQueryOffsetPath" + val ATTR_RESULT_OFFSET_PATH = "ResultOffsetPath" + val ATTR_XQUERY_FILE_EXTENSION = "XQueryFileExtension" + val ATTR_SOURCE_OFFSET_PATH = "SourceOffsetPath" + val ATTR_URI = "uri" + + // Comparison types + val COMPARE_XML = "XML" + val COMPARE_FRAGMENT = "Fragment" + val COMPARE_TEXT = "Text" + val COMPARE_INSPECT = "Inspect" + val COMPARE_IGNORE = "Ignore" + + // Scenario types + val SCENARIO_STANDARD = "standard" + val SCENARIO_PARSE_ERROR = "parse-error" + val SCENARIO_RUNTIME_ERROR = "runtime-error" + + private[xqftts] val PARSER_FACTORY = new InputFactoryImpl + + implicit class AsyncXMLStreamReaderPimp[F <: AsyncInputFeeder](asyncXmlStreamReader: AsyncXMLStreamReader[F]) { + def getAttributeValue(localName: String): String = asyncXmlStreamReader.getAttributeValue(XMLConstants.NULL_NS_URI, localName) + def getAttributeValueOpt(localName: String): Option[String] = Option(asyncXmlStreamReader.getAttributeValue(XMLConstants.NULL_NS_URI, localName)) + def getAttributeValueOptNE(localName: String): Option[String] = getAttributeValueOpt(localName).filter(_.nonEmpty) + } +}