#! /usr/bin/env python
# -*- coding: utf-8 -*-
# ##########################################################################
#
# This program is part of Zenoss Core, an open source monitoring platform.
# Copyright (C) 2008, Zenoss Inc.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 2 as published by
# the Free Software Foundation.
#
# For complete information please visit: http://www.zenoss.com/oss/
#
# ##########################################################################

__doc__ = """runtests
Run unit and Selenium (functional) tests for Zenoss
"""

import os
import re
import os.path
import glob
import sys
from subprocess import call
import optparse
import time

STDOUT = sys.stdout

# Remove script directory from path
scriptdir = os.path.realpath(os.path.dirname(sys.argv[0]))
sys.path[:] = [p for p in sys.path if os.path.realpath(p)!=scriptdir]

ZENHOME = os.environ['ZENHOME']
zenhome = lambda *args: os.path.join(ZENHOME, *args)

PYTHON = zenhome('bin', 'python')
CONFIG = zenhome('etc', 'zope.conf')
SOFTWARE_HOME = zenhome('lib', 'python')
PRODUCTS = zenhome('Products')

# add SOFTWARE_HOME to sys.path, but only if Zope isn't available
try:
    import Zope2
except ImportError:
    sys.path.insert(0, SOFTWARE_HOME)

exitcodes = []

def runZopeTests(options):
    from zope.testing import testrunner
    from zope.testing.testrunner.options import setup

    def load_config_file(option, opt, config_file, *ignored):
        print "Parsing %s" % config_file
        import Zope2
        Zope2.configure(config_file)

    setup.add_option( '--config-file', action='callback', type='string',
                     dest='config_file', callback=load_config_file)

    defaults = '--tests-pattern ^tests$ -v'.split()
    defaults += ['--config-file', CONFIG]
    defaults += ['-m',
                 '!^('
                 'ZConfig'
                 '|'
                 'BTrees'
                 '|'
                 'persistent'
                 '|'
                 'ThreadedAsync'
                 '|'
                 'transaction'
                 '|'
                 'ZEO'
                 '|'
                 'ZODB'
                 '|'
                 'ZopeUndo'
                 '|'
                 'zdaemon'
                 '|'
                 'zope[.]testing'
                 '|'
                 'zope[.]app'
                 ')[.]']
    defaults += ['--path', SOFTWARE_HOME]
    defaults += ['--package-path', PRODUCTS, 'Products']
    sys.exit(testrunner.run(defaults, options))


def overrideCoreTests(results):
    """
    Check to see if a commercial skin is installed. If so, don't run the core
    tests.

    @param results: list of directories
    @type results: list of strings
    @return: results without core tests
    @rtype: list of strings
    """
    commercial = False
    for result in results:
        if 'Skin' in result:
            commercial = True
    if commercial:
        results.remove(zenhome('Products', 'ZenUITests'))
    return results


def findSeleniumTests(packages=[], regex=None):
    """
    Find Selenium tests

    @param packages: packages
    @type packages: string
    @param regex: regular expression to determine to include or not
    @type regex: string
    @return: directories containing Selenium tests
    @rtype: list of strings
    """
    prods = findSeleniumTestableProducts(packages, regex,
                                 testdir='tests/selenium')
    results = []
    if not regex:
        regex = 'testAll'
    regex += '.py'
    for prod in prods:
        selpath = os.path.join(prod, 'tests', 'selenium', regex)
        if os.path.exists(selpath):
            results.append(selpath)
    return results


def demangleEggName(eggdir, name):
    """
    Deals with Python egg edge cases to expand out the directories.
    Python eggs mangle the names, so '-' gets changed
    to '_' at a *lower* level subdirectory.

    @param eggdir: path to the ZenPack
    @type eggdir: string
    @param name: name of the ZenPack
    @type name: string
    @return: path to the ZenPack
    @rtype: string
    """
    path = eggdir
    components = name.split( '.', 2 ) # ie a list with three items
    # Note, we discard the last item to satisfy findTestableProducts
    for component in components[0:1]:
        if os.path.isdir( os.path.join( path, component )):
            path = os.path.join( path, component )
            continue

        newcomponent = component.split( '-', 1 )[0] + '*'
        found = glob.glob( os.path.join( os.path.join( path, newcomponent )) )
        if len(found) != 1:
            # Ouch! Something bad happened
            print "Unable to find egg directory from %s and %s" % \
                  (path, component)
            return eggdir
        path = os.path.join( path, found[0] )
    return path


