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