/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Eclipse Public License, Version 1.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.eclipse.org/org/documents/epl-v10.php * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.manifmerger; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.manifmerger.IMergerLog.FileAndLine; import com.android.sdklib.mock.MockLog; import junit.framework.Test; import junit.framework.TestCase; import junit.framework.TestSuite; import org.w3c.dom.Document; import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Some utilities to reduce repetitions in the {@link ManifestMergerTest}s. * <p/> * See {@link #loadTestData(String)} for an explanation of the data file format. */ public class ManifestMergerTest extends TestCase { /** * Delimiter that indicates the test must fail. * An XML output and errors are still generated and checked. */ private static final String DELIM_FAILS = "fails"; /** * Delimiter that starts a library XML content. * The delimiter name must be in the form {@code @libSomeName} and it will be * used as the base for the test file name. Using separate lib names is encouraged * since it makes the error output easier to read. */ private static final String DELIM_LIB = "lib"; /** * Delimiter that starts the main manifest XML content. */ private static final String DELIM_MAIN = "main"; /** * Delimiter that starts an overlay XML content. * The delimiter must follow the same rules as {@link #DELIM_LIB} */ private static final String DELIM_OVERLAY = "overlay"; /** * Delimiter that starts the resulting XML content, whatever is generated by the merge. */ private static final String DELIM_RESULT = "result"; /** * Delimiter that starts the SdkLog output. * The logger prints each entry on its lines, prefixed with E for errors, * W for warnings and P for regular printfs. */ private static final String DELIM_ERRORS = "errors"; /** * Delimiter for starts a section that declares how to inject an attribute. * The section is composed of one or more lines with the * syntax: "/node/node|attr-URI attrName=attrValue". * This is essentially a pseudo XPath-like expression that is described in * {@link ManifestMerger#process(Document, File[], Map, String)}. */ private static final String DELIM_INJECT_ATTR = "inject"; /** * Delimiter for a section that declares how to toggle a ManifMerger option. * The section is composed of one or more lines with the * syntax: "functionName=false|true". */ private static final String DELIM_FEATURES = "features"; /** * Delimiter for a section that declares how to override the package. * The section is composed of one line containing the new package name. */ private static final String DELIM_PACKAGE = "package"; /* * Wait, I hear you, where are the tests? * * processTestFiles() uses loadTestData(), which uses one of the data filename * indicated below. * * We could simplify this even further by dynamically finding the data * files to use; however there's some value in having tests break when out * of sync with the known data file set. */ private static String[] sDataFiles = new String[] { "00_noop", "01_ignore_app_attr", "02_ignore_instrumentation", "03_inject_attributes", "04_inject_attributes", "05_inject_package", "10_activity_merge", "11_activity_dup", "12_alias_dup", "13_service_dup", "14_receiver_dup", "15_provider_dup", "16_fqcn_merge", "17_fqcn_conflict", "20_uses_lib_merge", "21_uses_lib_errors", "25_permission_merge", "26_permission_dup", "28_uses_perm_merge", "30_uses_sdk_ok", "32_uses_sdk_minsdk_ok", "33_uses_sdk_minsdk_conflict", "36_uses_sdk_targetsdk_warning", "40_uses_feat_merge", "41_uses_feat_errors", "45_uses_feat_gles_once", "47_uses_feat_gles_conflict", "50_uses_conf_warning", "52_support_screens_warning", "54_compat_screens_warning", "56_support_gltext_warning", "60_merge_order", "65_override_app", "66_remove_app", "67_override_activities", "68_override_uses", "69_remove_uses", "70_expand_fqcns", "71_extract_package_prefix", "75_app_metadata_merge", "76_app_metadata_ignore", "77_app_metadata_conflict", }; /** * This overrides the default test suite created by junit. * The test suite is a bland TestSuite with a dedicated name. * We inject as many instances of {@link ManifestMergerTest} in the suite * as we have declared data files above. * * @return A new {@link TestSuite}. */ public static Test suite() { TestSuite suite = new TestSuite(); // Give a non-generic name to our test suite, for better unit reports. suite.setName("ManifestMergerTestSuite"); for (String fileName : sDataFiles) { suite.addTest(TestSuite.createTest(ManifestMergerTest.class, fileName)); } return suite; } /** * Default constructor invoked by {@link TestSuite#createTest(Class, String)}. * * @param testName The test name provided to {@code TestSuite.createTest()}. * This is later accessible via {@link #getName()}. */ public ManifestMergerTest(String testName) { super(testName); } /** * Invoked by the test framework to run the specific test which name * has been passed to the constructor. * Note that we create one instance of this class per test to run in * the associated {@link TestSuite}. */ @Override protected void runTest() throws Throwable { String testName = getName(); assertNotNull(testName); processTestFiles(loadTestData(testName)); } static class TestFiles { private final File[] mOverlayFiles; private final File mMain; private final File[] mLibs; private final Map<String, String> mInjectAttributes; private final String mPackageOverride; private final File mActualResult; private final String mExpectedResult; private final String mExpectedErrors; private final boolean mShouldFail; private final Map<String, Boolean> mFeatures; /** Files used by a given test case. */ public TestFiles( boolean shouldFail, @NonNull File[] overlayFiles, @NonNull File main, @NonNull File[] libs, @NonNull Map<String, Boolean> features, @NonNull Map<String, String> injectAttributes, @Nullable String packageOverride, @Nullable File actualResult, @NonNull String expectedResult, @NonNull String expectedErrors) { mShouldFail = shouldFail; mMain = main; mLibs = libs; mFeatures = features; mPackageOverride = packageOverride; mInjectAttributes = injectAttributes; mActualResult = actualResult; mExpectedResult = expectedResult; mExpectedErrors = expectedErrors; mOverlayFiles = overlayFiles; } public boolean getShouldFail() { return mShouldFail; } @NonNull public File[] getOverlayFiles() { return mOverlayFiles; } @NonNull public File getMain() { return mMain; } @NonNull public File[] getLibs() { return mLibs; } @NonNull public Map<String, Boolean> getFeatures() { return mFeatures; } @NonNull public Map<String, String> getInjectAttributes() { return mInjectAttributes; } @Nullable public String getPackageOverride() { return mPackageOverride; } @Nullable public File getActualResult() { return mActualResult; } @NonNull public String getExpectedResult() { return mExpectedResult; } public String getExpectedErrors() { return mExpectedErrors; } // Try to delete any temp file potentially created. public void cleanup() { if (mMain != null && mMain.isFile()) { mMain.delete(); } if (mActualResult != null && mActualResult.isFile()) { mActualResult.delete(); } for (File f : mLibs) { if (f != null && f.isFile()) { f.delete(); } } } } /** * Calls {@link #loadTestData(String)} by * inferring the data filename from the caller's method name. * <p/> * The caller method name must be composed of "test" + the leaf filename. * Extensions ".xml" or ".txt" are implied. * <p/> * E.g. to use the data file "12_foo.xml", simply call this from a method * named "test12_foo". * * @return A new {@link TestFiles} instance. Never null. * @throws Exception when things go wrong. * @see #loadTestData(String) */ @NonNull TestFiles loadTestData() throws Exception { StackTraceElement[] stack = Thread.currentThread().getStackTrace(); for (int i = 0, n = stack.length; i < n; i++) { StackTraceElement caller = stack[i]; String name = caller.getMethodName(); if (name.startsWith("test")) { return loadTestData(name.substring(4)); } } throw new IllegalArgumentException("No caller method found which name started with 'test'"); } /** * Returns the relative path the test data directory */ protected String getTestDataDirectory() { return "data"; } /** * Loads test data for a given test case. * The input (main + libs) are stored in temp files. * A new destination temp file is created to store the actual result output. * The expected result is actually kept in a string. * <p/> * Data File Syntax: * <ul> * <li> Lines starting with # are ignored (anywhere, as long as # is the first char). * <li> Lines before the first {@code @delimiter} are ignored. * <li> Empty lines just after the {@code @delimiter} * and before the first < XML line are ignored. * <li> Valid delimiters are {@code @main} for the XML of the main app manifest. * <li> Following delimiters are {@code @libXYZ}, read in the order of definition. * The name can be anything as long as it starts with "{@code @lib}". * </ul> * * @param filename The test data filename. If no extension is provided, this will * try with .xml or .txt. Must not be null. * @return A new {@link TestFiles} instance. Must not be null. * @throws Exception when things fail to load properly. */ @NonNull TestFiles loadTestData(@NonNull String filename) throws Exception { String resName = getTestDataDirectory() + File.separator + filename; InputStream is = null; BufferedReader reader = null; BufferedWriter writer = null; try { is = this.getClass().getResourceAsStream(resName); if (is == null && !filename.endsWith(".xml")) { String resName2 = resName + ".xml"; is = this.getClass().getResourceAsStream(resName2); if (is != null) { filename = resName2; } } if (is == null && !filename.endsWith(".txt")) { String resName3 = resName + ".txt"; is = this.getClass().getResourceAsStream(resName3); if (is != null) { filename = resName3; } } assertNotNull("Test data file not found for " + filename, is); reader = new BufferedReader(new InputStreamReader(is, "UTF-8")); // Get the temporary directory to use. Just create a temp file, extracts its // directory and remove the file. File tempFile = File.createTempFile(this.getClass().getSimpleName(), ".tmp"); File tempDir = tempFile.getParentFile(); if (!tempFile.delete()) { tempFile.deleteOnExit(); } String line = null; String delimiter = null; boolean skipEmpty = true; boolean shouldFail = false; Map<String, Boolean> features = new HashMap<String, Boolean>(); String packageOverride = null; Map<String, String> injectAttributes = new HashMap<String, String>(); StringBuilder expectedResult = new StringBuilder(); StringBuilder expectedErrors = new StringBuilder(); File mainFile = null; File actualResultFile = null; List<File> libFiles = new ArrayList<File>(); List<File> overlayFiles = new ArrayList<File>(); int tempIndex = 0; while ((line = reader.readLine()) != null) { if (skipEmpty && line.trim().isEmpty()) { continue; } if (!line.isEmpty() && line.charAt(0) == '#') { continue; } if (!line.isEmpty() && line.charAt(0) == '@') { delimiter = line.substring(1); assertTrue( "Unknown delimiter @" + delimiter + " in " + filename, delimiter.startsWith(DELIM_OVERLAY) || delimiter.startsWith(DELIM_LIB) || delimiter.equals(DELIM_MAIN) || delimiter.equals(DELIM_RESULT) || delimiter.equals(DELIM_ERRORS) || delimiter.equals(DELIM_FAILS) || delimiter.equals(DELIM_FEATURES) || delimiter.equals(DELIM_INJECT_ATTR) || delimiter.equals(DELIM_PACKAGE)); skipEmpty = true; if (writer != null) { try { writer.close(); } catch (IOException ignore) {} writer = null; } if (delimiter.equals(DELIM_FAILS)) { shouldFail = true; } else if (!delimiter.equals(DELIM_ERRORS) && !delimiter.equals(DELIM_FEATURES) && !delimiter.equals(DELIM_INJECT_ATTR) && !delimiter.equals(DELIM_PACKAGE)) { tempFile = new File(tempDir, String.format("%1$s%2$d_%3$s.xml", this.getClass().getSimpleName(), tempIndex++, delimiter.replaceAll("[^a-zA-Z0-9_-]", "") )); tempFile.deleteOnExit(); if (delimiter.startsWith(DELIM_OVERLAY)) { overlayFiles.add(tempFile); } else if (delimiter.startsWith(DELIM_LIB)) { libFiles.add(tempFile); } else if (delimiter.equals(DELIM_MAIN)) { mainFile = tempFile; } else if (delimiter.equals(DELIM_RESULT)) { actualResultFile = tempFile; } else { fail("Unexpected data file delimiter @" + delimiter + " in " + filename); } if (!delimiter.equals(DELIM_RESULT)) { writer = new BufferedWriter(new FileWriter(tempFile)); } } continue; } if (delimiter != null && skipEmpty && !line.isEmpty() && line.charAt(0) != '#' && line.charAt(0) != '@') { skipEmpty = false; } if (writer != null) { writer.write(line); writer.write('\n'); } else if (DELIM_RESULT.equals(delimiter)) { expectedResult.append(line).append('\n'); } else if (DELIM_ERRORS.equals(delimiter)) { expectedErrors.append(line).append('\n'); } else if (DELIM_INJECT_ATTR.equals(delimiter)) { String[] in = line.split("="); if (in != null && in.length == 2) { injectAttributes.put(in[0], "null".equals(in[1]) ? null : in[1]); } } else if (DELIM_FEATURES.equals(delimiter)) { String[] in = line.split("="); if (in != null && in.length == 2) { features.put(in[0], Boolean.parseBoolean(in[1])); } } else if (DELIM_PACKAGE.equals(delimiter)) { if (packageOverride == null) { packageOverride = line; } } } assertNotNull("Missing @" + DELIM_MAIN + " in " + filename, mainFile); assert mainFile != null; Collections.sort(libFiles); return new TestFiles( shouldFail, overlayFiles.toArray(new File[overlayFiles.size()]), mainFile, libFiles.toArray(new File[libFiles.size()]), features, injectAttributes, packageOverride, actualResultFile, expectedResult.toString(), expectedErrors.toString()); } catch (UnsupportedEncodingException e) { // BufferedReader failed to decode UTF-8, O'RLY? throw e; } finally { if (writer != null) { try { writer.close(); } catch (IOException ignore) {} } if (reader != null) { try { reader.close(); } catch (IOException ignore) {} } if (is != null) { try { is.close(); } catch (IOException ignore) {} } } } // /** // * Loads the data test files using {@link #loadTestData()} and then // * invokes {@link #processTestFiles(TestFiles)} to test them. // * // * @see #loadTestData() // * @see #processTestFiles(TestFiles) // */ // void processTestFiles() throws Exception { // processTestFiles(loadTestData()); // } /** * Processes the data from the given {@link TestFiles} by * invoking {@link ManifestMerger#process(File, File, File[], Map, String)}: * the given library files are applied consecutively to the main XML * document and the output is generated. * <p/> * Then the expected and actual outputs are loaded into a DOM, * dumped again to a String using an XML transform and compared. * This makes sure only the structure is checked and that any * formatting is ignored in the comparison. * * @param testFiles The test files to process. Must not be null. * @throws Exception when this go wrong. */ void processTestFiles(TestFiles testFiles) throws Exception { MockLog log = new MockLog(); IMergerLog mergerLog = MergerLog.wrapSdkLog(log); ManifestMerger merger = new ManifestMerger(mergerLog, new ICallback() { @Override public int queryCodenameApiLevel(@NonNull String codename) { if ("ApiCodename1".equals(codename)) { return 1; } else if ("ApiCodename10".equals(codename)) { return 10; } return ICallback.UNKNOWN_CODENAME; } }); for (Entry<String, Boolean> feature : testFiles.getFeatures().entrySet()) { Method m = merger.getClass().getMethod( feature.getKey(), new Class<?>[] { boolean.class } ); m.invoke(merger, new Object[] { feature.getValue() } ); } boolean processOK = merger.process(testFiles.getActualResult(), testFiles.getMain(), testFiles.getLibs(), testFiles.getInjectAttributes(), testFiles.getPackageOverride()); // Convert relative pathnames to absolute. String expectedErrors = testFiles.getExpectedErrors().trim(); expectedErrors = expectedErrors.replaceAll( Pattern.quote(testFiles.getMain().getName()), Matcher.quoteReplacement(testFiles.getMain().getAbsolutePath())); for (File file : testFiles.getLibs()) { expectedErrors = expectedErrors.replaceAll( Pattern.quote(file.getName()), Matcher.quoteReplacement(file.getAbsolutePath())); } StringBuilder actualErrors = new StringBuilder(); for (String s : log.getMessages()) { actualErrors.append(s); if (!s.endsWith("\n")) { actualErrors.append('\n'); } } assertEquals("Error generated during merging", expectedErrors, actualErrors.toString().trim()); if (testFiles.getShouldFail()) { assertFalse("Merge process() returned true, expected false", processOK); } else { assertTrue("Merge process() returned false, expected true", processOK); } // Test result XML. There should always be one created // since the process action does not stop on errors. log.clear(); Document document = MergerXmlUtils.parseDocument(testFiles.getActualResult(), mergerLog, merger); assertNotNull(document); assert document != null; // for Eclipse null analysis String actual = MergerXmlUtils.printXmlString(document, mergerLog); assertEquals("Error parsing actual result XML", "", log.toString()); log.clear(); document = MergerXmlUtils.parseDocument( testFiles.getExpectedResult(), mergerLog, new FileAndLine("<expected-result>", 0)); assertNotNull("Failed to parse result document: " + testFiles.getExpectedResult(),document); assert document != null; String expected = MergerXmlUtils.printXmlString(document, mergerLog); assertEquals("Error parsing expected result XML", "", log.toString()); assertEquals("Error comparing expected to actual result", expected, actual); testFiles.cleanup(); } }