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