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
17package libcore.xml;
18
19import java.io.BufferedInputStream;
20import java.io.File;
21import java.io.FileInputStream;
22import java.io.FileNotFoundException;
23import java.io.IOException;
24import java.io.InputStream;
25import java.io.InputStreamReader;
26import java.io.Reader;
27import java.io.StringReader;
28import java.io.StringWriter;
29import java.util.ArrayList;
30import java.util.Collections;
31import java.util.Comparator;
32import java.util.List;
33import javax.xml.parsers.DocumentBuilder;
34import javax.xml.parsers.DocumentBuilderFactory;
35import javax.xml.parsers.ParserConfigurationException;
36import javax.xml.transform.ErrorListener;
37import javax.xml.transform.Result;
38import javax.xml.transform.Source;
39import javax.xml.transform.Transformer;
40import javax.xml.transform.TransformerConfigurationException;
41import javax.xml.transform.TransformerException;
42import javax.xml.transform.TransformerFactory;
43import javax.xml.transform.dom.DOMResult;
44import javax.xml.transform.stream.StreamResult;
45import javax.xml.transform.stream.StreamSource;
46import junit.framework.Assert;
47import junit.framework.AssertionFailedError;
48import junit.framework.Test;
49import junit.framework.TestCase;
50import junit.framework.TestSuite;
51import org.w3c.dom.Attr;
52import org.w3c.dom.Document;
53import org.w3c.dom.Element;
54import org.w3c.dom.EntityReference;
55import org.w3c.dom.NamedNodeMap;
56import org.w3c.dom.Node;
57import org.w3c.dom.NodeList;
58import org.w3c.dom.ProcessingInstruction;
59import org.xml.sax.InputSource;
60import org.xml.sax.SAXException;
61import org.xml.sax.SAXParseException;
62import org.xmlpull.v1.XmlPullParserException;
63import org.xmlpull.v1.XmlPullParserFactory;
64import 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 */
90public 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
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
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
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     */
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     */
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     */
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
296        MisspecifiedTest(String message) {
297            super("test");
298            this.message = message;
299        }
300
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
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
344        XsltTest(File principalData, File principalStylesheet, File principal) {
345            this("standalone", "test", "", "",
346                    principalData, principalStylesheet, principal, "standard", "XML");
347        }
348
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
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     */
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     */
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
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
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
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
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
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     */
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
617        public void warning(TransformerException exception) {
618            if (this.warning == null) {
619                this.warning = exception;
620            }
621        }
622
623        public void error(TransformerException exception) {
624            if (this.error == null) {
625                this.error = exception;
626            }
627        }
628
629        public void fatalError(TransformerException exception) {
630            if (this.error == null) {
631                this.error = exception;
632            }
633        }
634    }
635}
636