diff --git a/src/main/scala/org/exist/xqts/runner/ExistServer.scala b/src/main/scala/org/exist/xqts/runner/ExistServer.scala index 1337b14..982cc93 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) @@ -106,7 +123,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 @@ -338,7 +355,12 @@ class ExistConnection(brokerRes: Resource[IO, DBBroker]) { IO.delay { try { val resultSequence = xqueryService.execute(broker, compiledQuery.compiledXquery, contextSequence.orNull) - Right(Result(resultSequence, compiledQuery.compilationTime, System.currentTimeMillis() - executionStartTime)) + // Extract serialization properties from the query context (e.g. declare option output:method "json") + val serializationProps = new Properties() + compiledQuery.xqueryContext.checkOptions(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 => @@ -533,6 +555,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 +586,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 8a06b5b..7e7aefb 100644 --- a/src/main/scala/org/exist/xqts/runner/TestCaseRunnerActor.scala +++ b/src/main/scala/org/exist/xqts/runner/TestCaseRunnerActor.scala @@ -270,6 +270,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)) } @@ -532,6 +534,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. @@ -720,12 +745,17 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac * @return the test result from processing the assertion. */ private def assert(connection: ExistConnection, testSetName: TestSetName, testCaseName: TestCaseName, compilationTime: CompilationTime, executionTime: ExecutionTime, assertionNamespaces: Seq[Namespace] = Seq.empty)(xpath: String, actual: ExistServer.QueryResult): TestResult = { - executeQueryWith$Result(connection, xpath, true, None, actual, assertionNamespaces) match { + // 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, assertionNamespaces) 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 @@ -782,7 +812,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac 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 @@ -821,7 +851,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac 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 @@ -925,7 +955,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac 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 @@ -1193,8 +1223,12 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac 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) @@ -1318,7 +1352,7 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac 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 @@ -1345,8 +1379,12 @@ class TestCaseRunnerActor(existServer: ExistServer, commonResourceCacheActor: Ac 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 diff = DiffBuilder.compare(expectedSource) + .withTest(actualSource) + .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) + }) .checkForIdentical() .withComparisonFormatter(ignorableWrapperComparisonFormatter) .checkForSimilar()