def expandPackDir(fulldir):
    """
    Expand Zenoss ZenPack directory names.

    This handles old-style ZenPacks as well as egg-style.

    @param fulldir: path to the ZenPack
    @type fulldir: string
    @return: path to the ZenPack
    @rtype: string
    """
    name = os.path.basename(fulldir)
    if not name.endswith('.egg'):
        # Old-style ZenPack
        return fulldir
    return demangleEggName(fulldir, name)


_packname = re.compile('ZenPacks\.[^-/]+\.[^-/]+').search
def zenPackName(s):
    match = _packname(s)
    if match:
        return match.group()
    return None

def findZenPackNames():
    dirs = findZenPackDirectories()
    return filter(None, map(zenPackName, dirs))


def findZenPackDirectories():
    """
    Get the list of ZenPacks with tests

    @return: list of ZenPack directories
    @rtype: list of strings
    """
    zenpackhome = zenhome('ZenPacks')
    dirs = []
    try:
        for item in os.listdir(zenpackhome):
            fullpath = zenhome('ZenPacks', item)
            if os.path.isdir(fullpath):
                dir = expandPackDir(fullpath)
            elif item.endswith('.egg-link'):
                f = file(fullpath)
                dir = expandPackDir(f.readline().strip())
                f.close()
            else:
                continue
            if not dir.endswith('ZenPacks'):
                dir = os.path.join(dir, 'ZenPacks')
            dirs.append(dir)
    except OSError:
        pass
    return dirs


def findZenossProducts():
    """
    Get all Zenoss products + ZenPacks.
    """
    validProds = ['Products.'+x for x in (
        'DataCollector',
        'Jobber',
        'ZenEvents',
        'ZenHub',
        'ZenModel',
        'ZenRRD',
        'ZenRelations',
        'ZenReports',
        'ZenStatus',
        'ZenUtils',
        'ZenWidgets',
        'ZenWin',
        'Zuul'
    )]
    zenpacks = findZenPackNames()
    return validProds + zenpacks


def findSeleniumTestableProducts(packages=[], regex=None, testdir='tests'):
    """
    Get the list of Zope Products with tests

    @param packages: packages to run tests
    @type packages: list of strings
    @param regex: regular expression to determine to include or not
    @type regex: string
    @param testdir: directory name containing test files
    @type testdir: string
    @return: list of Zope Product directories
    @rtype: list of strings
    """
    results = []
    for target in findZenPackDirectories() + [zenhome('Products')]:
        for root, dirs, files in os.walk(target):

            # don't look past a lib directory that is under the ZenPacks
            # directory
            rootParts = root.split(os.path.sep)
            if 'ZenPacks' in rootParts and \
                    'lib' in rootParts[rootParts.index('ZenPacks'):]:
                continue

            for dir in dirs:
                if (packages and 
                    # ZenPacks have a problem unless you do this
                    target.split('/')[-1] not in packages and 
                    dir not in packages):
                        continue
                # Sigh. We need to make sure no-one ends a ZenPack with 'Products'
                if target.endswith('Products'):
                    if not (dir.startswith('Zen') or dir
                             == 'DataCollector') or dir\
                         in 'ZenTestRunner':
                        continue
                newdir = os.path.join(root, dir)
                if testdir in os.listdir(newdir):
                    init_file =  os.path.join(newdir, testdir, '__init__.py')
                    if not os.path.exists(init_file):
                        print "Warning: missing the %s file -- skipping %s" \
                              % (init_file, target)
                    elif regex:
                        f = os.path.join(newdir, testdir, regex + '.py')
                        if os.path.exists(f):
                            results.append(newdir)
                    else:
                        results.append(newdir)
    results = overrideCoreTests(results)
    if not results:
        print "No %s directories found for %s" % (testdir, packages)
    return results


