001/*
002 * (C) Copyright 2006-2015 Nuxeo SA (http://nuxeo.com/) and contributors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Nuxeo - initial API and implementation
018 */
019
020package org.nuxeo.runtime.test;
021
022import static org.junit.Assert.assertEquals;
023import static org.junit.Assert.assertNotNull;
024import static org.junit.Assert.fail;
025
026import java.io.File;
027import java.io.IOException;
028import java.lang.reflect.InvocationTargetException;
029import java.lang.reflect.Method;
030import java.net.MalformedURLException;
031import java.net.URI;
032import java.net.URISyntaxException;
033import java.net.URL;
034import java.net.URLClassLoader;
035import java.util.ArrayList;
036import java.util.Enumeration;
037import java.util.HashMap;
038import java.util.HashSet;
039import java.util.List;
040import java.util.Map;
041import java.util.Properties;
042import java.util.Set;
043import java.util.jar.Attributes;
044import java.util.jar.JarFile;
045import java.util.jar.Manifest;
046
047import org.apache.commons.io.FileUtils;
048import org.apache.commons.logging.Log;
049import org.apache.commons.logging.LogFactory;
050import org.jmock.Mockery;
051import org.jmock.integration.junit4.JUnit4Mockery;
052import org.junit.After;
053import org.junit.Before;
054import org.junit.Ignore;
055import org.junit.runner.RunWith;
056import org.nuxeo.common.Environment;
057import org.nuxeo.osgi.BundleFile;
058import org.nuxeo.osgi.BundleImpl;
059import org.nuxeo.osgi.DirectoryBundleFile;
060import org.nuxeo.osgi.JarBundleFile;
061import org.nuxeo.osgi.OSGiAdapter;
062import org.nuxeo.osgi.SystemBundle;
063import org.nuxeo.osgi.SystemBundleFile;
064import org.nuxeo.osgi.application.StandaloneBundleLoader;
065import org.nuxeo.runtime.AbstractRuntimeService;
066import org.nuxeo.runtime.api.Framework;
067import org.nuxeo.runtime.model.RuntimeContext;
068import org.nuxeo.runtime.osgi.OSGiRuntimeContext;
069import org.nuxeo.runtime.osgi.OSGiRuntimeService;
070import org.nuxeo.runtime.test.runner.ConditionalIgnoreRule;
071import org.nuxeo.runtime.test.runner.Features;
072import org.nuxeo.runtime.test.runner.FeaturesRunner;
073import org.nuxeo.runtime.test.runner.MDCFeature;
074import org.nuxeo.runtime.test.runner.RandomBug;
075import org.nuxeo.runtime.test.runner.RuntimeHarness;
076import org.osgi.framework.Bundle;
077import org.osgi.framework.FrameworkEvent;
078
079/**
080 * Abstract base class for test cases that require a test runtime service.
081 * <p>
082 * The runtime service itself is conveniently available as the <code>runtime</code> instance variable in derived
083 * classes.
084 *
085 * @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
086 */
087// Make sure this class is kept in sync with with RuntimeHarness
088@RunWith(FeaturesRunner.class)
089@Features({ MDCFeature.class, ConditionalIgnoreRule.Feature.class, RandomBug.Feature.class })
090@Ignore
091public class NXRuntimeTestCase implements RuntimeHarness {
092
093    protected Mockery jmcontext = new JUnit4Mockery();
094
095    static {
096        // jul to jcl redirection may pose problems (infinite loops) in some
097        // environment
098        // where slf4j to jul, and jcl over slf4j is deployed
099        System.setProperty(AbstractRuntimeService.REDIRECT_JUL, "false");
100    }
101
102    private static final Log log = LogFactory.getLog(NXRuntimeTestCase.class);
103
104    protected OSGiRuntimeService runtime;
105
106    protected URL[] urls; // classpath urls, used for bundles lookup
107
108    protected File workingDir;
109
110    protected StandaloneBundleLoader bundleLoader;
111
112    private Set<URI> readUris;
113
114    protected Map<String, BundleFile> bundles;
115
116    protected boolean restart = false;
117
118    @Override
119    public boolean isRestart() {
120        return restart;
121    }
122
123    protected OSGiAdapter osgi;
124
125    protected Bundle runtimeBundle;
126
127    protected final List<WorkingDirectoryConfigurator> wdConfigs = new ArrayList<>();
128
129    protected final TargetResourceLocator targetResourceLocator;
130
131    public NXRuntimeTestCase() {
132        targetResourceLocator = new TargetResourceLocator(this.getClass());
133    }
134
135    public NXRuntimeTestCase(String name) {
136        this();
137    }
138
139    public NXRuntimeTestCase(Class<?> clazz) {
140        targetResourceLocator = new TargetResourceLocator(clazz);
141    }
142
143    @Override
144    public void addWorkingDirectoryConfigurator(WorkingDirectoryConfigurator config) {
145        wdConfigs.add(config);
146    }
147
148    @Override
149    public File getWorkingDir() {
150        return workingDir;
151    }
152
153    /**
154     * Restarts the runtime and preserve homes directory.
155     */
156    @Override
157    public void restart() throws Exception {
158        restart = true;
159        try {
160            tearDown();
161            setUp();
162        } finally {
163            restart = false;
164        }
165    }
166
167    @Override
168    public void start() throws Exception {
169        setUp();
170    }
171
172    @Before
173    public void setUp() throws Exception {
174        System.setProperty("org.nuxeo.runtime.testing", "true");
175        // super.setUp();
176        wipeRuntime();
177        initUrls();
178        if (urls == null) {
179            throw new UnsupportedOperationException("no bundles available");
180        }
181        initOsgiRuntime();
182    }
183
184    /**
185     * Fire the event {@code FrameworkEvent.STARTED}.
186     */
187    @Override
188    public void fireFrameworkStarted() throws Exception {
189        osgi.fireFrameworkEvent(new FrameworkEvent(FrameworkEvent.STARTED, runtimeBundle, null));
190    }
191
192    @After
193    public void tearDown() throws Exception {
194        wipeRuntime();
195        if (workingDir != null) {
196            if (!restart) {
197                if (!FileUtils.deleteQuietly(workingDir)) {
198                    log.warn("Cannot delete " + workingDir);
199                }
200                workingDir = null;
201            }
202        }
203        readUris = null;
204        bundles = null;
205    }
206
207    @Override
208    public void stop() throws Exception {
209        tearDown();
210    }
211
212    @Override
213    public boolean isStarted() {
214        return runtime != null;
215    }
216
217    protected void initOsgiRuntime() throws Exception {
218        try {
219            if (!restart) {
220                Environment.setDefault(null);
221                if (System.getProperties().remove("nuxeo.home") != null) {
222                    log.warn("Removed System property nuxeo.home.");
223                }
224                workingDir = File.createTempFile("nxruntime-" + Thread.currentThread().getName() + "-", null, new File(
225                        "target"));
226                workingDir.delete();
227            }
228        } catch (IOException e) {
229            log.error("Could not init working directory", e);
230            throw e;
231        }
232        osgi = new OSGiAdapter(workingDir);
233        BundleFile bf = new SystemBundleFile(workingDir);
234        bundleLoader = new StandaloneBundleLoader(osgi, NXRuntimeTestCase.class.getClassLoader());
235        SystemBundle systemBundle = new SystemBundle(osgi, bf, bundleLoader.getSharedClassLoader().getLoader());
236        osgi.setSystemBundle(systemBundle);
237        Thread.currentThread().setContextClassLoader(bundleLoader.getSharedClassLoader().getLoader());
238
239        for (WorkingDirectoryConfigurator cfg : wdConfigs) {
240            cfg.configure(this, workingDir);
241        }
242
243        bundleLoader.setScanForNestedJARs(false); // for now
244        bundleLoader.setExtractNestedJARs(false);
245
246        BundleFile bundleFile = lookupBundle("org.nuxeo.runtime");
247        runtimeBundle = new RootRuntimeBundle(osgi, bundleFile, bundleLoader.getClass().getClassLoader(), true);
248        runtimeBundle.start();
249
250        runtime = handleNewRuntime((OSGiRuntimeService) Framework.getRuntime());
251
252        assertNotNull(runtime);
253    }
254
255    protected OSGiRuntimeService handleNewRuntime(OSGiRuntimeService aRuntime) {
256        return aRuntime;
257    }
258
259    public static URL[] introspectClasspath(ClassLoader loader) {
260        // normal case
261        if (loader instanceof URLClassLoader) {
262            return ((URLClassLoader) loader).getURLs();
263        }
264        // surefire suite runner
265        final Class<? extends ClassLoader> loaderClass = loader.getClass();
266        if (loaderClass.getName().equals("org.apache.tools.ant.AntClassLoader")) {
267            try {
268                Method method = loaderClass.getMethod("getClasspath");
269                String cp = (String) method.invoke(loader);
270                String[] paths = cp.split(File.pathSeparator);
271                URL[] urls = new URL[paths.length];
272                for (int i = 0; i < paths.length; i++) {
273                    urls[i] = new URL("file:" + paths[i]);
274                }
275                return urls;
276            } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException
277                    | InvocationTargetException | MalformedURLException cause) {
278                throw new AssertionError("Cannot introspect mavent class loader", cause);
279            }
280        }
281        // try getURLs method
282        try {
283            Method m = loaderClass.getMethod("getURLs");
284            return (URL[]) m.invoke(loader);
285        } catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException
286                | InvocationTargetException cause) {
287            throw new AssertionError("Unsupported classloader type: " + loaderClass.getName()
288                    + "\nWon't be able to load OSGI bundles");
289        }
290    }
291
292    protected void initUrls() throws Exception {
293        ClassLoader classLoader = NXRuntimeTestCase.class.getClassLoader();
294        urls = introspectClasspath(classLoader);
295        // special cases such as Surefire with useManifestOnlyJar or Jacoco
296        // Look for nuxeo-runtime
297        boolean found = false;
298        JarFile surefirebooterJar = null;
299        for (URL url : urls) {
300            URI uri = url.toURI();
301            if (uri.getPath().matches(".*/nuxeo-runtime-[^/]*\\.jar")) {
302                found = true;
303                break;
304            } else if (uri.getScheme().equals("file") && uri.getPath().contains("surefirebooter")) {
305                surefirebooterJar = new JarFile(new File(uri));
306            }
307        }
308        if (!found && surefirebooterJar != null) {
309            try {
310                String cp = surefirebooterJar.getManifest().getMainAttributes().getValue(Attributes.Name.CLASS_PATH);
311                if (cp != null) {
312                    String[] cpe = cp.split(" ");
313                    URL[] newUrls = new URL[cpe.length];
314                    for (int i = 0; i < cpe.length; i++) {
315                        // Don't need to add 'file:' with maven surefire
316                        // >= 2.4.2
317                        String newUrl = cpe[i].startsWith("file:") ? cpe[i] : "file:" + cpe[i];
318                        newUrls[i] = new URL(newUrl);
319                    }
320                    urls = newUrls;
321                }
322            } catch (Exception e) {
323                // skip
324            } finally {
325                surefirebooterJar.close();
326            }
327        }
328        if (log.isDebugEnabled()) {
329            StringBuilder sb = new StringBuilder();
330            sb.append("URLs on the classpath: ");
331            for (URL url : urls) {
332                sb.append(url.toString());
333                sb.append('\n');
334            }
335            log.debug(sb.toString());
336        }
337        readUris = new HashSet<>();
338        bundles = new HashMap<>();
339    }
340
341    /**
342     * Makes sure there is no previous runtime hanging around.
343     * <p>
344     * This happens for instance if a previous test had errors in its <code>setUp()</code>, because
345     * <code>tearDown()</code> has not been called.
346     */
347    protected void wipeRuntime() throws Exception {
348        // Make sure there is no active runtime (this might happen if an
349        // exception is raised during a previous setUp -> tearDown is not called
350        // afterwards).
351        runtime = null;
352        if (Framework.getRuntime() != null) {
353            Framework.shutdown();
354        }
355    }
356
357    public static URL getResource(String name) {
358        final ClassLoader loader = Thread.currentThread().getContextClassLoader();
359        String callerName = Thread.currentThread().getStackTrace()[2].getClassName();
360        final String relativePath = callerName.replace('.', '/').concat(".class");
361        final String fullPath = loader.getResource(relativePath).getPath();
362        final String basePath = fullPath.substring(0, fullPath.indexOf(relativePath));
363        Enumeration<URL> resources;
364        try {
365            resources = loader.getResources(name);
366            while (resources.hasMoreElements()) {
367                URL resource = resources.nextElement();
368                if (resource.getPath().startsWith(basePath)) {
369                    return resource;
370                }
371            }
372        } catch (IOException e) {
373            return null;
374        }
375        return loader.getResource(name);
376    }
377
378    /**
379     * @deprecated use <code>deployContrib()</code> instead
380     */
381    @Override
382    @Deprecated
383    public void deploy(String contrib) {
384        deployContrib(contrib);
385    }
386
387    protected void deployContrib(URL url) {
388        assertEquals(runtime, Framework.getRuntime());
389        log.info("Deploying contribution from " + url.toString());
390        try {
391            runtime.getContext().deploy(url);
392        } catch (Exception e) {
393            fail("Failed to deploy contrib " + url.toString());
394        }
395    }
396
397    /**
398     * Deploys a contribution file by looking for it in the class loader.
399     * <p>
400     * The first contribution file found by the class loader will be used. You have no guarantee in case of name
401     * collisions.
402     *
403     * @deprecated use the less ambiguous {@link #deployContrib(String, String)}
404     * @param contrib the relative path to the contribution file
405     */
406    @Override
407    @Deprecated
408    public void deployContrib(String contrib) {
409        URL url = getResource(contrib);
410        assertNotNull("Test contribution not found: " + contrib, url);
411        deployContrib(url);
412    }
413
414    /**
415     * Deploys a contribution from a given bundle.
416     * <p>
417     * The path will be relative to the bundle root. Example: <code>
418     * deployContrib("org.nuxeo.ecm.core", "OSGI-INF/CoreExtensions.xml")
419     * </code>
420     * <p>
421     * For compatibility reasons the name of the bundle may be a jar name, but this use is discouraged and deprecated.
422     *
423     * @param name the name of the bundle to peek the contrib in
424     * @param contrib the path to contrib in the bundle.
425     */
426    @Override
427    public void deployContrib(String name, String contrib) throws Exception {
428        RuntimeContext context = runtime.getContext(name);
429        if (context == null) {
430            context = runtime.getContext();
431            BundleFile file = lookupBundle(name);
432            URL location = file.getEntry(contrib);
433            if (location == null) {
434                throw new AssertionError("Cannot locate " + contrib + " in " + name);
435            }
436            context.deploy(location);
437            return;
438        }
439        context.deploy(contrib);
440    }
441
442    /**
443     * Deploy an XML contribution from outside a bundle.
444     * <p>
445     * This should be used by tests wiling to deploy test contribution as part of a real bundle.
446     * <p>
447     * The bundle owner is important since the contribution may depend on resources deployed in that bundle.
448     * <p>
449     * Note that the owner bundle MUST be an already deployed bundle.
450     *
451     * @param bundle the bundle that becomes the contribution owner
452     * @param contrib the contribution to deploy as part of the given bundle
453     */
454    @Override
455    public RuntimeContext deployTestContrib(String bundle, String contrib) throws Exception {
456        URL url = targetResourceLocator.getTargetTestResource(contrib);
457        return deployTestContrib(bundle, url);
458    }
459
460    @Override
461    public RuntimeContext deployTestContrib(String bundle, URL contrib) throws Exception {
462        Bundle b = bundleLoader.getOSGi().getRegistry().getBundle(bundle);
463        if (b == null) {
464            b = osgi.getSystemBundle();
465        }
466        OSGiRuntimeContext ctx = new OSGiRuntimeContext(runtime, b);
467        ctx.deploy(contrib);
468        return ctx;
469    }
470
471    /**
472     * @deprecated use {@link #undeployContrib(String, String)} instead
473     */
474    @Override
475    @Deprecated
476    public void undeploy(String contrib) {
477        undeployContrib(contrib);
478    }
479
480    /**
481     * @deprecated use {@link #undeployContrib(String, String)} instead
482     */
483    @Override
484    @Deprecated
485    public void undeployContrib(String contrib) {
486        URL url = getResource(contrib);
487        assertNotNull("Test contribution not found: " + contrib, url);
488        try {
489            runtime.getContext().undeploy(url);
490        } catch (Exception e) {
491            fail("Failed to undeploy contrib " + url.toString());
492        }
493    }
494
495    /**
496     * Undeploys a contribution from a given bundle.
497     * <p>
498     * The path will be relative to the bundle root. Example: <code>
499     * undeployContrib("org.nuxeo.ecm.core", "OSGI-INF/CoreExtensions.xml")
500     * </code>
501     *
502     * @param name the bundle
503     * @param contrib the contribution
504     */
505    @Override
506    public void undeployContrib(String name, String contrib) throws Exception {
507        RuntimeContext context = runtime.getContext(name);
508        if (context == null) {
509            context = runtime.getContext();
510        }
511        context.undeploy(contrib);
512    }
513
514    protected static boolean isVersionSuffix(String s) {
515        if (s.length() == 0) {
516            return true;
517        }
518        return s.matches("-(\\d+\\.?)+(-SNAPSHOT)?(\\.\\w+)?");
519    }
520
521    /**
522     * Resolves an URL for bundle deployment code.
523     * <p>
524     * TODO: Implementation could be finer...
525     *
526     * @return the resolved url
527     */
528    protected URL lookupBundleUrl(String bundle) {
529        for (URL url : urls) {
530            String[] pathElts = url.getPath().split("/");
531            for (int i = 0; i < pathElts.length; i++) {
532                if (pathElts[i].startsWith(bundle) && isVersionSuffix(pathElts[i].substring(bundle.length()))) {
533                    // we want the main version of the bundle
534                    boolean isTestVersion = false;
535                    for (int j = i + 1; j < pathElts.length; j++) {
536                        // ok for Eclipse (/test) and Maven (/test-classes)
537                        if (pathElts[j].startsWith("test")) {
538                            isTestVersion = true;
539                            break;
540                        }
541                    }
542                    if (!isTestVersion) {
543                        log.info("Resolved " + bundle + " as " + url.toString());
544                        return url;
545                    }
546                }
547            }
548        }
549        throw new RuntimeException("Could not resolve bundle " + bundle);
550    }
551
552    /**
553     * Deploys a whole OSGI bundle.
554     * <p>
555     * The lookup is first done on symbolic name, as set in <code>MANIFEST.MF</code> and then falls back to the bundle
556     * url (e.g., <code>nuxeo-platform-search-api</code>) for backwards compatibility.
557     *
558     * @param name the symbolic name
559     */
560    @Override
561    public void deployBundle(String name) throws Exception {
562        // install only if not yet installed
563        BundleImpl bundle = bundleLoader.getOSGi().getRegistry().getBundle(name);
564        if (bundle == null) {
565            BundleFile bundleFile = lookupBundle(name);
566            bundleLoader.loadBundle(bundleFile);
567            bundleLoader.installBundle(bundleFile);
568            bundle = bundleLoader.getOSGi().getRegistry().getBundle(name);
569        }
570        if (runtime.getContext(bundle) == null) {
571            runtime.createContext(bundle);
572        }
573    }
574
575    protected String readSymbolicName(BundleFile bf) {
576        Manifest manifest = bf.getManifest();
577        if (manifest == null) {
578            return null;
579        }
580        Attributes attrs = manifest.getMainAttributes();
581        String name = attrs.getValue("Bundle-SymbolicName");
582        if (name == null) {
583            return null;
584        }
585        String[] sp = name.split(";", 2);
586        return sp[0];
587    }
588
589    public BundleFile lookupBundle(String bundleName) throws Exception {
590        BundleFile bundleFile = bundles.get(bundleName);
591        if (bundleFile != null) {
592            return bundleFile;
593        }
594        for (URL url : urls) {
595            URI uri = url.toURI();
596            if (readUris.contains(uri)) {
597                continue;
598            }
599            File file = new File(uri);
600            readUris.add(uri);
601            try {
602                if (file.isDirectory()) {
603                    bundleFile = new DirectoryBundleFile(file);
604                } else {
605                    bundleFile = new JarBundleFile(file);
606                }
607            } catch (IOException e) {
608                // no manifest => not a bundle
609                continue;
610            }
611            String symbolicName = readSymbolicName(bundleFile);
612            if (symbolicName != null) {
613                log.info(String.format("Bundle '%s' has URL %s", symbolicName, url));
614                bundles.put(symbolicName, bundleFile);
615            }
616            if (bundleName.equals(symbolicName)) {
617                return bundleFile;
618            }
619        }
620        log.warn(String.format("No bundle with symbolic name '%s'; Falling back to deprecated url lookup scheme",
621                bundleName));
622        return oldLookupBundle(bundleName);
623    }
624
625    @Deprecated
626    protected BundleFile oldLookupBundle(String bundle) throws Exception {
627        URL url = lookupBundleUrl(bundle);
628        File file = new File(url.toURI());
629        BundleFile bundleFile;
630        if (file.isDirectory()) {
631            bundleFile = new DirectoryBundleFile(file);
632        } else {
633            bundleFile = new JarBundleFile(file);
634        }
635        log.warn(String.format(
636                "URL-based bundle lookup is deprecated. Please use the symbolic name from MANIFEST (%s) instead",
637                readSymbolicName(bundleFile)));
638        return bundleFile;
639    }
640
641    @Override
642    public void deployFolder(File folder, ClassLoader loader) throws Exception {
643        DirectoryBundleFile bf = new DirectoryBundleFile(folder);
644        BundleImpl bundle = new BundleImpl(osgi, bf, loader);
645        osgi.install(bundle);
646    }
647
648    @Override
649    public Properties getProperties() {
650        return runtime.getProperties();
651    }
652
653    @Override
654    public RuntimeContext getContext() {
655        return runtime.getContext();
656    }
657
658    @Override
659    public OSGiAdapter getOSGiAdapter() {
660        return osgi;
661    }
662
663    /*
664     * (non-Javadoc)
665     * @see org.nuxeo.runtime.test.runner.RuntimeHarness#getClassLoaderFiles()
666     */
667    @Override
668    public List<String> getClassLoaderFiles() throws URISyntaxException {
669        List<String> files = new ArrayList<>(urls.length);
670        for (URL url : urls) {
671            files.add(url.toURI().getPath());
672        }
673        return files;
674    }
675
676}