1 /*
2  * Copyright (C) 2010 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package libcore.xml;
18 
19 import java.io.BufferedInputStream;
20 import java.io.File;
21 import java.io.FileInputStream;
22 import java.io.FileNotFoundException;
23 import java.io.IOException;
24 import java.io.InputStream;
25 import java.io.InputStreamReader;
26 import java.io.Reader;
27 import java.io.StringReader;
28 import java.io.StringWriter;
29 import java.util.ArrayList;
30 import java.util.Collections;
31 import java.util.Comparator;
32 import java.util.List;
33 import javax.xml.parsers.DocumentBuilder;
34 import javax.xml.parsers.DocumentBuilderFactory;
35 import javax.xml.parsers.ParserConfigurationException;
36 import javax.xml.transform.ErrorListener;
37 import javax.xml.transform.Result;
38 import javax.xml.transform.Source;
39 import javax.xml.transform.Transformer;
40 import javax.xml.transform.TransformerConfigurationException;
41 import javax.xml.transform.TransformerException;
42 import javax.xml.transform.TransformerFactory;
43 import javax.xml.transform.dom.DOMResult;
44 import javax.xml.transform.stream.StreamResult;
45 import javax.xml.transform.stream.StreamSource;
46 import junit.framework.Assert;
47 import junit.framework.AssertionFailedError;
48 import junit.framework.Test;
49 import junit.framework.TestCase;
50 import junit.framework.TestSuite;
51 import org.w3c.dom.Attr;
52 import org.w3c.dom.Document;
53 import org.w3c.dom.Element;
54 import org.w3c.dom.EntityReference;
55 import org.w3c.dom.NamedNodeMap;
56 import org.w3c.dom.Node;
57 import org.w3c.dom.NodeList;
58 import org.w3c.dom.ProcessingInstruction;
59 import org.xml.sax.InputSource;
60 import org.xml.sax.SAXException;
61 import org.xml.sax.SAXParseException;
62 import org.xmlpull.v1.XmlPullParserException;
63 import org.xmlpull.v1.XmlPullParserFactory;
64 import org.xmlpull.v1.XmlSerializer;
65 
66 /**
67  * The <a href="http://www.oasis-open.org/committees/tc_home.php?wg_abbrev=xslt">OASIS
68  * XSLT conformance test suite</a>, adapted for use by JUnit. To run these tests
69  * on a device:
70  * <ul>
71  *     <li>Obtain the <a href="http://www.oasis-open.org/committees/download.php/12171/XSLT-testsuite-04.ZIP">test
72  *         suite zip file from the OASIS project site.</li>
73  *     <li>Unzip.
74  *     <li>Copy the files to a device: <code>adb shell mkdir /data/oasis ;
75  *         adb push ./XSLT-Conformance-TC /data/oasis</code>.
76  *     <li>Invoke this class' main method, passing the on-device path to the test
77  *         suite's <code>catalog.xml</code> file as an argument.
78  * </ul>
79  *
80  * <p>Unfortunately, some of the tests in the OASIS suite will fail when
81  * executed outside of their original development environment:
82  * <ul>
83  *     <li>The tests assume case insensitive filesystems. Some will fail with
84  *        "Couldn't open file" errors due to a mismatch in file name casing.
85  *     <li>The tests assume certain network hosts will exist and serve
86  *         stylesheet files. In particular, "http://webxtest/" isn't generally
87  *         available.
88  * </ul>
89  */
90 public class XsltXPathConformanceTestSuite {
91 
92     private static final String defaultCatalogFile
93             = "/home/dalvik-prebuild/OASIS/XSLT-Conformance-TC/TESTS/catalog.xml";
94 
95     /** Orders element attributes by optional URI and name. */
96     private static final Comparator<Attr> orderByName = new Comparator<Attr>() {
97         public int compare(Attr a, Attr b) {
98             int result = compareNullsFirst(a.getNamespaceURI(), b.getNamespaceURI());
99             return result == 0 ? result
100                     : compareNullsFirst(a.getName(), b.getName());
101         }
102 
103         <T extends Comparable<T>> int compareNullsFirst(T a, T b) {
104             return (a == b) ? 0
105                     : (a == null) ? -1
106                     : (b == null) ? 1
107                     : a.compareTo(b);
108         }
109     };
110 
111     private final DocumentBuilder documentBuilder;
112     private final TransformerFactory transformerFactory;
113     private final XmlPullParserFactory xmlPullParserFactory;
114 
XsltXPathConformanceTestSuite()115     public XsltXPathConformanceTestSuite()
116             throws ParserConfigurationException, XmlPullParserException {
117         DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
118         factory.setNamespaceAware(true);
119         factory.setCoalescing(true);
120         documentBuilder = factory.newDocumentBuilder();
121 
122         transformerFactory = TransformerFactory.newInstance();
123         xmlPullParserFactory = XmlPullParserFactory.newInstance();
124     }
125 
main(String[] args)126     public static void main(String[] args) throws Exception {
127         if (args.length != 1) {
128             System.out.println("Usage: XsltXPathConformanceTestSuite <catalog-xml>");
129             System.out.println();
130             System.out.println("  catalog-xml: an XML file describing an OASIS test suite");
131             System.out.println("               such as: " + defaultCatalogFile);
132             return;
133         }
134 
135         File catalogXml = new File(args[0]);
136         // TestRunner.run(suite(catalogXml)); android-changed
137     }
138 
suite()139     public static Test suite() throws Exception {
140         return suite(new File(defaultCatalogFile));
141     }
142 
143     /**
144      * Returns a JUnit test suite for the tests described by the given document.
145      */
suite(File catalogXml)146     public static Test suite(File catalogXml) throws Exception {
147         XsltXPathConformanceTestSuite suite = new XsltXPathConformanceTestSuite();
148 
149         /*
150          * Extract the tests from an XML document with the following structure:
151          *
152          *  <test-suite>
153          *    <test-catalog submitter="Lotus">
154          *      <creator>Lotus/IBM</creator>
155          *      <major-path>Xalan_Conformance_Tests</major-path>
156          *      <date>2001-11-16</date>
157          *      <test-case ...> ... </test-case>
158          *      <test-case ...> ... </test-case>
159          *      <test-case ...> ... </test-case>
160          *    </test-catalog>
161          *  </test-suite>
162          */
163 
164         Document document = DocumentBuilderFactory.newInstance()
165                 .newDocumentBuilder().parse(catalogXml);
166         Element testSuiteElement = document.getDocumentElement();
167         TestSuite result = new TestSuite();
168         for (Element testCatalog : elementsOf(testSuiteElement.getElementsByTagName("test-catalog"))) {
169             Element majorPathElement = (Element) testCatalog.getElementsByTagName("major-path").item(0);
170             String majorPath = majorPathElement.getTextContent();
171             File base = new File(catalogXml.getParentFile(), majorPath);
172 
173             for (Element testCaseElement : elementsOf(testCatalog.getElementsByTagName("test-case"))) {
174                 result.addTest(suite.create(base, testCaseElement));
175             }
176         }
177 
178         return result;
179     }
180 
181     /**
182      * Returns a JUnit test for the test described by the given element.
183      */
create(File base, Element testCaseElement)184     private TestCase create(File base, Element testCaseElement) {
185 
186         /*
187          * Extract the XSLT test from a DOM entity with the following structure:
188          *
189          *   <test-case category="XSLT-Result-Tree" id="attribset_attribset01">
190          *       <file-path>attribset</file-path>
191          *       <creator>Paul Dick</creator>
192          *       <date>2001-11-08</date>
193          *       <purpose>Set attribute of a LRE from single attribute set.</purpose>
194          *       <spec-citation place="7.1.4" type="section" version="1.0" spec="xslt"/>
195          *        <scenario operation="standard">
196          *           <input-file role="principal-data">attribset01.xml</input-file>
197          *           <input-file role="principal-stylesheet">attribset01.xsl</input-file>
198          *           <output-file role="principal" compare="XML">attribset01.out</output-file>
199          *       </scenario>
200          *   </test-case>
201          */
202 
203         Element filePathElement = (Element) testCaseElement.getElementsByTagName("file-path").item(0);
204         Element purposeElement = (Element) testCaseElement.getElementsByTagName("purpose").item(0);
205         Element specCitationElement = (Element) testCaseElement.getElementsByTagName("spec-citation").item(0);
206         Element scenarioElement = (Element) testCaseElement.getElementsByTagName("scenario").item(0);
207 
208         String category = testCaseElement.getAttribute("category");
209         String id = testCaseElement.getAttribute("id");
210         String name = category + "." + id;
211         String purpose = purposeElement != null ? purposeElement.getTextContent() : "";
212         String spec = "place=" + specCitationElement.getAttribute("place")
213                 + " type" + specCitationElement.getAttribute("type")
214                 + " version=" + specCitationElement.getAttribute("version")
215                 + " spec=" + specCitationElement.getAttribute("spec");
216         String operation = scenarioElement.getAttribute("operation");
217 
218         Element principalDataElement = null;
219         Element principalStylesheetElement = null;
220         Element principalElement = null;
221 
222         for (Element element : elementsOf(scenarioElement.getChildNodes())) {
223             String role = element.getAttribute("role");
224             if (role.equals("principal-data")) {
225                 principalDataElement = element;
226             } else if (role.equals("principal-stylesheet")) {
227                 principalStylesheetElement = element;
228             } else if (role.equals("principal")) {
229                 principalElement = element;
230             } else if (!role.equals("supplemental-stylesheet")
231                     && !role.equals("supplemental-data")) {
232                 return new MisspecifiedTest("Unexpected element at " + name);
233             }
234         }
235 
236         String testDirectory = filePathElement.getTextContent();
237         File inBase = new File(base, testDirectory);
238         File outBase = new File(new File(base, "REF_OUT"), testDirectory);
239 
240         if (principalDataElement == null || principalStylesheetElement == null) {
241             return new MisspecifiedTest("Expected <scenario> to have "
242                     + "principal=data and principal-stylesheet elements at " + name);
243         }
244 
245         try {
246             File principalData = findFile(inBase, principalDataElement.getTextContent());
247             File principalStylesheet = findFile(inBase, principalStylesheetElement.getTextContent());
248 
249             final File principal;
250             final String compareAs;
251             if (!operation.equals("execution-error")) {
252                 if (principalElement == null) {
253                     return new MisspecifiedTest("Expected <scenario> to have principal element at " + name);
254                 }
255 
256                 principal = findFile(outBase, principalElement.getTextContent());
257                 compareAs = principalElement.getAttribute("compare");
258             } else {
259                 principal = null;
260                 compareAs = null;
261             }
262 
263             return new XsltTest(category, id, purpose, spec, principalData,
264                     principalStylesheet, principal, operation, compareAs);
265         } catch (FileNotFoundException e) {
266             return new MisspecifiedTest(e.getMessage() + " at " + name);
267         }
268     }
269 
270     /**
271      * Finds the named file in the named directory. This tries extra hard to
272      * avoid case-insensitive-naming problems, where the requested file is
273      * available in a different casing.
274      */
findFile(File directory, String name)275     private File findFile(File directory, String name) throws FileNotFoundException {
276         File file = new File(directory, name);
277         if (file.exists()) {
278             return file;
279         }
280 
281         for (String child : directory.list()) {
282             if (child.equalsIgnoreCase(name)) {
283                 return new File(directory, child);
284             }
285         }
286 
287         throw new FileNotFoundException("Missing file: " + file);
288     }
289 
290     /**
291      * Placeholder for a test that couldn't be configured to run properly.
292      */
293     public class MisspecifiedTest extends TestCase {
294         private final String message;
295 
MisspecifiedTest(String message)296         MisspecifiedTest(String message) {
297             super("test");
298             this.message = message;
299         }
300 
test()301         public void test() {
302             fail(message);
303         }
304     }
305 
306     /**
307      * Processes an input XML file with an input XSLT stylesheet and compares
308      * the result to an expected output file.
309      */
310     public class XsltTest extends TestCase {
311         private final String category;
312         private final String id;
313         private final String purpose;
314         private final String spec;
315 
316         private final File principalData;
317         private final File principalStylesheet;
318         private final File principal;
319 
320         /** either "standard" or "execution-error" */
321         private final String operation;
322 
323         /**
324          * The syntax to compare the output file using, such as "XML", "HTML",
325          * "manual", or null for expected execution errors.
326          */
327         private final String compareAs;
328 
XsltTest(String category, String id, String purpose, String spec, File principalData, File principalStylesheet, File principal, String operation, String compareAs)329         XsltTest(String category, String id, String purpose, String spec,
330                 File principalData, File principalStylesheet, File principal,
331                 String operation, String compareAs) {
332             super("test");
333             this.category = category;
334             this.id = id;
335             this.purpose = purpose;
336             this.spec = spec;
337             this.principalData = principalData;
338             this.principalStylesheet = principalStylesheet;
339             this.principal = principal;
340             this.operation = operation;
341             this.compareAs = compareAs;
342         }
343 
XsltTest(File principalData, File principalStylesheet, File principal)344         XsltTest(File principalData, File principalStylesheet, File principal) {
345             this("standalone", "test", "", "",
346                     principalData, principalStylesheet, principal, "standard", "XML");
347         }
348 
test()349         public void test() throws Exception {
350             if (purpose != null) {
351                 System.out.println("Purpose: " + purpose);
352             }
353             if (spec != null) {
354                 System.out.println("Spec: " + spec);
355             }
356 
357             Result result;
358             if ("XML".equals(compareAs)) {
359                 DOMResult domResult = new DOMResult();
360                 domResult.setNode(documentBuilder.newDocument().createElementNS("", "result"));
361                 result = domResult;
362             } else {
363                 result = new StreamResult(new StringWriter());
364             }
365 
366             ErrorRecorder errorRecorder = new ErrorRecorder();
367             transformerFactory.setErrorListener(errorRecorder);
368 
369             Transformer transformer;
370             try {
371                 Source xslt = new StreamSource(principalStylesheet);
372                 transformer = transformerFactory.newTransformer(xslt);
373                 if (errorRecorder.error == null) {
374                     transformer.setErrorListener(errorRecorder);
375                     transformer.transform(new StreamSource(principalData), result);
376                 }
377             } catch (TransformerConfigurationException e) {
378                 errorRecorder.fatalError(e);
379             }
380 
381             if (operation.equals("standard")) {
382                 if (errorRecorder.error != null) {
383                     throw errorRecorder.error;
384                 }
385             } else if (operation.equals("execution-error")) {
386                 if (errorRecorder.error != null) {
387                     return;
388                 }
389                 fail("Expected " + operation + ", but transform completed normally."
390                         + " (Warning=" + errorRecorder.warning + ")");
391             } else {
392                 throw new UnsupportedOperationException("Unexpected operation: " + operation);
393             }
394 
395             if ("XML".equals(compareAs)) {
396                 assertNodesAreEquivalent(principal, ((DOMResult) result).getNode());
397             } else {
398                 // TODO: implement support for comparing HTML etc.
399                 throw new UnsupportedOperationException("Cannot compare as " + compareAs);
400             }
401         }
402 
getName()403         @Override public String getName() {
404             return category + "." + id;
405         }
406     }
407 
408     /**
409      * Ensures both XML documents represent the same semantic data. Non-semantic
410      * data such as namespace prefixes, comments, and whitespace is ignored.
411      *
412      * @param actual an XML document whose root is a {@code <result>} element.
413      * @param expected a file containing an XML document fragment.
414      */
assertNodesAreEquivalent(File expected, Node actual)415     private void assertNodesAreEquivalent(File expected, Node actual)
416             throws ParserConfigurationException, IOException, SAXException,
417             XmlPullParserException {
418 
419         Node expectedNode = fileToResultNode(expected);
420         String expectedString = nodeToNormalizedString(expectedNode);
421         String actualString = nodeToNormalizedString(actual);
422 
423         Assert.assertEquals("Expected XML to match file " + expected,
424                 expectedString, actualString);
425     }
426 
427     /**
428      * Returns the given file's XML fragment as a single node, wrapped in
429      * {@code <result>} tags. This takes care of normalizing the following
430      * conditions:
431      *
432      * <ul>
433      * <li>Files containing XML document fragments with multiple elements:
434      * {@code <SPAN style="color=blue">Smurfs!</SPAN><br />}
435      *
436      * <li>Files containing XML document fragments with no elements:
437      * {@code Smurfs!}
438      *
439      * <li>Files containing proper XML documents with a single element and an
440      * XML declaration:
441      * {@code <?xml version="1.0"?><doc />}
442      *
443      * <li>Files prefixed with a byte order mark header, such as 0xEFBBBF.
444      * </ul>
445      */
fileToResultNode(File file)446     private Node fileToResultNode(File file) throws IOException, SAXException {
447         String rawContents = fileToString(file);
448         String fragment = rawContents;
449 
450         // If the file had an XML declaration, strip that. Otherwise wrapping
451         // it in <result> tags would result in a malformed XML document.
452         if (fragment.startsWith("<?xml")) {
453             int declarationEnd = fragment.indexOf("?>");
454             fragment = fragment.substring(declarationEnd + 2);
455         }
456 
457         // Parse it as document fragment wrapped in <result> tags.
458         try {
459             fragment = "<result>" + fragment + "</result>";
460             return documentBuilder.parse(new InputSource(new StringReader(fragment)))
461                     .getDocumentElement();
462         } catch (SAXParseException e) {
463             Error error = new AssertionFailedError(
464                     "Failed to parse XML: " + file + "\n" + rawContents);
465             error.initCause(e);
466             throw error;
467         }
468     }
469 
nodeToNormalizedString(Node node)470     private String nodeToNormalizedString(Node node)
471             throws XmlPullParserException, IOException {
472         StringWriter writer = new StringWriter();
473         XmlSerializer xmlSerializer = xmlPullParserFactory.newSerializer();
474         xmlSerializer.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
475         xmlSerializer.setOutput(writer);
476         emitNode(xmlSerializer, node);
477         xmlSerializer.flush();
478         return writer.toString();
479     }
480 
emitNode(XmlSerializer serializer, Node node)481     private void emitNode(XmlSerializer serializer, Node node) throws IOException {
482         if (node == null) {
483             throw new UnsupportedOperationException("Cannot emit null nodes");
484 
485         } else if (node.getNodeType() == Node.ELEMENT_NODE) {
486             Element element = (Element) node;
487             serializer.startTag(element.getNamespaceURI(), element.getLocalName());
488             emitAttributes(serializer, element);
489             emitChildren(serializer, element);
490             serializer.endTag(element.getNamespaceURI(), element.getLocalName());
491 
492         } else if (node.getNodeType() == Node.TEXT_NODE
493                 || node.getNodeType() == Node.CDATA_SECTION_NODE) {
494             // TODO: is it okay to trim whitespace in general? This may cause
495             //     false positives for elements like HTML's <pre> tag
496             String trimmed = node.getTextContent().trim();
497             if (trimmed.length() > 0) {
498                 serializer.text(trimmed);
499             }
500 
501         } else if (node.getNodeType() == Node.DOCUMENT_NODE) {
502             Document document = (Document) node;
503             serializer.startDocument("UTF-8", true);
504             emitNode(serializer, document.getDocumentElement());
505             serializer.endDocument();
506 
507         } else if (node.getNodeType() == Node.PROCESSING_INSTRUCTION_NODE) {
508             ProcessingInstruction processingInstruction = (ProcessingInstruction) node;
509             String data = processingInstruction.getData();
510             String target = processingInstruction.getTarget();
511             serializer.processingInstruction(target + " " + data);
512 
513         } else if (node.getNodeType() == Node.COMMENT_NODE) {
514             // ignore!
515 
516         } else if (node.getNodeType() == Node.ENTITY_REFERENCE_NODE) {
517             EntityReference entityReference = (EntityReference) node;
518             serializer.entityRef(entityReference.getNodeName());
519 
520         } else {
521             throw new UnsupportedOperationException(
522                     "Cannot emit " + node + " of type " + node.getNodeType());
523         }
524     }
525 
emitAttributes(XmlSerializer serializer, Node node)526     private void emitAttributes(XmlSerializer serializer, Node node)
527             throws IOException {
528         NamedNodeMap map = node.getAttributes();
529         if (map == null) {
530             return;
531         }
532 
533         List<Attr> attributes = new ArrayList<Attr>();
534         for (int i = 0; i < map.getLength(); i++) {
535             attributes.add((Attr) map.item(i));
536         }
537         Collections.sort(attributes, orderByName);
538 
539         for (Attr attr : attributes) {
540             if ("xmlns".equals(attr.getPrefix()) || "xmlns".equals(attr.getLocalName())) {
541                 /*
542                  * Omit namespace declarations because they aren't considered
543                  * data. Ie. <foo:a xmlns:bar="http://google.com"> is semantically
544                  * equal to <bar:a xmlns:bar="http://google.com"> since the
545                  * prefix doesn't matter, only the URI it points to.
546                  *
547                  * When we omit the prefix, our XML serializer will still
548                  * generate one for us, using a predictable pattern.
549                  */
550             } else {
551                 serializer.attribute(attr.getNamespaceURI(), attr.getLocalName(), attr.getValue());
552             }
553         }
554     }
555 
emitChildren(XmlSerializer serializer, Node node)556     private void emitChildren(XmlSerializer serializer, Node node)
557             throws IOException {
558         NodeList childNodes = node.getChildNodes();
559         for (int i = 0; i < childNodes.getLength(); i++) {
560             emitNode(serializer, childNodes.item(i));
561         }
562     }
563 
elementsOf(NodeList nodeList)564     private static List<Element> elementsOf(NodeList nodeList) {
565         List<Element> result = new ArrayList<Element>();
566         for (int i = 0; i < nodeList.getLength(); i++) {
567             Node node = nodeList.item(i);
568             if (node instanceof Element) {
569                 result.add((Element) node);
570             }
571         }
572         return result;
573     }
574 
575     /**
576      * Reads the given file into a string. If the file contains a byte order
577      * mark, the corresponding character set will be used. Otherwise the system
578      * default charset will be used.
579      */
fileToString(File file)580     private String fileToString(File file) throws IOException {
581         InputStream in = new BufferedInputStream(new FileInputStream(file), 1024);
582 
583         // Read the byte order mark to determine the charset.
584         // TODO: use a built-in API for this...
585         Reader reader;
586         in.mark(3);
587         int byte1 = in.read();
588         int byte2 = in.read();
589         if (byte1 == 0xFF && byte2 == 0xFE) {
590             reader = new InputStreamReader(in, "UTF-16LE");
591         } else if (byte1 == 0xFF && byte2 == 0xFF) {
592             reader = new InputStreamReader(in, "UTF-16BE");
593         } else {
594             int byte3 = in.read();
595             if (byte1 == 0xEF && byte2 == 0xBB && byte3 == 0xBF) {
596                 reader = new InputStreamReader(in, "UTF-8");
597             } else {
598                 in.reset();
599                 reader = new InputStreamReader(in);
600             }
601         }
602 
603         StringWriter out = new StringWriter();
604         char[] buffer = new char[1024];
605         int count;
606         while ((count = reader.read(buffer)) != -1) {
607             out.write(buffer, 0, count);
608         }
609         in.close();
610         return out.toString();
611     }
612 
613     static class ErrorRecorder implements ErrorListener {
614         Exception warning;
615         Exception error;
616 
warning(TransformerException exception)617         public void warning(TransformerException exception) {
618             if (this.warning == null) {
619                 this.warning = exception;
620             }
621         }
622 
error(TransformerException exception)623         public void error(TransformerException exception) {
624             if (this.error == null) {
625                 this.error = exception;
626             }
627         }
628 
fatalError(TransformerException exception)629         public void fatalError(TransformerException exception) {
630             if (this.error == null) {
631                 this.error = exception;
632             }
633         }
634     }
635 }
636