def runSeleniumTests(
    packages=[],
    regex=None,
    zenoss_server=None,
    selenium_server=None,
    ):
    """
    Run any Selenium tests that match the regular expression.

    @param packages: packages
    @type packages: list of strings
    @param regex: Regular expression
    @type regex: string
    @param zenoss_server: Zenoss server
    @type zenoss_server: string
    @param selenium_server: Selenium server name
    @type selenium_server: string
    """
    tests = findSeleniumTests(packages, regex)
    for testscript in tests:
        command = ['python', testscript]
        for arg in (zenoss_server, selenium_server):
            if arg is not None:
                command.append(arg)
        rc = call(command)
        exitcodes.append(rc)


def runUnitTests(packages=[], modules=[], names=[], coverage="", count=0):
    """
    Run unit tests for any packages that match the regular expression.

    @param packages: packages
    @type packages: list of strings
    @param regex: regular expression
    @type regex: string
    @param coverage: coverage
    @type coverage: string
    @param show_tests: run or just display the test name?
    @type show_tests: boolean
    """
    valid_packages = findZenossProducts()
    invalid_packages = []
    if not modules:
        modules = []
    if not packages:
        packages = valid_packages
    for pkg in packages:
        if pkg not in valid_packages:
            packages.remove(pkg)
            invalid_packages.append(pkg)
    print "="*30
    print
    print "Packages to be tested:"
    for p in packages:
        print "\t" + p
    print
    if invalid_packages:
        print "Invalid packages:"
        for p in invalid_packages:
            print '\t' + p
    print "="*30

    #parts = [python, zdctl, '-C', cfgfile, 'test']
    parts = ['--config-file', CONFIG]

    # Add ZenPack homes to package directories
    for d in findZenPackDirectories():
        path = d.rsplit('/', 1)[0]
        name = zenPackName(d)
        if name in packages or name in modules:
            parts.extend(['--test-path', path])
            packdir = os.path.join(path, *name.split('.'))
            libdir = os.path.join(packdir, 'lib')
            if os.path.exists(libdir):
                parts.extend(['--ignore_dir', 'lib'])

    if packages:
        for p in packages: parts.extend(['-s', p])
    if modules:
        for m in modules: parts.extend(['-m', m])
    if names:
        for t in names: parts.extend(['-t', t])
    if count:
        parts.append('-'+('v'*count))
    if coverage: parts.extend(['--coverage', coverage])
    if packages or modules or names:
        sys.argv[:] = sys.argv[:1]
        runZopeTests(parts)


usage = \
    """%prog [options] [package1 [package2]]

Run Zenoss tests against specified packages.  

Packages are the names of directories in $ZENHOME/Products. If no
packages are specified, tests will be executed against all testable
Zenoss packages.

Valid test types are:
    unit        Run unit tests and doctests.
    selenium    Run selenium tests.
    all         Run all tests.

Individual test modules may also be specified. For example, to run only
the Device tests, use:
    runtests --type unit --name testDevice ZenModel
"""


def main():
    parser = optparse.OptionParser(prog='runtests', usage=usage)
    parser.add_option('-t', '--type', help='The test types to run.', 
                      default='unit')
    parser.add_option('-c', '--coverage', help='Dir to store coverage stats')
    parser.add_option('-v', dest="count", action="count", help="Verbosity of test output")
    parser.add_option('-m', '--module', action="append", 
                      help='The name of a test module.')
    parser.add_option('-n', '--name', action="append", 
                      help='The name of an individual test')
    parser.add_option('--selenium-server',
                      help='The server hosting the Selenium jar')
    parser.add_option('--zenoss-server',
                      help='The Zenoss server against which Selenium should test'
                      )

    (options, args) = parser.parse_args()

    if options.type not in ('unit', 'selenium', 'all'):
        print "Unknown test type %s -- exiting" % options.type
        sys.exit(1)

    if options.type in ('unit', 'all'):
        runUnitTests(args, options.module, options.name, options.coverage,
                     options.count)

    if options.type in ('selenium', 'all'):
        runSeleniumTests(args, options.name, options.zenoss_server,
                         options.selenium_server)


if __name__ == '__main__':
    start = time.time()
    main()
    if exitcodes:
        code = max(exitcodes)
    else:
        code = 0
    sys.exit(code)

