var EventSupport = Blaze._EventSupport = {};

var DOMBackend = Blaze._DOMBackend;

// List of events to always delegate, never capture.
// Since jQuery fakes bubbling for certain events in
// certain browsers (like `submit`), we don't want to
// get in its way.
//
// We could list all known bubbling
// events here to avoid creating speculative capturers
// for them, but it would only be an optimization.
var eventsToDelegate = EventSupport.eventsToDelegate = {
  blur: 1, change: 1, click: 1, focus: 1, focusin: 1,
  focusout: 1, reset: 1, submit: 1
};

var EVENT_MODE = EventSupport.EVENT_MODE = {
  TBD: 0,
  BUBBLING: 1,
  CAPTURING: 2
};

var NEXT_HANDLERREC_ID = 1;

var HandlerRec = function (elem, type, selector, handler, recipient) {
  this.elem = elem;
  this.type = type;
  this.selector = selector;
  this.handler = handler;
  this.recipient = recipient;
  this.id = (NEXT_HANDLERREC_ID++);

  this.mode = EVENT_MODE.TBD;

  // It's important that delegatedHandler be a different
  // instance for each handlerRecord, because its identity
  // is used to remove it.
  //
  // It's also important that the closure have access to
  // `this` when it is not called with it set.
  this.delegatedHandler = (function (h) {
    return function (evt) {
      if ((! h.selector) && evt.currentTarget !== evt.target)
        // no selector means only fire on target
        return;
      return h.handler.apply(h.recipient, arguments);
    };
  })(this);

  // WHY CAPTURE AND DELEGATE: jQuery can't delegate
  // non-bubbling events, because
  // event capture doesn't work in IE 8.  However, there
  // are all sorts of new-fangled non-bubbling events
  // like "play" and "touchenter".  We delegate these
  // events using capture in all browsers except IE 8.
  // IE 8 doesn't support these events anyway.

  var tryCapturing = elem.addEventListener &&
        (! _.has(eventsToDelegate,
                 DOMBackend.Events.parseEventType(type)));

  if (tryCapturing) {
    this.capturingHandler = (function (h) {
      return function (evt) {
        if (h.mode === EVENT_MODE.TBD) {
          // must be first time we're called.
          if (evt.bubbles) {
            // this type of event bubbles, so don't
            // get called again.
            h.mode = EVENT_MODE.BUBBLING;
            DOMBackend.Events.unbindEventCapturer(
              h.elem, h.type, h.capturingHandler);
            return;
          } else {
            // this type of event doesn't bubble,
            // so unbind the delegation, preventing
            // it from ever firing.
            h.mode = EVENT_MODE.CAPTURING;
            DOMBackend.Events.undelegateEvents(
              h.elem, h.type, h.delegatedHandler);
          }
        }

        h.delegatedHandler(evt);
      };
    })(this);

  } else {
    this.mode = EVENT_MODE.BUBBLING;
  }
};
EventSupport.HandlerRec = HandlerRec;

HandlerRec.prototype.bind = function () {
  // `this.mode` may be EVENT_MODE_TBD, in which case we bind both. in
  // this case, 'capturingHandler' is in charge of detecting the
  // correct mode and turning off one or the other handlers.
  if (this.mode !== EVENT_MODE.BUBBLING) {
    DOMBackend.Events.bindEventCapturer(
      this.elem, this.type, this.selector || '*',
      this.capturingHandler);
  }

  if (this.mode !== EVENT_MODE.CAPTURING)
    DOMBackend.Events.delegateEvents(
      this.elem, this.type,
      this.selector || '*', this.delegatedHandler);
};

HandlerRec.prototype.unbind = function () {
  if (this.mode !== EVENT_MODE.BUBBLING)
    DOMBackend.Events.unbindEventCapturer(this.elem, this.type,
                                          this.capturingHandler);

  if (this.mode !== EVENT_MODE.CAPTURING)
    DOMBackend.Events.undelegateEvents(this.elem, this.type,
                                       this.delegatedHandler);
};

EventSupport.listen = function (element, events, selector, handler, recipient, getParentRecipient) {

  // Prevent this method from being JITed by Safari.  Due to a
  // presumed JIT bug in Safari -- observed in Version 7.0.6
  // (9537.78.2) -- this method may crash the Safari render process if
  // it is JITed.
  // Repro: https://github.com/dgreensp/public/tree/master/safari-crash
  try { element = element; } finally {}

  var eventTypes = [];
  events.replace(/[^ /]+/g, function (e) {
    eventTypes.push(e);
  });

  var newHandlerRecs = [];
  for (var i = 0, N = eventTypes.length; i < N; i++) {
    var type = eventTypes[i];

    var eventDict = element.$blaze_events;
    if (! eventDict)
      eventDict = (element.$blaze_events = {});

    var info = eventDict[type];
    if (! info) {
      info = eventDict[type] = {};
      info.handlers = [];
    }
    var handlerList = info.handlers;
    var handlerRec = new HandlerRec(
      element, type, selector, handler, recipient);
    newHandlerRecs.push(handlerRec);
    handlerRec.bind();
    handlerList.push(handlerRec);
    // Move handlers of enclosing ranges to end, by unbinding and rebinding
    // them.  In jQuery (or other DOMBackend) this causes them to fire
    // later when the backend dispatches event handlers.
    if (getParentRecipient) {
      for (var r = getParentRecipient(recipient); r;
           r = getParentRecipient(r)) {
        // r is an enclosing range (recipient)
        for (var j = 0, Nj = handlerList.length;
             j < Nj; j++) {
          var h = handlerList[j];
          if (h.recipient === r) {
            h.unbind();
            h.bind();
            handlerList.splice(j, 1); // remove handlerList[j]
            handlerList.push(h);
            j--; // account for removed handler
            Nj--; // don't visit appended handlers
          }
        }
      }
    }
  }

  return {
    // closes over just `element` and `newHandlerRecs`
    stop: function () {
      var eventDict = element.$blaze_events;
      if (! eventDict)
        return;
      // newHandlerRecs has only one item unless you specify multiple
      // event types.  If this code is slow, it's because we have to
      // iterate over handlerList here.  Clearing a whole handlerList
      // via stop() methods is O(N^2) in the number of handlers on
      // an element.
      for (var i = 0; i < newHandlerRecs.length; i++) {
        var handlerToRemove = newHandlerRecs[i];
        var info = eventDict[handlerToRemove.type];
        if (! info)
          continue;
        var handlerList = info.handlers;
        for (var j = handlerList.length - 1; j >= 0; j--) {
          if (handlerList[j] === handlerToRemove) {
            handlerToRemove.unbind();
            handlerList.splice(j, 1); // remove handlerList[j]
          }
        }
      }
      newHandlerRecs.length = 0;
    }
  };
};


// XXX slow - perhaps we should only do this lazily

var bundlePath = path.join(localPath, 'build-tar');

// XXX the project-level cordova plugins deps override the package-level ones

keep keep keep replace replace replace keep keep keep

var bundle = getBundle(bundlePath, [webArchName], options);

var fs = require('fs');
var path = require('path');
var _ = require('underscore');
var catalog = require('./catalog.js');
var utils = require('./utils.js');
var buildmessage = require('./buildmessage.js');
var unipackage = require('./unipackage.js');
var tropohouse = require('./tropohouse.js');

// options:
//  - versions: a map from package name to the version to use.  or null to only
//    use local packages and ignore the package versions.
exports.PackageLoader = function (options) {
  var self = this;
  if (!options.catalog)
    throw Error("Must specify a catalog");

  self.versions = null;
  // Ignore specified versions if we're doing this as part of uniload.
  // The PackageLoader created in uniload.js will not specify a versions option,
  // but other PackageLoaders (eg, created to build plugins in compiler.compile)
  // might, but we should ignore that since uniload never loads versioned
  // packages; it only loads precompiled packages (for built releases) or local
  //packages (from checkout).
  if (options.versions && options.catalog !== catalog.uniload)
    self.versions = options.versions;

  self.uniloadDir = options.uniloadDir;
  self.constraintSolverOpts = options.constraintSolverOpts;
  self.catalog = options.catalog;
};

_.extend(exports.PackageLoader.prototype, {
  // Given the name of a package, return a Unipackage object, or throw an
  // error if the package wasn't included in the 'versions' passed on
  // initalization or isn't available (for example, hasn't been
  // downloaded yet).
  //
  // Options are:
  //  - throwOnError: if true (the default), throw an error if the
  //    package can't be found. (If false is passed for throwOnError,
  //    then return null if the package can't be found.) When called
  //    inside buildmessage.enterJob, however, instead of throwing an
  //    error it will record a build error and return a dummy (empty)
  //    package.
  //    XXX rename to throwOnNotFound
  getPackage: function (name, options) {
    var self = this;
    buildmessage.assertInCapture();

    options = options || {};
    if (options.throwOnError === undefined) {
      options.throwOnError = true;
    }
    var loadPath = self.getLoadPathForPackage(name);
    if (! loadPath) {
      if (options.throwOnError === false)
        return null;
      buildmessage.error("package not available: " + name);
      // recover by returning a dummy (empty) package
      var pkg = new unipackage.Unipackage;
      pkg.initEmpty(name);
      return pkg;
    }

    return self.catalog.packageCache.loadPackageAtPath(
      name, loadPath, self.constraintSolverOpts);
  },

  // As getPackage, but returns the path of the package that would be
  // loaded rather than loading the package, and does not take any
  // options. Returns null if the package is not available.
  //
  // XXX it's a little unfortunate that we have two functions called
  // getLoadPathForPackage, one on this object (which does not take a
  // version) and one on Catalog (which does). Maybe rename them to
  // getPackageLoadPath / getPackageVersionLoadPath?
  getLoadPathForPackage: function (name) {
    var self = this;
    buildmessage.assertInCapture();

    if (self.versions && ! _.has(self.versions, name)) {
      throw new Error("no version chosen for package " + name + "?");
    }

    var version;
    if (self.versions) {
      version = self.versions[name];
    } else {
      version = null;
    }

    return self.catalog.getLoadPathForPackage(
      name, version, self.constraintSolverOpts);
  },

  // Given a package name like "ddp" and an architecture, get the unibuild of
  // that package at the right architecture.
  getUnibuild: function (packageName, arch) {
    var self = this;
    buildmessage.assertInCapture();

    var pkg = self.getPackage(packageName, { throwOnError: true });
    return pkg.getUnibuildAtArch(arch);
  },

  downloadMissingPackages: function (options) {
    var self = this;
    options = options || {};
    // We can only download packages if we know what versions they are.
    if (!self.versions)
      return;
    // We shouldn't ever download packages for uniload.
    if (self.catalog === catalog.uniload)
      return;
    tropohouse.default.downloadMissingPackages(self.versions, {
      serverArch: options.serverArch
    });
  }
});


// Returns the cordovaDependencies of the Cordova arch from a star json.

var getCordovaDependenciesFromStar = function (star) {

var cordovaProgram = _.findWhere(star.programs, { arch: webArchName });

var main = require('./main.js');
var path = require('path');
var _ = require('underscore');
var fs = require('fs');
var util = require('util');
var files = require('./files.js');
var buildmessage = require('./buildmessage.js');
var project = require('./project.js').project;
var auth = require('./auth.js');
var config = require('./config.js');
var release = require('./release.js');
var Future = require('fibers/future');
var runLog = require('./run-log.js');
var packageClient = require('./package-client.js');
var utils = require('./utils.js');
var archinfo = require('./archinfo.js');
var tropohouse = require('./tropohouse.js');
var packageCache = require('./package-cache.js');
var packageLoader = require('./package-loader.js');
var PackageSource = require('./package-source.js');
var compiler = require('./compiler.js');
var unipackage = require('./unipackage.js');
var tropohouse = require('./tropohouse.js');

// XXX hard-coded the use of default tropohouse
var tropo = tropohouse.default;

var cordova = exports;

var supportedPlatforms = ['ios', 'android', 'firefoxos'];

var localCordova = path.join(files.getCurrentToolsDir(),
  "tools", "cordova-scripts", "cordova.sh");

var localAdb = path.join(files.getCurrentToolsDir(),
  "tools", "cordova-scripts", "adb.sh");

var localAndroid = path.join(files.getCurrentToolsDir(),
  "tools", "cordova-scripts", "android.sh");

var verboseness = false;
var setVerboseness = cordova.setVerboseness = function (v) {
  verboseness = !!v;
};
var verboseLog = cordova.verboseLog = function (/* args */) {
  if (verboseness)
    process.stderr.write('%% ' + util.format.apply(null, arguments) + '\n');
};

var execFileAsyncOrThrow = function (file, args, opts, cb) {
  verboseLog('Running asynchronously: ', file, args);
  if (_.isFunction(opts)) {
    cb = opts;
    opts = undefined;
  }

  // XXX a hack to always tell the scripts where warehouse is
  if (opts) opts.env = _.extend({ "WAREHOUSE_DIR": tropo.root, "USE_GLOBAL_ADK": process.env.USE_GLOBAL_ADK || "", HOME: process.env.HOME }, opts.env);

  var execFileAsync = require('./utils.js').execFileAsync;
  if (_.contains([localCordova, localAdb, localAndroid], file) &&
      _.contains(project.getCordovaPlatforms(), 'android'))
    ensureAndroidBundle();

  var p = execFileAsync(file, args, opts);
  p.on('close', function (code) {
    var err = null;
    if (code)
      err = new Error(file + ' ' + args.join(' ') +
                      ' exited with non-zero code: ' + code + '. Use -v for' +
                      ' more logs.');

    if (cb) cb(err, code);
    else if (err) throw err;
  });
};

var execFileSyncOrThrow = function (file, args, opts) {
  var execFileSync = require('./utils.js').execFileSync;
  if (_.contains([localCordova, localAdb, localAndroid], file) &&
      _.contains(project.getCordovaPlatforms(), 'android')) {
    ensureAndroidBundle();
  }

  verboseLog('Running synchronously: ', file, args);

  // XXX a hack to always tell the scripts where warehouse is
  if (opts) opts.env = _.extend({ "WAREHOUSE_DIR": tropo.root, "USE_GLOBAL_ADK": process.env.USE_GLOBAL_ADK || "", HOME: process.env.HOME }, opts.env);

  var childProcess = execFileSync(file, args, opts);
  if (! childProcess.success)
    throw new Error('Failed to run ' + file + '\n' + childProcess.stderr + '\n\n' + childProcess.stdout);

  return childProcess;
};

var ensureAndroidBundle = function () {
  verboseLog('Ensuring android_bundle');
  var ensureScriptPath =
    path.join(files.getCurrentToolsDir(), 'tools', 'cordova-scripts',
              'ensure_android_bundle.sh');

  try {
    execFileSyncOrThrow('bash', [ensureScriptPath], { pipeOutput: true });
  } catch (err) {
    verboseLog('Failed to install android_bundle: ', err.stack);
    throw new Error('Failed to install android_bundle: ' + err.message);
  }
};

var getLoadedPackages = _.once(function () {
  var uniload = require('./uniload.js');
  return uniload.load({
    packages: [ 'boilerplate-generator', 'logging', 'webapp-hashing' ]
  });
});

var generateCordovaBoilerplate = function (clientDir, options) {
  var clientJsonPath = path.join(clientDir, 'program.json');
  var clientJson = JSON.parse(fs.readFileSync(clientJsonPath, 'utf8'));
  var manifest = clientJson.manifest;
  var settings = options.settings ?
    JSON.parse(fs.readFileSync(options.settings, 'utf8')) : {};
  var publicSettings = settings['public'];

  var meteorRelease = project.getMeteorReleaseVersion();
  var Boilerplate = getLoadedPackages()['boilerplate-generator'].Boilerplate;
  var WebAppHashing = getLoadedPackages()['webapp-hashing'].WebAppHashing;

  var configDummy = {};
  if (publicSettings) configDummy.PUBLIC_SETTINGS = publicSettings;

  var calculatedHash =
    WebAppHashing.calculateClientHash(manifest, null, configDummy);

  // XXX partially copied from autoupdate package
  var version = process.env.AUTOUPDATE_VERSION ||
        process.env.SERVER_ID || // XXX COMPAT 0.6.6
        calculatedHash;

  var runtimeConfig = {
    meteorRelease: meteorRelease,
    ROOT_URL: 'http://' + options.host + ':' + options.port + '/',
    // XXX propagate it from options?
    ROOT_URL_PATH_PREFIX: '',
    DDP_DEFAULT_CONNECTION_URL: 'http://' + options.host + ':' + options.port,
    autoupdateVersionCordova: version,
    cleanCache: options.clean
  };

  if (publicSettings)
    runtimeConfig.PUBLIC_SETTINGS = publicSettings;

  var boilerplate = new Boilerplate('web.cordova', manifest, {
    urlMapper: function (url) { return url ? url.substr(1) : ''; },
    pathMapper: function (p) { return path.join(clientDir, p); },
    baseDataExtension: {
      meteorRuntimeConfig: JSON.stringify(runtimeConfig)
    }
  });
  return boilerplate.toHTML();
};

var fetchCordovaPluginFromShaUrl =
    function (urlWithSha, localPluginsDir, pluginName) {
  verboseLog('Fetching a tarball from url:', urlWithSha);
  var pluginPath = path.join(localPluginsDir, pluginName);
  var pluginTarballPath = pluginPath + '.tgz';

  var execFileSync = require('./utils.js').execFileSync;
  var whichCurl = execFileSync('which', ['curl']);

  var downloadProcess = null;

  if (whichCurl.success) {
    verboseLog('Downloading with curl');
    downloadProcess =
      execFileSyncOrThrow('curl', ['-L', urlWithSha, '-o', pluginTarballPath]);
  } else {
    verboseLog('Downloading with wget');
    downloadProcess =
      execFileSyncOrThrow('wget', ['-O', pluginTarballPath, urlWithSha]);
  }

  if (! downloadProcess.success)
    throw new Error("Failed to fetch the tarball from " + urlWithSha + ": " +
                    downloadProcess.stderr);

  verboseLog('Create a folder for the plugin', pluginPath);
  files.mkdir_p(pluginPath);

  verboseLog('Untarring the tarball with plugin');
  var tarProcess = execFileSyncOrThrow('tar',
    ['xf', pluginTarballPath, '-C', pluginPath, '--strip-components=1']);
  if (! tarProcess.success)
    throw new Error("Failed to untar the tarball from " + urlWithSha + ": " +
                    tarProcess.stderr);
  verboseLog('Untarring succeeded, removing the tarball');
  files.rm_recursive(pluginTarballPath);
  return pluginPath;
};

cordova.checkIsValidPlatform = function (name) {
  if (! _.contains(supportedPlatforms, name))
    throw new Error(name + ": no such platform");
};

cordova.checkIsValidPlugin = function (name) {
  var pluginHash = {};
  pluginHash[name.split('@')[0]] = name.split('@')[1];

  // check that every plugin is specifying either an exact constraint or a
  // tarball url with sha
  utils.ensureOnlyExactVersions(pluginHash);
};

// options
//  - debug
var getBundle = function (bundlePath, webArchs, options) {
  var bundler = require(path.join(__dirname, 'bundler.js'));

  var bundleResult = bundler.bundle({
    outputPath: bundlePath,
    buildOptions: {
      minify: ! options.debug,
      arch: archinfo.host(),
      webArchs: webArchs
    }
  });

  if (bundleResult.errors) {
    throw new Error("Errors prevented bundling:\n" +
                    bundleResult.errors.formatMessages());
  }

  return bundleResult;
};

var localPluginsPathFromCordovaPath = function (cordovaPath) {
  return path.join(cordovaPath, 'local-plugins');
};

////////////////////////////////////////////////////////////////////////////////
// ensureCordova
////////////////////////////////////////////////////////////////////////////////

// Creates a Cordova project if necessary.
cordova.ensureCordovaProject = function (localPath, appName) {
  verboseLog('Ensuring the cordova build project');
  var cordovaPath = path.join(localPath, 'cordova-build');
  var localPluginsPath = localPluginsPathFromCordovaPath(cordovaPath);
  if (! fs.existsSync(cordovaPath)) {
    verboseLog('Cordova build project doesn\'t exist, creating one');
    try {
      var creation = execFileSyncOrThrow(localCordova,
        ['create', path.basename(cordovaPath), 'com.meteor.' + appName, appName.replace(/\s/g, '')],
        { cwd: path.dirname(cordovaPath) });

      // create a folder for storing local plugins
      // XXX cache them there
      files.mkdir_p(localPluginsPath);
    } catch (err) {
      process.stderr.write("Error creating Cordova project: " +
        err.message + "\n" + err.stack + "\n");
    }
  }
};

// Ensures that the Cordova platforms are synchronized with the app-level
// platforms.
cordova.ensureCordovaPlatforms = function (localPath) {
  verboseLog('Ensuring that platforms in cordova build project are in sync');
  var cordovaPath = path.join(localPath, 'cordova-build');
  var platforms = project.getCordovaPlatforms();
  var platformsList = execFileSyncOrThrow(localCordova, ['platform', 'list'],
                                   { cwd: cordovaPath });

  verboseLog('The output of `cordova platforms list`:', platformsList.stdout);

  // eg. ['android 3.5.0', 'ios 3.5.0']
  var platformsOutput = platformsList.stdout.split('\n')[0];
  var platformsStrings = platformsOutput.match(/Installed platforms: (.*)/)[1];

  if (platformsStrings === undefined)
    throw new Error('Failed to parse the output of `cordova platform list`: ' +
                     platformsList.stdout);

  var installedPlatforms = _.map(platformsStrings.split(', '), function (s) {
    return s.split(' ')[0];
  });

  _.each(platforms, function (platform) {
    if (! _.contains(installedPlatforms, platform) &&
        _.contains(supportedPlatforms, platform)) {
      verboseLog('Adding a platform', platform);
      execFileSyncOrThrow(localCordova, ['platform', 'add', platform],
                          { cwd: cordovaPath });
    }
  });

  _.each(installedPlatforms, function (platform) {
    if (! _.contains(platforms, platform) &&
        _.contains(supportedPlatforms, platform)) {
      verboseLog('Removing a platform', platform);
      execFileSyncOrThrow(localCordova, ['platform', 'rm', platform],
                          { cwd: cordovaPath });
    }
  });

  return true;
};

var installPlugin = function (cordovaPath, name, version, settings) {
  verboseLog('Installing a plugin', name, version);

  // XXX do something different for plugins fetched from a url.
  var pluginInstallCommand = version ? name + '@' + version : name;
  var localPluginsPath = localPluginsPathFromCordovaPath(cordovaPath);

  if (version && utils.isUrlWithSha(version)) {
    pluginInstallCommand =
      fetchCordovaPluginFromShaUrl(version, localPluginsPath, name);
  }

  var additionalArgs = [];
  // XXX how do we get settings to work now? Do we require settings to be
  // passed every time we add a plugin?

  if (settings && ! _.isObject(settings))
    throw new Error('Meteor.settings.cordova.' + name +
                    ' is expected to be an object');

  _.each(settings, function (value, variable) {
    additionalArgs.push('--variable');
    additionalArgs.push(variable + '=' + JSON.stringify(value));
  });

  process.stdout.write('  installing ' + pluginInstallCommand + '\n');
  var execRes = execFileSyncOrThrow(localCordova,
     ['plugin', 'add', pluginInstallCommand].concat(additionalArgs),
     { cwd: cordovaPath });
  if (! execRes.success)
    throw new Error("Failed to install plugin " + name + ": " + execRes.stderr);
};

var uninstallPlugin = function (cordovaPath, name) {
  verboseLog('Uninstalling a plugin', name);
  try {
    execFileSyncOrThrow(localCordova, ['plugin', 'rm', name],
      { cwd: cordovaPath });
  } catch (err) {
    // Catch when an uninstall fails, because it might just be a dependency
    // issue. For example, plugin A depends on plugin B and we try to remove
    // plugin B. In this case, we will loop and remove plugin A first.
    verboseLog('Plugin removal threw an error:', err.message);
  }
};

// Returns the list of installed plugins as a hash from plugin name to version.
var getInstalledPlugins = function (cordovaPath) {
  verboseLog('Getting installed plugins for project');
  var installedPlugins = {};

  var pluginsOutput = execFileSyncOrThrow(localCordova, ['plugin', 'list'],
                                   { cwd: cordovaPath }).stdout;

  verboseLog('The output of `cordova plugins list`:', pluginsOutput);

  // Check if there are any plugins
  if (! pluginsOutput.match(/No plugins added/)) {
    _.each(pluginsOutput.split('\n'), function (line) {
      line = line.trim();
      if (line === '')
        return;
      var plugin = line.split(' ')[0];
      var version = line.split(' ')[1];
      installedPlugins[plugin] = version;
    });
  }

  return installedPlugins;
};

// Ensures that the Cordova platforms are synchronized with the app-level
// platforms.

var ensureCordovaPlugins = function (localPath, options) {
  options = options || {};
  var plugins = options.packagePlugins;

  verboseLog('Ensuring plugins in the cordova build project are in sync',
             plugins);

  if (! plugins) {
    // Bundle to gather the plugin dependencies from packages.
    // XXX slow - perhaps we should only do this lazily
    // XXX code copied from buildCordova
    var bundlePath = path.join(localPath, 'build-tar');
    var webArchName = 'web.cordova';
    plugins =
      getBundle(bundlePath, [webArchName], options).starManifest.cordovaDependencies;
    files.rm_recursive(bundlePath);
  }
  // XXX the project-level cordova plugins deps override the package-level ones
  _.extend(plugins, project.getCordovaPlugins());

  var cordovaPath = path.join(localPath, 'cordova-build');
  var settingsFile = path.join(cordovaPath, 'cordova-settings.json');

  var newSettings;
  if (options.settings) {
    newSettings =
      JSON.parse(fs.readFileSync(options.settings, "utf8")).cordova;
    fs.writeFileSync(settingsFile, JSON.stringify(newSettings, null, 2),
      'utf8');
  }

  var oldSettings;
  try {
    oldSettings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
  } catch(err) {
    if (err.code !== 'ENOENT')
      throw err;
    oldSettings = {};
  }

  // XXX compare the latest used sha's with the currently required sha's for
  // plugins fetched from a github/tarball url.

  var installedPlugins = getInstalledPlugins(cordovaPath);

  // Due to the dependency structure of Cordova plugins, it is impossible to
  // upgrade the version on an individual Cordova plugin. Instead, whenever a
  // new Cordova plugin is added or removed, or its version is changed,
  // we just reinstall all of the plugins.

  // If there are Cordova settings and they have changed, then reinstall
  // all of the plugins.
  var shouldReinstallPlugins = newSettings &&
                               ! _.isEqual(newSettings, oldSettings);

  // If we have newSettings then use them, otherwise use the old settings.
  var settings = (newSettings ? newSettings : oldSettings) || {};

  // Iterate through all of the plugin and find if any of them have a new
  // version.
  _.each(plugins, function (version, name) {
    // XXX there is a hack here that never updates a package if you are
    // trying to install it from a URL, because we can't determine if
    // it's the right version or not
    if (! _.has(installedPlugins, name) ||
      (installedPlugins[name] !== version && ! utils.isUrlWithSha(version))) {
      // The version of the plugin has changed, or we do not contain a plugin.
      shouldReinstallPlugins = true;
    }
  });

  // Check to see if we have any installed plugins that are not in the current
  // set of plugins.
  _.each(installedPlugins, function (version, name) {
    if (! _.has(plugins, name)) {
      shouldReinstallPlugins = true;
    }
  });

  if (shouldReinstallPlugins) {
    // Loop through all of the current plugins and remove them one by one until
    // we have no plugins. It's necessary to loop because we might have
    // dependencies between plugins.
    var uninstallAllPlugins = function () {
      installedPlugins = getInstalledPlugins(cordovaPath);
      while (_.size(installedPlugins)) {
        _.each(_.keys(installedPlugins), function (name) {
          uninstallPlugin(cordovaPath, name);
        });
        installedPlugins = getInstalledPlugins(cordovaPath);
      }
      // XXX HACK, because Cordova doesn't properly clear its plugins on `rm`.
      // This will completely destroy the project state. We should work with
      // Cordova to fix the bug in their system, because it doesn't seem
      // like there's a way around this.
      files.rm_recursive(path.join(cordovaPath, 'platforms'));
      cordova.ensureCordovaPlatforms(localPath);
    };
    process.stdout.write("Initializing Cordova plugins...\n");
    uninstallAllPlugins();

    // Now install all of the plugins.
    try {
      _.each(plugins, function (version, name) {
        installPlugin(cordovaPath, name, version, settings[name]);
      });
    } catch (err) {
      // If a plugin fails to install, then remove all plugins and throw the
      // error. Cordova doesn't remove the plugin by default for some reason.
      // XXX don't throw and improve this error message.
      uninstallAllPlugins();
      throw err;
    }
  }
};

// Build a Cordova project, creating a Cordova project if necessary.
var buildCordova = function (localPath, buildCommand, options) {
  verboseLog('Building the cordova build project');
  var webArchName = "web.cordova";

  var bundlePath = path.join(localPath, 'build-cordova-temp');
  var programPath = path.join(bundlePath, 'programs');

  var cordovaPath = path.join(localPath, 'cordova-build');
  var wwwPath = path.join(cordovaPath, "www");
  var cordovaProgramPath = path.join(programPath, webArchName);
  var cordovaProgramAppPath = path.join(cordovaProgramPath, 'app');

  verboseLog('Bundling the web.cordova program of the app');
  var bundle = getBundle(bundlePath, [webArchName], options);

  cordova.ensureCordovaProject(localPath, options.appName);
  cordova.ensureCordovaPlatforms(localPath);
  ensureCordovaPlugins(localPath, _.extend({}, options, {
    packagePlugins: bundle.starManifest.cordovaDependencies
  }));

  // XXX hack, copy files from app folder one level up
  if (fs.existsSync(cordovaProgramAppPath)) {
    verboseLog('Copying the JS/CSS files one level up');
    files.cp_r(cordovaProgramAppPath, cordovaProgramPath);
    files.rm_recursive(cordovaProgramAppPath);
  }

  verboseLog('Rewriting the www folder');
  // rewrite the www folder
  files.rm_recursive(wwwPath);
  files.cp_r(cordovaProgramPath, wwwPath);

  // clean up the temporary bundle directory
  files.rm_recursive(bundlePath);

  verboseLog('Writing index.html, cordova_loader.js');

  // generate index.html
  var indexHtml = generateCordovaBoilerplate(wwwPath, options);
  fs.writeFileSync(path.join(wwwPath, 'index.html'), indexHtml, 'utf8');

  var loaderPath = path.join(__dirname, 'client', 'meteor_cordova_loader.js');
  var loaderCode = fs.readFileSync(loaderPath);
  fs.writeFileSync(path.join(wwwPath, 'meteor_cordova_loader.js'), loaderCode);

  var buildOverridePath = path.join(project.rootDir, 'cordova-build-override');

  if (fs.existsSync(buildOverridePath) &&
      fs.statSync(buildOverridePath).isDirectory()) {
    verboseLog('Copying over the cordova-build-override');
    files.cp_r(buildOverridePath, cordovaPath);
  }

  verboseLog('Running the build command:', buildCommand);
  // Give the buffer more space as the output of the build is really huge
  execFileSyncOrThrow(localCordova, [buildCommand],
               { cwd: cordovaPath, maxBuffer: 2000*1024 });

  verboseLog('Done building the cordova build project');
};

// checks that every requested platform such as 'android' or 'ios' is already
// added to the project
var checkRequestedPlatforms = function (platforms) {
  platforms = _.uniq(platforms);

  var requestedPlatforms = [];
  // Find the required platforms.
  // ie. ["ios", "android", "ios-device"] will produce ["ios", "android"]
  _.each(platforms, function (platformName) {
    var platform = platformName.split('-')[0];
    if (! _.contains(requestedPlatforms, platform)) {
      requestedPlatforms.push(platform);
    }
  });

  var cordovaPlatforms = project.getCordovaPlatforms();
  _.each(requestedPlatforms, function (platform) {
    if (! _.contains(cordovaPlatforms, platform))
      throw new Error(platform +
        ": platform is not added to the project. Try 'meteor add-platform " +
        platform + "' to add it or 'meteor help add-platform' for help.");
  });
};

// Builds a Cordova project that targets the list of 'platforms'
// options:
//   - appName: the target path of the build
//   - host
//   - port
cordova.buildPlatforms = function (localPath, platforms, options) {
  verboseLog('Running build for platforms:', platforms);
  checkRequestedPlatforms(platforms);
  buildCordova(localPath, 'build', options);
};

// Start the simulator or physical device for a specific platform.
// platformName is of the form ios/ios-device/android/android-device
// options:
//    - verbose: print all logs
var execCordovaOnPlatform = function (localPath, platformName, options) {
  verboseLog('Execing cordova for platform', platformName);

  var cordovaPath = path.join(localPath, 'cordova-build');

  // XXX error if an invalid platform
  var platform = platformName.split('-')[0];
  var isDevice = platformName.split('-')[1] === 'device';

  verboseLog('isDevice:', isDevice);

  var args = [ 'run',
               isDevice ? '--device' : '--emulator',
               platform ];

  // XXX assert we have a valid Cordova project
  if (platform === 'ios' && isDevice) {
    verboseLog('It is ios-device, just opening the Xcode project with `open` command');
    // ios-deploy is super buggy, so we just open xcode and let the user
    // start the app themselves. XXX print a message about this?
    execFileSyncOrThrow('sh',
      ['-c', 'open ' + path.join(localPath, 'cordova-build',
             'platforms', 'ios', '*.xcodeproj')]);
  } else {
    verboseLog('Running emulator:', localCordova, args);
    execFileAsyncOrThrow(
      localCordova, args,
      { verbose: options.verbose, cwd: cordovaPath });
  }

  var Log = getLoadedPackages().logging.Log;

  var androidMapper = function (line) {
    // remove the annoying prefix
    line = line.replace(/^.\/CordovaLog\(\s*\d+\s*\):\s+/, '');
    // remove a part of file url we don't like
    line = line.replace(/^file:\/\/\/android_asset\/www\//, '');
    // filename.js?hashsha1: Line 123 : message goes here
    var parsedLine = line.match(/^([^?]+)(\?[a-zA-Z0-9]+)?: Line (\d+) : (.*)$/);

    if (! parsedLine)
      return Log.format(Log.objFromText(line), { color: true });

    var output = {
      time: new Date,
      level: 'info',
      file: parsedLine[1],
      line: parsedLine[3],
      message: parsedLine[4],
      program: 'android'
    };
    return Log.format(output, {
      metaColor: 'green',
      color: true
    });
  };

  var iosMapper = function (line) {
    if (line.match(/^[0-9]+-[0-9]+-[0-9].*/)) {
      // if the line starts with the date, we remove the prefix
      line = line.replace(/^\S+\s\S+\s\S+\s/, '');
    }
    return Log.format(Log.objFromText(line, { program: 'ios' }), {
      metaColor: 'cyan',
      color: true
    });
  };

  if (platform === 'ios') {
    var logFilePath =
      path.join(cordovaPath, 'platforms', 'ios', 'cordova', 'console.log');
    verboseLog('Printing logs for ios emulator, tailing file', logFilePath);

    // overwrite the file so we don't have to print the old logs
    fs.writeFileSync(logFilePath, '');
    // print the log file
    execFileAsyncOrThrow('tail', ['-f', logFilePath], {
      verbose: true,
      lineMapper: iosMapper
    });
  } else if (platform === 'android') {
    verboseLog('Clearing logs for Android with `adb logcat -c`, should time-out in 5 seconds');

    // clear the logcat logs from the previous run
    // set a timeout for this command for 5s
    var future = new Future;
    execFileAsyncOrThrow(localAdb, ['logcat', '-c'], future.resolver());
    setTimeout(function () {
      if (! future.isResolved) {
        verboseLog('adb logcat -c timed out');
        future.throw(new Error("clearing logs of Android device timed out: adb logcat -c"));
      }
    }, 5000);

    try {
      future.wait();
    } catch (err) {
      // ignore errors from clearing logs, too much trouble baby-sitting logcat
      verboseLog('Clearing logs failed:', err.stack);
    }
    verboseLog('Done clearing Android logs.');

    verboseLog('Tailing logs for android with `adb logcat -s CordovaLog`');
    execFileAsyncOrThrow(localAdb, ['logcat', '-s', 'CordovaLog'], {
      verbose: true,
      lineMapper: androidMapper
    });
  }

  verboseLog('Done execing cordova for platform', platformName);
  return 0;
};

// Start the simulator or physical device for a list of platforms
// options:
//   - verbose: print all build logs
cordova.runPlatforms = function (localPath, platforms, options) {
  _.each(platforms, function (platformName) {
    execCordovaOnPlatform(localPath, platformName, options);
  });
};

// packages - list of strings
cordova.filterPackages = function (packages) {
// We hard-code the 'cordova' and 'platform' namespaces
  var ret = {
    rest: [],
    plugins: []
  };

  _.each(packages, function (p) {
    var namespace = p.split(':')[0];
    var name = p.split(':').slice(1).join(':');
    if (namespace === 'cordova') {
      ret.plugins.push(name);
    } else {
      ret.rest.push(p); // leave it the same
    }
  });
  return ret;
};

// add one or more Cordova platforms
main.registerCommand({
  name: "add-platform",
  minArgs: 1,
  maxArgs: Infinity,
  requiresApp: true
}, function (options) {
  var platforms = options.args;

  try {
    _.each(platforms, function (platform) {
      cordova.checkIsValidPlatform(platform);
    });
  } catch (err) {
    process.stderr.write(err.message + "\n");
    return 1;
  }

  project.addCordovaPlatforms(platforms);

  if (platforms.length) {
    var localPath = path.join(options.appDir, '.meteor', 'local');
    files.mkdir_p(localPath);

    var appName = path.basename(options.appDir);
    cordova.ensureCordovaProject(localPath, appName);
    cordova.ensureCordovaPlatforms(localPath);
  }

  _.each(platforms, function (platform) {
    process.stdout.write("added platform " + platform + "\n");
  });
});

// remove one or more Cordova platforms
main.registerCommand({
  name: "remove-platform",
  minArgs: 1,
  maxArgs: Infinity,
  requiresApp: true
}, function (options) {
  var platforms = options.args;
  var currentPlatforms = project.getCordovaPlatforms();

  _.each(platforms, function (platform) {
    if (_.contains(currentPlatforms, platform)) {
      process.stdout.write("removed platform " + platform + "\n");
    } else {
      process.stdout.write(platform + " is not in this project\n");
    }
  });
  project.removeCordovaPlatforms(platforms);

  if (platforms.length) {
    var localPath = path.join(options.appDir, '.meteor', 'local');
    files.mkdir_p(localPath);

    var appName = path.basename(options.appDir);
    cordova.ensureCordovaProject(localPath, appName);
    cordova.ensureCordovaPlatforms(localPath);
  }

});

main.registerCommand({
  name: "list-platforms",
  requiresApp: true
}, function () {
  var platforms = project.getCordovaPlatforms();
  process.stdout.write(platforms.join("\n"));

  // print nothing at all if no platforms
  if (platforms.length) {
    process.stdout.write("\n");
  }
});

main.registerCommand({
  name: "configure-android",
  options: {
    verbose: { type: Boolean, short: "v" }
  }
}, function (options) {
  return execFileSyncOrThrow(localAndroid, [], options);
});


// XXX should check error codes
var failure = function (test, code, reason) {
  return function (error, result) {
    test.equal(result, undefined);
    test.isTrue(error && typeof error === "object");
    if (error && typeof error === "object") {
      if (typeof code === "number") {
        test.instanceOf(error, Meteor.Error);
        code && test.equal(error.error, code);
        reason && test.equal(error.reason, reason);
        // XXX should check that other keys aren't present.. should
        // probably use something like the Matcher we used to have
      } else {
        // for normal Javascript errors
        test.instanceOf(error, Error);
        test.equal(error.message, code);
      }
    }
  };
};

Tinytest.add("livedata - Meteor.Error", function (test) {
  var error = new Meteor.Error(123, "kittens", "puppies");
  test.instanceOf(error, Meteor.Error);
  test.instanceOf(error, Error);
  test.equal(error.error, 123);
  test.equal(error.reason, "kittens");
  test.equal(error.details, "puppies");
});

if (Meteor.isServer) {
  Tinytest.add("livedata - version negotiation", function (test) {
    var versionCheck = function (clientVersions, serverVersions, expected) {
      test.equal(
        LivedataTest.calculateVersion(clientVersions, serverVersions),
        expected);
    };

    versionCheck(["A", "B", "C"], ["A", "B", "C"], "A");
    versionCheck(["B", "C"], ["A", "B", "C"], "B");
    versionCheck(["A", "B", "C"], ["B", "C"], "B");
    versionCheck(["foo", "bar", "baz"], ["A", "B", "C"], "A");
  });
}

Tinytest.add("livedata - methods with colliding names", function (test) {
  var x = Random.id();
  var m = {};
  m[x] = function () {};
  Meteor.methods(m);

  test.throws(function () {
    Meteor.methods(m);
  });
});

var echoTest = function (item) {
  return function (test, expect) {
    if (Meteor.isServer) {
      test.equal(Meteor.call("echo", item), [item]);
      test.equal(Meteor.call("echoOne", item), item);
    }
    if (Meteor.isClient)
      test.equal(Meteor.call("echo", item), undefined);

    test.equal(Meteor.call("echo", item, expect(undefined, [item])), undefined);
    test.equal(Meteor.call("echoOne", item, expect(undefined, item)), undefined);
  };
};

testAsyncMulti("livedata - basic method invocation", [
  // Unknown methods
  function (test, expect) {
    if (Meteor.isServer) {
      // On server, with no callback, throws exception
      try {
        var ret = Meteor.call("unknown method");
      } catch (e) {
        test.equal(e.error, 404);
        var threw = true;
      }
      test.isTrue(threw);
      test.equal(ret, undefined);
    }

    if (Meteor.isClient) {
      // On client, with no callback, just returns undefined
      var ret = Meteor.call("unknown method");
      test.equal(ret, undefined);
    }

    // On either, with a callback, calls the callback and does not throw
    var ret = Meteor.call("unknown method",
                          expect(failure(test, 404, "Method not found")));
    test.equal(ret, undefined);
  },

  function (test, expect) {
    // make sure 'undefined' is preserved as such, instead of turning
    // into null (JSON does not have 'undefined' so there is special
    // code for this)
    if (Meteor.isServer)
      test.equal(Meteor.call("nothing"), undefined);
    if (Meteor.isClient)
      test.equal(Meteor.call("nothing"), undefined);

    test.equal(Meteor.call("nothing", expect(undefined, undefined)), undefined);
  },

  function (test, expect) {
    if (Meteor.isServer)
      test.equal(Meteor.call("echo"), []);
    if (Meteor.isClient)
      test.equal(Meteor.call("echo"), undefined);

    test.equal(Meteor.call("echo", expect(undefined, [])), undefined);
  },

  echoTest(new Date()),
  echoTest({d: new Date(), s: "foobarbaz"}),
  echoTest([new Date(), "foobarbaz"]),
  echoTest(new Mongo.ObjectID()),
  echoTest({o: new Mongo.ObjectID()}),
  echoTest({$date: 30}), // literal
  echoTest({$literal: {$date: 30}}),
  echoTest(12),
  echoTest(Infinity),
  echoTest(-Infinity),

  function (test, expect) {
    if (Meteor.isServer)
      test.equal(Meteor.call("echo", 12, {x: 13}), [12, {x: 13}]);
    if (Meteor.isClient)
      test.equal(Meteor.call("echo", 12, {x: 13}), undefined);

    test.equal(Meteor.call("echo", 12, {x: 13},
                           expect(undefined, [12, {x: 13}])), undefined);
  },

  // test that `wait: false` is respected
  function (test, expect) {
    if (Meteor.isClient) {
      // For test isolation
      var token = Random.id();
      Meteor.apply(
        "delayedTrue", [token], {wait: false}, expect(function(err, res) {
          test.equal(res, false);
        }));
      Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", [token]);
    }
  },

  // test that `wait: true` is respected
  function(test, expect) {
    if (Meteor.isClient) {
      var token = Random.id();
      Meteor.apply(
        "delayedTrue", [token], {wait: true}, expect(function(err, res) {
          test.equal(res, true);
        }));
      Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", [token]);
    }
  },

  function (test, expect) {
    // No callback

    if (Meteor.isServer) {
      test.throws(function () {
        Meteor.call("exception", "both");
      });
      test.throws(function () {
        Meteor.call("exception", "server");
      });
      // No exception, because no code will run on the client
      test.equal(Meteor.call("exception", "client"), undefined);
    }

    if (Meteor.isClient) {
      // The client exception is thrown away because it's in the
      // stub. The server exception is throw away because we didn't
      // give a callback.
      test.equal(Meteor.call("exception", "both"), undefined);
      test.equal(Meteor.call("exception", "server"), undefined);
      test.equal(Meteor.call("exception", "client"), undefined);
    }

    // With callback

    if (Meteor.isClient) {
      test.equal(
        Meteor.call("exception", "both",
                    expect(failure(test, 500, "Internal server error"))),
        undefined);
      test.equal(
        Meteor.call("exception", "server",
                    expect(failure(test, 500, "Internal server error"))),
        undefined);
      test.equal(Meteor.call("exception", "client"), undefined);
    }

    if (Meteor.isServer) {
      test.equal(
        Meteor.call("exception", "both",
                    expect(failure(test, "Test method throwing an exception"))),
        undefined);
      test.equal(
        Meteor.call("exception", "server",
                    expect(failure(test, "Test method throwing an exception"))),
        undefined);
      test.equal(Meteor.call("exception", "client"), undefined);
    }
  },

  function (test, expect) {
    if (Meteor.isServer) {
      var threw = false;
      try {
        Meteor.call("exception", "both", {intended: true});
      } catch (e) {
        threw = true;
        test.equal(e.error, 999);
        test.equal(e.reason, "Client-visible test exception");
      }
      test.isTrue(threw);
      threw = false;
      try {
        Meteor.call("exception", "both", {intended: true,
                                          throwThroughFuture: true});
      } catch (e) {
        threw = true;
        test.equal(e.error, 999);
        test.equal(e.reason, "Client-visible test exception");
      }
      test.isTrue(threw);
    }

    if (Meteor.isClient) {
      test.equal(
        Meteor.call("exception", "both", {intended: true},
                    expect(failure(test, 999,
                                   "Client-visible test exception"))),
        undefined);
      test.equal(
        Meteor.call("exception", "server", {intended: true},
                    expect(failure(test, 999,
                                   "Client-visible test exception"))),
        undefined);
      test.equal(
        Meteor.call("exception", "server", {intended: true,
                                            throwThroughFuture: true},
                    expect(failure(test, 999,
                                   "Client-visible test exception"))),
        undefined);
    }
  }
]);

var checkBalances = function (test, a, b) {
  var alice = Ledger.findOne({name: "alice", world: test.runId()});
  var bob = Ledger.findOne({name: "bob", world: test.runId()});
  test.equal(alice.balance, a);
  test.equal(bob.balance, b);
};

// would be nice to have a database-aware test harness of some kind --
// this is a big hack (and XXX pollutes the global test namespace)
testAsyncMulti("livedata - compound methods", [
  function (test, expect) {
    if (Meteor.isClient)
      Meteor.subscribe("ledger", test.runId(), expect());

    Ledger.insert({name: "alice", balance: 100, world: test.runId()},
                  expect(function () {}));
    Ledger.insert({name: "bob", balance: 50, world: test.runId()},
                  expect(function () {}));
  },
  function (test, expect) {
    Meteor.call('ledger/transfer', test.runId(), "alice", "bob", 10,
                expect(function(err, result) {
                  test.equal(err, undefined);
                  test.equal(result, undefined);
                  checkBalances(test, 90, 60);
                }));
    checkBalances(test, 90, 60);
  },
  function (test, expect) {
    Meteor.call('ledger/transfer', test.runId(), "alice", "bob", 100, true,
                expect(function (err, result) {
                  failure(test, 409)(err, result);
                  // Balances are reverted back to pre-stub values.
                  checkBalances(test, 90, 60);
                }));

    if (Meteor.isClient)
      // client can fool itself by cheating, but only until the sync
      // finishes
      checkBalances(test, -10, 160);
    else
      checkBalances(test, 90, 60);
  }
]);

// Replaces the Connection's `_livedata_data` method to push incoming
// messages on a given collection to an array. This can be used to
// verify that the right data is sent on the wire
//
// @param messages {Array} The array to which to append the messages
// @return {Function} A function to call to undo the eavesdropping
var eavesdropOnCollection = function(livedata_connection,
                                     collection_name, messages) {
  var old_livedata_data = _.bind(
    livedata_connection._livedata_data, livedata_connection);

  // Kind of gross since all tests past this one will run with this
  // hook set up. That's probably fine since we only check a specific
  // collection but still...
  //
  // Should we consider having a separate connection per Tinytest or
  // some similar scheme?
  livedata_connection._livedata_data = function(msg) {
    if (msg.collection && msg.collection === collection_name) {
      messages.push(msg);
    }
    old_livedata_data(msg);
  };

  return function() {
    livedata_connection._livedata_data = old_livedata_data;
  };
};

if (Meteor.isClient) {
  testAsyncMulti("livedata - changing userid reruns subscriptions without flapping data on the wire", [
    function(test, expect) {
      var messages = [];
      var undoEavesdrop = eavesdropOnCollection(
        Meteor.connection, "objectsWithUsers", messages);

      // A helper for testing incoming set and unset messages
      // XXX should this be extracted as a general helper together with
      // eavesdropOnCollection?
      var expectMessages = function(expectedAddedMessageCount,
                                    expectedRemovedMessageCount,
                                    expectedNamesInCollection) {
        var actualAddedMessageCount = 0;
        var actualRemovedMessageCount = 0;
        _.each(messages, function (msg) {
          if (msg.msg === 'added')
            ++actualAddedMessageCount;
          else if (msg.msg === 'removed')
            ++actualRemovedMessageCount;
          else
            test.fail({unexpected: JSON.stringify(msg)});
        });
        test.equal(actualAddedMessageCount, expectedAddedMessageCount);
        test.equal(actualRemovedMessageCount, expectedRemovedMessageCount);
        expectedNamesInCollection.sort();
        test.equal(_.pluck(objectsWithUsers.find({}, {sort: ['name']}).fetch(),
                           'name'),
                   expectedNamesInCollection);
        messages.length = 0; // clear messages without creating a new object
      };

      // make sure we're not already logged in. can happen if accounts
      // tests fail oddly.
      Meteor.apply("setUserId", [null], {wait: true}, expect(function () {}));

      Meteor.subscribe("objectsWithUsers", expect(function() {
        expectMessages(1, 0, ["owned by none"]);
        Meteor.apply("setUserId", ["1"], {wait: true}, afterFirstSetUserId);
      }));

      var afterFirstSetUserId = expect(function() {
        expectMessages(3, 1, [
          "owned by one - a",
          "owned by one/two - a",
          "owned by one/two - b"]);
        Meteor.apply("setUserId", ["2"], {wait: true}, afterSecondSetUserId);
      });

      var afterSecondSetUserId = expect(function() {
        expectMessages(2, 1, [
          "owned by one/two - a",
          "owned by one/two - b",
          "owned by two - a",
          "owned by two - b"]);
        Meteor.apply("setUserId", ["2"], {wait: true}, afterThirdSetUserId);
      });

      var afterThirdSetUserId = expect(function() {
        // Nothing should have been sent since the results of the
        // query are the same ("don't flap data on the wire")
        expectMessages(0, 0, [
          "owned by one/two - a",
          "owned by one/two - b",
          "owned by two - a",
          "owned by two - b"]);
        undoEavesdrop();
      });
    }, function(test, expect) {
      var key = Random.id();
      Meteor.subscribe("recordUserIdOnStop", key);
      Meteor.apply("setUserId", ["100"], {wait: true}, expect(function () {}));
      Meteor.apply("setUserId", ["101"], {wait: true}, expect(function () {}));
      Meteor.call("userIdWhenStopped", key, expect(function (err, result) {
        test.isFalse(err);
        test.equal(result, "100");
      }));
      // clean up
      Meteor.apply("setUserId", [null], {wait: true}, expect(function () {}));
    }
  ]);
}

Tinytest.add("livedata - setUserId error when called from server", function(test) {
  if (Meteor.isServer) {
    test.equal(errorThrownWhenCallingSetUserIdDirectlyOnServer.message,
               "Can't call setUserId on a server initiated method call");
  }
});

if (Meteor.isServer) {
  var pubHandles = {};
};
Meteor.methods({
  "livedata/setup" : function (id) {
    check(id, String);
    if (Meteor.isServer) {
      pubHandles[id] = {};
      Meteor.publish("pub1"+id, function () {
        pubHandles[id].pub1 = this;
        this.ready();
      });
      Meteor.publish("pub2"+id, function () {
        pubHandles[id].pub2 = this;
        this.ready();
      });

    }
  },
  "livedata/pub1go" : function (id) {
    check(id, String);
    if (Meteor.isServer) {

      pubHandles[id].pub1.added("MultiPubCollection" + id, "foo", {a: "aa"});
      return 1;
    }
    return 0;
  },
  "livedata/pub2go" : function (id) {
    check(id, String);
    if (Meteor.isServer) {
      pubHandles[id].pub2.added("MultiPubCollection" + id , "foo", {b: "bb"});
      return 2;
    }
    return 0;
  }
});

if (Meteor.isClient) {
  (function () {
    var MultiPub;
    var id = Random.id();
    testAsyncMulti("livedata - added from two different subs", [
      function (test, expect) {
        Meteor.call('livedata/setup', id, expect(function () {}));
      },
      function (test, expect) {
        MultiPub = new Mongo.Collection("MultiPubCollection" + id);
        var sub1 = Meteor.subscribe("pub1"+id, expect(function () {}));
        var sub2 = Meteor.subscribe("pub2"+id, expect(function () {}));
      },
      function (test, expect) {
        Meteor.call("livedata/pub1go", id, expect(function (err, res) {test.equal(res, 1);}));
      },
      function (test, expect) {
        test.equal(MultiPub.findOne("foo"), {_id: "foo", a: "aa"});
      },
      function (test, expect) {
        Meteor.call("livedata/pub2go", id, expect(function (err, res) {test.equal(res, 2);}));
      },
      function (test, expect) {
        test.equal(MultiPub.findOne("foo"), {_id: "foo", a: "aa", b: "bb"});
      }
    ]);
  })();
};

if (Meteor.isClient) {
  testAsyncMulti("livedata - overlapping universal subs", [
    function (test, expect) {
      var coll = new Mongo.Collection("overlappingUniversalSubs");
      var token = Random.id();
      test.isFalse(coll.findOne(token));
      Meteor.call("testOverlappingSubs", token, expect(function (err) {
        test.isFalse(err);
        test.isTrue(coll.findOne(token));
      }));
    }
  ]);

  testAsyncMulti("livedata - runtime universal sub creation", [
    function (test, expect) {
      var coll = new Mongo.Collection("runtimeSubCreation");
      var token = Random.id();
      test.isFalse(coll.findOne(token));
      Meteor.call("runtimeUniversalSubCreation", token, expect(function (err) {
        test.isFalse(err);
        test.isTrue(coll.findOne(token));
      }));
    }
  ]);

  testAsyncMulti("livedata - no setUserId after unblock", [
    function (test, expect) {
      Meteor.call("setUserIdAfterUnblock", expect(function (err, result) {
        test.isFalse(err);
        test.isTrue(result);
      }));
    }
  ]);

  testAsyncMulti("livedata - publisher errors", (function () {
    var conn, collName, coll;
    var errorFromRerun;
    var gotErrorFromStopper = false;
    return [
      function (test, expect) {
        // Use a separate connection so that we can safely check to see if
        // conn._subscriptions is empty.
        conn = new LivedataTest.Connection('/',
                                           {reloadWithOutstanding: true});
        collName = Random.id();
        coll = new Mongo.Collection(collName, {connection: conn});

        var testSubError = function (options) {
          conn.subscribe("publisherErrors", collName, options, {
            onReady: expect(),
            onError: expect(
              failure(test,
                      options.internalError ? 500 : 412,
                      options.internalError ? "Internal server error"
                                            : "Explicit error"))
          });
        };
        testSubError({throwInHandler: true});
        testSubError({throwInHandler: true, internalError: true});
        testSubError({errorInHandler: true});
        testSubError({errorInHandler: true, internalError: true});
        testSubError({errorLater: true});
        testSubError({errorLater: true, internalError: true});
      },
      function (test, expect) {
        test.equal(coll.find().count(), 0);
        test.equal(_.size(conn._subscriptions), 0);  // white-box test

        conn.subscribe("publisherErrors",
                       collName, {throwWhenUserIdSet: true}, {
          onReady: expect(),
          onError: function (error) {
            errorFromRerun = error;
          }
        });
      },
      function (test, expect) {
        // Because the last subscription is ready, we should have a document.
        test.equal(coll.find().count(), 1);
        test.isFalse(errorFromRerun);
        test.equal(_.size(conn._subscriptions), 1);  // white-box test
        conn.call('setUserId', 'bla', expect(function(){}));
      },
      function (test, expect) {
        // Now that we've re-run, we should have stopped the subscription,
        // gotten a error, and lost the document.
        test.equal(coll.find().count(), 0);
        test.isTrue(errorFromRerun);
        test.instanceOf(errorFromRerun, Meteor.Error);
        test.equal(errorFromRerun.error, 412);
        test.equal(errorFromRerun.reason, "Explicit error");
        test.equal(_.size(conn._subscriptions), 0);  // white-box test

        conn.subscribe("publisherErrors", collName, {stopInHandler: true}, {
          onError: function() { gotErrorFromStopper = true; }
        });
        // Call a method. This method won't be processed until the publisher's
        // function returns, so blocking on it being done ensures that we've
        // gotten the removed/nosub/etc.
        conn.call('nothing', expect(function(){}));
      },
      function (test, expect) {
        test.equal(coll.find().count(), 0);
        // sub.stop does NOT call onError.
        test.isFalse(gotErrorFromStopper);
        test.equal(_.size(conn._subscriptions), 0);  // white-box test
        conn._stream.disconnect({_permanent: true});
      }
    ];})());

    testAsyncMulti("livedata - publish multiple cursors", [
      function (test, expect) {
        Meteor.subscribe("multiPublish", {normal: 1}, {
          onReady: expect(function () {
            test.equal(One.find().count(), 2);
            test.equal(Two.find().count(), 3);
          }),
          onError: failure()
        });
      },
      function (test, expect) {
        Meteor.subscribe("multiPublish", {dup: 1}, {
          onReady: failure(),
          onError: expect(failure(test, 500, "Internal server error"))
        });
      },
      function (test, expect) {
        Meteor.subscribe("multiPublish", {notCursor: 1}, {
          onReady: failure(),
          onError: expect(failure(test, 500, "Internal server error"))
        });
      }
    ]);
}

var selfUrl = Meteor.isServer
      ? Meteor.absoluteUrl() : Meteor._relativeToSiteRootUrl('/');

if (Meteor.isServer) {
  Meteor.methods({
    "s2s": function (arg) {
      check(arg, String);
      return "s2s " + arg;
    }
  });
}
(function () {
  testAsyncMulti("livedata - connect works from both client and server", [
    function (test, expect) {
      var self = this;
      self.conn = DDP.connect(selfUrl);
      pollUntil(expect, function () {
        return self.conn.status().connected;
      }, 10000);
    },

    function (test, expect) {
      var self = this;
      if (self.conn.status().connected) {
        self.conn.call('s2s', 'foo', expect(function (err, res) {
          if (err)
            throw err;
          test.equal(res, "s2s foo");
        }));
      }
    }
  ]);
})();

if (Meteor.isServer) {
  (function () {
    testAsyncMulti("livedata - method call on server blocks in a fiber way", [
      function (test, expect) {
        var self = this;
        self.conn = DDP.connect(selfUrl);
        pollUntil(expect, function () {
          return self.conn.status().connected;
        }, 10000);
      },

      function (test, expect) {
        var self = this;
        if (self.conn.status().connected) {
          test.equal(self.conn.call('s2s', 'foo'), "s2s foo");
        }
      }
    ]);
  })();
}

(function () {
  testAsyncMulti("livedata - connect fails to unknown place", [
    function (test, expect) {
      var self = this;
      self.conn = DDP.connect("example.com", {_dontPrintErrors: true});
      Meteor.setTimeout(expect(function () {
        test.isFalse(self.conn.status().connected, "Not connected");
        self.conn.close();
      }), 500);
    }
  ]);
})();

if (Meteor.isServer) {
  Meteor.publish("publisherCloning", function () {
    var self = this;
    var fields = {x: {y: 42}};
    self.added("publisherCloning", "a", fields);
    fields.x.y = 43;
    self.changed("publisherCloning", "a", fields);
    self.ready();
  });
} else {
  var PublisherCloningCollection = new Mongo.Collection("publisherCloning");
  testAsyncMulti("livedata - publish callbacks clone", [
    function (test, expect) {
      Meteor.subscribe("publisherCloning", {normal: 1}, {
        onReady: expect(function () {
          test.equal(PublisherCloningCollection.findOne(), {
            _id: "a",
            x: {y: 43}});
        }),
        onError: failure()
      });
    }
  ]);
}

// XXX some things to test in greater detail:
// staying in simulation mode
// time warp
// serialization / beginAsync(true) / beginAsync(false)
// malformed messages (need raw wire access)
// method completion/satisfaction
// subscriptions (multiple APIs, including autorun?)
// subscription completion
// subscription attribute shadowing
// server method calling methods on other server (eg, should simulate)
// subscriptions and methods being idempotent
// reconnection
// reconnection not resulting in method re-execution
// reconnection tolerating all kinds of lost messages (including data)
// [probably lots more]


var selftest = require('../selftest.js');
var Sandbox = selftest.Sandbox;
var utils = require('../utils.js');
var net = require('net');
var Future = require('fibers/future');
var _ = require('underscore');
var files = require('../files.js');

var MONGO_LISTENING =
  { stdout: " [initandlisten] waiting for connections on port" };

var SIMPLE_WAREHOUSE = {
  v1: { },
  v2: { recommended: true },
  v3: { }
};

selftest.define("run", function () {
  var s = new Sandbox({ fakeMongo: true });
  var run;

  // Starting a run
  s.createApp("myapp", "standard-app");
  s.cd("myapp");
  s.set("METEOR_TEST_TMP", files.mkdtemp());
  run = s.run();
  run.match("myapp");
  run.match("proxy");
  run.tellMongo(MONGO_LISTENING);
  run.match("MongoDB");
  run.match("your app");
  run.waitSecs(5);
  run.match("running at");
  run.match("localhost");

  // File change
  s.write("empty.js", "");
  run.waitSecs(1);
  run.match("restarted");
  s.write("empty.js", " ");
  run.waitSecs(1);
  run.match("restarted (x2)");
  // XXX want app to generate output so that we can see restart counter reset

  // Crashes
  s.write("crash.js", "process.exit(42);");
  run.waitSecs(5);
  run.match("with code: 42");
  run.waitSecs(5);
  run.match("is crashing");
  s.unlink("crash.js");
  run.waitSecs(5);
  run.match("Modified");
  run.waitSecs(5);
  run.match("restarted");
  s.write("empty.js", "");
  run.waitSecs(5);
  run.match("restarted (x2)"); // see that restart counter reset
  s.write("crash.js", "process.kill(process.pid, 'SIGKILL');");
  run.waitSecs(5);
  run.match("from signal: SIGKILL");
  run.waitSecs(5);
  run.match("is crashing");

  // Bundle failure
  s.unlink("crash.js");
  s.write("junk.js", "]");
  run.waitSecs(5);
  run.match("Modified");
  run.match("prevented startup");
  run.match("Unexpected token");
  run.match("file change");

  // Back to working
  s.unlink("junk.js");
  run.waitSecs(5);
  run.match("restarted");

  // Crash just once, then restart successfully
  s.write("crash.js",
"var fs = Npm.require('fs')\n" +
"var path = Npm.require('path')\n" +
"var crashmark = path.join(process.env.METEOR_TEST_TMP, 'crashed');\n" +
"try {\n" +
"  fs.readFileSync(crashmark);\n" +
"} catch (e) {\n" +
"  fs.writeFileSync(crashmark);\n" +
"  process.exit(137);\n" +
"}\n");
  run.waitSecs(5);
  run.match("with code: 137");
  run.waitSecs(5);
  run.match("restarted");
  run.stop();

  // How about a bundle failure right at startup
  s.unlink("crash.js");
  s.write("junk.js", "]");
  run = s.run();
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  run.match("prevented startup");
  run.match("Unexpected token");
  run.match("file change");
  s.unlink("junk.js");
  run.waitSecs(5);
  run.match("restarted");
  run.stop();

// XXX --port, --production, --raw-logs, --settings, --program
});

selftest.define("run --once", function () {
  var s = new Sandbox({ fakeMongo: true });
  var run;

  s.createApp("onceapp", "once");
  s.cd("onceapp");

  // Basic run --once
  s.set("RUN_ONCE_OUTCOME", "exit");
  run = s.run("--once");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  run.match("once test\n");
  run.expectExit(123);

  // run --once, exit on signal
  s.set("RUN_ONCE_OUTCOME", "kill");
  run = s.run("--once");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  run.match("once test\n");
  run.matchErr("Killed (SIGKILL)\n");
  run.expectExit(255);

  // run --once, bundle failure
  s.set("RUN_ONCE_OUTCOME", "exit");
  s.write("junk.js", "]");
  run = s.run("--once");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  run.matchErr("Build failed");
  run.matchErr("Unexpected token");
  run.expectExit(254);
  s.unlink("junk.js");

  // file changes don't make it restart
  s.set("RUN_ONCE_OUTCOME", "hang");
  run = s.run("--once");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  run.match("once test\n");
  s.write('empty.js', 'null');
  var originalRelease = s.read('.meteor/release');
  s.write('.meteor/release', 'v1');
  utils.sleepMs(2000); // sorry, hard to avoid
  run.stop();
  run.forbidAll("updated");
  s.unlink('empty.js');
  s.write('.meteor/release', originalRelease);

  // running a different program
  run = s.run("--once", "--program", "other");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(8);
  run.match("other program\n");
  run.expectExit(44);

  // bad program name
  run = s.run("--once", "--program", "xyzzy");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(5);
  run.match("'xyzzy' not found");
  run.expectExit(254);

  // Try it with a real Mongo. Make sure that it actually starts one.
  s = new Sandbox;
  s.createApp("onceapp", "once");
  s.cd("onceapp");
  s.set("RUN_ONCE_OUTCOME", "mongo");
  run = s.run("--once");
  run.waitSecs(30);
  run.expectExit(86);
});

selftest.define("run errors", function () {
  var s = new Sandbox;
  s.createApp("myapp", "standard-app");
  s.cd("myapp");

  // Prevent mongod from starting up.  (Note that "127.0.0.1" matches the
  // interface that mongo uses.)
  var proxyPort = utils.randomPort();
  var mongoPort = proxyPort + 1;
  var f = new Future;
  var server = net.createServer().listen(mongoPort, "127.0.0.1", f.resolver());
  f.wait();

  var run = s.run("-p", proxyPort);
  _.times(3, function () {
    run.waitSecs(30);
    run.match("Unexpected mongo exit code 48. Restarting.");
  });
  run.waitSecs(3);
  run.match("Can't start Mongo server");
  run.match("MongoDB exited because its port was closed");
  run.match("running in the same project.\n");
  run.expectEnd();
  run.forbid("Started MongoDB");
  run.expectExit(254);

  f = new Future;
  server.close(f.resolver());
  f.wait();

  // This time, prevent the proxy from starting. (This time, leaving out the
  // interface name matches.)
  f = new Future;
  server = net.createServer().listen(proxyPort, f.resolver());
  f.wait();

  run = s.run("-p", proxyPort);
  run.waitSecs(3);
  run.match(/Can't listen on port.*another Meteor/);
  run.expectExit(254);

  f = new Future;
  server.close(f.resolver());
  f.wait();
});

selftest.define("update during run", ["checkout"], function () {
  var s = new Sandbox({
    warehouse: SIMPLE_WAREHOUSE,
    fakeMongo: true
  });
  var run;

  s.createApp("myapp", "packageless");
  s.cd("myapp");

  // This makes packages not depend on meteor (specifically, makes our empty
  // control program not depend on meteor).
  s.set("NO_METEOR_PACKAGE", "t");

  // If the app version changes, we exit with an error message.
  s.write('.meteor/release', 'METEOR@v1');
  run = s.run();
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  run.match('localhost:3000');
  s.write('.meteor/release', 'METEOR@v2');
  run.matchErr('to Meteor METEOR@v2 from Meteor METEOR@v1');
  run.expectExit(254);

  // But not if the release was forced (case 1)
  s.write('.meteor/release', 'METEOR@v1');
  run = s.run("--release", "METEOR@v3");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  run.match('localhost:3000');
  s.write('.meteor/release', 'METEOR@v2');
  s.write('empty.js', '');
  run.waitSecs(2);
  run.match('restarted');
  run.stop();
  run.forbidAll("updated");

  // But not if the release was forced (case 2)
  s.write('.meteor/release', 'METEOR@v1');
  run = s.run("--release", "METEOR@v1");
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  run.match('localhost:3000');
  s.write('.meteor/release', 'METEOR@v2');
  s.write('empty.js', '');
  run.waitSecs(2);
  run.match('restarted');
  run.stop();
  run.forbidAll("updated");

  // Nor do we do it if you're running from a checkout
  s = new Sandbox({ fakeMongo: true });
  s.createApp("myapp", "standard-app");
  s.cd("myapp");

  s.write('.meteor/release', 'METEOR@v1');
  run = s.run();
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  run.match('localhost:3000');
  s.write('.meteor/release', 'METEOR@v2');
  s.write('empty.js', '');
  run.waitSecs(2);
  run.match('restarted');
  run.stop();
  run.forbidAll("updated");
});

selftest.define("run with mongo crash", ["checkout"], function () {
  var s = new Sandbox({ fakeMongo: true });
  var run;

  s.createApp("myapp", "standard-app");
  s.cd("myapp");

  // Kill mongod three times.  See that it gives up and quits.
  run = s.run();
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  run.match('localhost:3000/\n');
  run.tellMongo({exit: 23});
  run.read('Unexpected mongo exit code 23. Restarting.\n');
  run.tellMongo({exit: 46});
  run.read('Unexpected mongo exit code 46. Restarting.\n');
  run.tellMongo({exit: 47});
  run.read('Unexpected mongo exit code 47. Restarting.\n');
  run.read("Can't start Mongo server.\n");
  run.read("MongoDB exited due to excess clock skew\n");
  run.expectEnd();
  run.expectExit(254);

  // Now create a build failure. Make sure that killing mongod three times
  // *also* successfully quits even if we're waiting on file change.
  s.write('bad.js', ']');
  run = s.run();
  run.tellMongo(MONGO_LISTENING);
  run.waitSecs(2);
  run.match("prevented startup");
  run.match("file change.\n");
  run.tellMongo({exit: 23});
  run.read('Unexpected mongo exit code 23. Restarting.\n');
  run.tellMongo({exit: 46});
  run.read('Unexpected mongo exit code 46. Restarting.\n');
  run.tellMongo({exit: 47});
  run.read('Unexpected mongo exit code 47. Restarting.\n');
  run.read("Can't start Mongo server.\n");
  run.read("MongoDB exited due to excess clock skew\n");
  run.expectEnd();
  run.expectExit(254);
});


var bundlePath = path.join(localPath, 'build-cordova-temp');

var programPath = path.join(bundlePath, 'programs');

keep keep keep replace keep keep keep keep


Package.describe({
  summary: "Login service for Google accounts",
  version: "1.0.0"
});

Package.on_use(function(api) {
  api.use(['underscore', 'random']);
  api.use('accounts-base', ['client', 'server']);
  // Export Accounts (etc) to packages using this one.
  api.imply('accounts-base', ['client', 'server']);
  api.use('accounts-oauth', ['client', 'server']);
  api.use('google', ['client', 'server']);

  api.add_files('google_login_button.css', 'client');

  api.add_files("google.js");
});


Tinytest.add("stylus - presence", function(test) {

  var div = document.createElement('div');
  UI.render(Template.stylus_test_presence, div);
  div.style.display = 'block';
  document.body.appendChild(div);

  var p = div.querySelector('p');
  var leftBorder = getStyleProperty(p, 'border-left-style');
  test.equal(leftBorder, "dashed");

  document.body.removeChild(div);
});

Tinytest.add("stylus - @import", function(test) {
  var div = document.createElement('div');
  UI.render(Template.stylus_test_import, div);
  div.style.display = 'block';
  document.body.appendChild(div);

  var p = div.querySelector('p');
  test.equal(getStyleProperty(p, 'font-size'), "20px");
  test.equal(getStyleProperty(p, 'border-left-style'), "dashed");

  document.body.removeChild(div);
});


Package.describe({
  summary: "Test package.",
  version: "1.0.0"
});


ensureCordovaPlugins(localPath, _.extend({}, options, {

// XXX hack, copy files from app folder one level up

keep keep keep keep replace keep keep keep


var selftest = require('../selftest.js');
var testUtils = require('../test-utils.js');
var Sandbox = selftest.Sandbox;
var config = require("../config.js");

var commandTimeoutSecs = testUtils.accountsCommandTimeoutSecs;

var loggedInError = function(run) {
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("You must be logged in for that.");
  run.expectExit(1);
};

selftest.define("authorized", ['net', 'slow'], function () {
  var s = new Sandbox;

  // Deploy an app authorized to test.
  testUtils.login(s, "test", "testtest");
  var appName = testUtils.createAndDeployApp(s);
  testUtils.logout(s);

  // You are not authorized if you are not logged in.
  var run = s.run("authorized", appName);
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("You must be logged in for that.");
  run.expectExit(1);

  run = s.run("authorized");
  run.matchErr("not enough arguments");

  run = s.run("authorized", appName, "--remove", "bob");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("You must be logged in for that.");
  run.expectExit(1);

  run = s.run("authorized", appName, "--add", "bob");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("You must be logged in for that.");
  run.expectExit(1);

  run = s.run("authorized", appName, "--list");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("You must be logged in for that.");
  run.expectExit(1);

  // Now let us log in, but as the wrong user.
  testUtils.login(s, "testtest", "testtest");

  run = s.run("authorized", appName, "--remove", "bob");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't change authorized users");
  run.expectExit(1);

  run = s.run("authorized", appName, "--add", "bob");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't change authorized users");
  run.expectExit(1);

  run = s.run("authorized", appName, "--list");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't get authorized users list");
  run.expectExit(1);

  // Yay, now let's log in as the right user.
  testUtils.logout(s);
  testUtils.login(s, "test", "testtest");

  run = s.run("authorized", appName, "--list");
  run.waitSecs(commandTimeoutSecs);
  run.match("test");
  run.expectExit(0);

  run = s.run("authorized", appName, "--add", "test");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't change authorized users: test: already an authorized user");
  run.expectExit(1);

  // Adding a user
  var newUser = testUtils.randomString(10);
  run = s.run("authorized", appName, "--add", newUser);
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't change authorized users: Unknown user");
  run.expectExit(1);

  run = s.run("authorized", appName, "--add", "testtest");
  run.waitSecs(commandTimeoutSecs);
  run.match(": added testtest");
  run.expectExit(0);

  run = s.run("authorized", appName, "--list");
  run.waitSecs(commandTimeoutSecs);
  run.match("test\ntesttest");
  run.expectExit(0);

  // Removing a user
  run = s.run("authorized", appName, "--remove", newUser);
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't change authorized users: Unknown user");
  run.expectExit(1);

  run = s.run("authorized", appName, "--remove", "test");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't change authorized users: Can't remove yourself");
  run.expectExit(1);

  run = s.run("authorized", appName, "--remove", "testtest");
  run.waitSecs(commandTimeoutSecs);
  run.match(": removed testtest");
  run.expectExit(0);

  run = s.run("authorized", appName, "--list");
  run.waitSecs(commandTimeoutSecs);
  run.match("test\n");
  run.expectExit(0);

  run = s.run("authorized", appName, "--remove", "testtest");
  run.waitSecs(commandTimeoutSecs);
  run.matchErr("Couldn't change authorized users: testtest: not an authorized user");
  run.expectExit(1);

  testUtils.cleanUpApp(s, appName);
  testUtils.logout(s);
});

selftest.define('authorized - no username', ['net', 'slow'], function () {
  var s = new Sandbox;

  // We shouldn't be able to add authorized users before we set a
  // username.
  var email = testUtils.randomUserEmail();
  var username = testUtils.randomString(10);
  var appName = testUtils.randomAppName() + "." +
        (process.env.DEPLOY_HOSTNAME || config.getDeployHostname());
  var token = testUtils.deployWithNewEmail(s, email, appName);
  var run = s.run('authorized', appName, '--add', 'test');
  run.waitSecs(commandTimeoutSecs);
  run.matchErr('You must set a password on your account before ' +
               'you can authorize other users');
  run.expectExit(1);
  // After we set a username, we should be able to authorize others.
  testUtils.registerWithToken(token, username, 'testtest', email);
  run = s.run('authorized', appName, '--add', 'test');
  run.waitSecs(commandTimeoutSecs);
  run.match(': added test');
  run.expectExit(0);
  testUtils.cleanUpApp(s, appName);
  testUtils.logout(s);
});


import { execFileSync } from '../utils/processes.js';

var selftest = require('../tool-testing/selftest.js');
var Sandbox = selftest.Sandbox;
var files = require('../fs/files.js');
var _ = require('underscore');

// Copy the contents of one file to another.  In these series of tests, we often
// want to switch contents of package.js files. It is more legible to copy in
// the backup file rather than trying to write into it manually.
//
// XXX: Surely there is a function for this in fs?
// XXX: In which case, perhaps move this to sandbox.
var copyFile = function(from, to, sand) {
  var contents = sand.read(from);
  if (!contents) {
    throw new Error("File " + from + " does not exist.");
  };
  sand.write(to, contents);
};

// Given a sandbox, that has the app as its currend cwd, read the versions file
// and read the plugins list.
//
// sand: a sandbox, that has the main app directory as its cwd.
var getCordovaPluginsList = function(sand) {
  var env = files.currentEnvWithPathsAdded(files.getCurrentNodeBinDir());
  env.METEOR_WAREHOUSE_DIR = sand.warehouse;

  var lines = selftest.execFileSync('cordova', ['plugins'],
    {
      cwd: files.pathJoin(sand.cwd, '.meteor', 'local', 'cordova-build'),
      env: env
    }).split("\n");
  if (lines[0].match(/No plugins/)) {
    lines = [];
  }
  lines.sort();
  return lines;
}

// Given a sandbox, that has the app as its currend cwd, read the versions file
// and check that it contains the plugins that we are looking for. We don't
// check the order, we just want to make sure that the right dependencies are
// in.
//
// sand: a sandbox, that has the main app directory as its cwd.
// plugins: an array of plugins in order.
var checkCordovaPlugins = selftest.markStack(function(sand, plugins) {
  var cordovaPlugins = getCordovaPluginsList(sand);

  plugins = _.clone(plugins).sort();

  var i = 0;
  _.each(cordovaPlugins, function(line) {
    if (!line || line === '') {
      return;
    }
    // XXX should check for the version as well?
    selftest.expectEqual(line.split(' ')[0], plugins[i]);
    i++;
  });
  selftest.expectEqual(plugins.length, i);
});

// Like the function above but only looks if a certain plugin is on the list
var checkCordovaPluginExists = selftest.markStack(function(sand, plugin) {
  var cordovaPlugins = getCordovaPluginsList(sand);
  var found = false;
  cordovaPlugins = cordovaPlugins.map(function (line) {
    if (line && line !== '') {
      return line.split(' ')[0];
    }
  });
  selftest.expectTrue(_.contains(cordovaPlugins, plugin));
});

// Given a sandbox, that has the app as its cwd, read the cordova plugins
// file and check that it contains exactly the plugins specified, in order.
//
// sand: a sandbox, that has the main app directory as its cwd.
// plugins: an array of plugins in order. Plugins can be of the form:
//
//    standard-app-plugins (ie: name), in which case this will match any
//    version of that plugin as long as it is included.
//
//    awesome-plugin@1.0.0 (ie: name@version) to match that name at that
//    version explicitly. This is for plugins that we included at a specific
//    version.
var checkUserPlugins = function(sand, plugins) {
  var lines = sand.read(".meteor/cordova-plugins").split("\n");
  var depend = {};
  _.each(lines, function(line) {
    if (!line) {
      return;
    }
    // plugins are stored of the form foo@1.0.0, so this should give us an
    // array [foo, 1.0.0].
    var split = line.split('@');
    var plugins = split[0];
    depend[plugins] = split[1];
  });
  var i = 0;

  _.each(plugins, function (plugins) {
    var split = plugins.split('@');
    if (split.length > 1) {
      selftest.expectEqual(depend[split[0]], split[1]);
    } else {
      var exists = _.has(depend, split[0]);
      selftest.expectEqual(exists, true);
    }
    i++;
  });
  selftest.expectEqual(plugins.length, i);
};

var startAppOnAndroidEmulator = function (s) {
  var run = s.run("run", "android");
  // Building and running the app on the Android Emulator can take a long time.
  run.waitSecs(60);
  run.match("Started app on Android Emulator");
  return run;
}

var addPlatform = function (s, platform) {
  var run = s.run("add-platform", "android");
  // Cordova may need to download cordova-android if it's not already
  // cached (in ~/.cordova).
  run.waitSecs(30);
  run.match("added platform");
}

// Add plugins to an app. Change the contents of the plugins and their
// dependencies, make sure that the app still refreshes.
selftest.define("change cordova plugins", ["cordova"], function () {
  var s = new Sandbox();
  var run;

  s.set("METEOR_WATCH_PRIORITIZE_CHANGED", "false");

  // Starting a run
  s.createApp("myapp", "package-tests");
  s.cd("myapp");
  run = s.run();
  run.match("myapp");
  run.match("proxy");
  run.waitSecs(30);
  run.match("MongoDB");
  run.match("your app");
  run.match("running at");
  run.match("localhost");

  // Add a local package contains-cordova-plugin.
  s.write(".meteor/packages", "meteor-base \n contains-cordova-plugin");
  run.match("restarted");

  // Change something in the plugin.
  s.cp('packages/contains-cordova-plugin/package2.js', 'packages/contains-cordova-plugin/package.js');
  run.match("restarted");

  // Introduce an error.
  s.cp('packages/contains-cordova-plugin/package3.js', 'packages/contains-cordova-plugin/package.js');
  run.match("valid version");

  // Fix the error.
  s.cp('packages/contains-cordova-plugin/package2.js', 'packages/contains-cordova-plugin/package.js');
  run.match("restarted");
});

// Add plugins through the command line, and make sure that the correct set of
// changes is reflected in .meteor/packages, .meteor/versions and list
selftest.define("add cordova plugins", ["slow", "cordova"], function () {
  var s = new Sandbox();
  var run;

  // Starting a run
  s.createApp("myapp", "package-tests");
  s.cd("myapp");
  s.set("METEOR_OFFLINE_CATALOG", "t");

  run = s.run("remove", "meteor-base");
  run.match("removed");

  run = s.run("run", "android");
  run.matchErr("Please add the Android platform to your project first");
  run.match("meteor add-platform ");

  run = addPlatform(s, 'android');

  run = s.run("add", "cordova:cordova-plugin-camera@1.2.0");
  run.match("Added Cordova plugin cordova-plugin-camera");
  run.expectExit(0);

  run = s.run("add", "cordova:cordova-plugin-file");
  run.matchErr("exact version");
  run.expectExit(1);

  // The current behavior doesn't fail if a plugin is not in the registry until
  // build time.
  run = s.run("add", "cordova:foo@1.0.0");
  run.match("Added Cordova plugin foo");
  run.expectExit(0);

  run = s.run("remove", "cordova:foo");
  run.match("Removed Cordova plugin foo");
  run.expectExit(0);

  checkUserPlugins(s, ["cordova-plugin-camera"]);

  run = s.run("add", "contains-cordova-plugin");
  run.match("added,");
  run.match("contains a cordova plugin");
  run.expectExit(0);

  checkUserPlugins(s, ["cordova-plugin-camera"]);

  run = s.run("list");
  run.match("cordova-plugin-camera");

  run = s.run("list-platforms");
  run.match("android");

  run = s.run("build", '../a', "--server", "localhost:3000");
  run.waitSecs(60);
  run.expectExit(0);

  checkCordovaPlugins(s, ["cordova-plugin-camera",
    "com.phonegap.plugins.facebookconnect"]);

  // Remove a plugin
  run = s.run("remove", "contains-cordova-plugin");
  run.match("removed");

  run = s.run("build", '../a', "--server", "localhost:3000");
  run.waitSecs(60);
  run.expectExit(0);

  run = s.run("remove", "cordova:cordova-plugin-camera");
  run.match("Removed");
  run.expectExit(0);

  run = s.run("build", '../a', "--server", "localhost:3000");
  run.waitSecs(60);
  run.expectExit(0);

  checkCordovaPlugins(s, []);

  run = s.run("add", "cordova:cordova-plugin-device@1.0.1");
  run.match("Added");
  run.expectExit(0);

  run = s.run("build", '../a', "--server", "localhost:3000");
  run.waitSecs(60);
  run.expectExit(0);
  checkCordovaPlugins(s, ["cordova-plugin-device"]);

  run = s.run("remove", "cordova:cordova-plugin-device");
  run.match("Removed");
  run.expectExit(0);

  run = s.run("add", "cordova:com.example.plugin@file://");
  run.matchErr("exact version");
  run.expectExit(1);

  run = s.run("add", "cordova:com.example.plugin@file://../../plugin_directory");
  run.match("Added Cordova plugin com.example.plugin");
  run.expectExit(0);

  checkUserPlugins(s, ["com.example.plugin"]);

  // This should fail beacuse the plugin does not exists at the specified path
  run = s.run("build", '../a', "--server", "localhost:3000");
  run.waitSecs(30);
  run.expectExit(1);

  checkCordovaPlugins(s, []);

  // Add a package with Cordova.depends with local plugin (added from path)
  run = s.run("add", "empty-cordova-plugin");
  run.match("added,");
  run.match("contains an empty cordova plugin");
  run.expectExit(0);
});

selftest.define("remove cordova plugins", ['cordova'], function () {
  var s = new Sandbox();
  var run;

  s.createApp("myapp", "package-tests");
  s.cd("myapp");
  run = s.run("add", "cordova:cordova-plugin-camera@0.3.0");
  run.expectExit(0);

  checkUserPlugins(s, ["cordova-plugin-camera"]);

  // Removing a plugin that hasn't been added should say that it isn't
  // in this project.
  run = s.run("remove", "cordova:blahblah");
  run.matchErr("not in this project");
  run.forbidAll("Removed");
  run.expectExit(1);

  run = s.run("remove", "cordova:blahblah",
              "cordova:cordova-plugin-camera");
  run.matchErr("not in this project");
  run.match("Removed");
  run.expectExit(1);
  checkUserPlugins(s, []);

  run = s.run("add", "cordova:com.example.plugin@file://../../plugin_directory");
  run.match("Added Cordova plugin com.example.plugin");
  run.expectExit(0);
  checkUserPlugins(s, ["com.example.plugin"]);

  run = s.run("remove", "cordova:com.example.plugin");
  run.match("Removed");
  run.expectExit(0);
  checkUserPlugins(s, []);

});

selftest.define("meteor exits when cordova platforms it is currently running \
are removed", ["slow", "cordova"], function () {
  var s = new Sandbox();
  var run;

  s.createApp("myapp", "package-tests");
  s.cd("myapp");

  addPlatform(s, "android");

  run = startAppOnAndroidEmulator(s);

  // Remove a platform via command line
  platformRun = s.run("remove-platform", "android");
  platformRun.match("removed platform");

  run.waitSecs(60);
  run.matchErr("Your app's platforms have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);

  addPlatform(s, "android");

  // Remove a platform in .meteor/platforms
  run = startAppOnAndroidEmulator(s);

  platforms = s.read(files.pathJoin(".meteor", "platforms"));
  platforms = platforms.replace(/android/g, "");
  s.write(files.pathJoin(".meteor", "platforms"), platforms);

  run.waitSecs(60);
  run.matchErr("Your app's platforms have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);
});

selftest.define("meteor reinstalls only local cordova plugins on consecutive builds/runs", ["slow", "cordova"], function () {
  var s = new Sandbox();
  var run;

  s.createApp("myapp", "package-tests");
  s.cd("myapp");

  run = addPlatform(s, 'android');

  var
    pluginPath          = '../cordova-local-plugin',
    pluginSource        = "packages/empty-cordova-plugin/plugin",
    androidPluginSource = ".meteor/local/cordova-build/platforms/android/src";

  // Copy fake cordova plugin to ../cordova-local-plugin
  s.mkdir(pluginPath);
  s.cp(pluginSource + '/plugin.xml', pluginPath + '/plugin.xml');
  s.mkdir(pluginPath + '/www');
  s.mkdir(pluginPath + '/src');
  s.mkdir(pluginPath + '/src/android');
  s.cp(pluginSource + '/www/Empty.js', pluginPath +'/www/Empty.js');
  s.cp(
    pluginSource + '/src/android/Empty.java',
    pluginPath + '/src/android/Empty.java'
  );

  // Add the local cordova plugin
  run = s.run("add", "cordova:com.cordova.empty@file://../cordova-local-plugin");
  run.match("Added Cordova plugin com.cordova.empty");
  run.expectExit(0);

  checkUserPlugins(s, ["com.cordova.empty"]);

  // Run meteor and check if the cordova android build have the plugin file.
  run = startAppOnAndroidEmulator(s);
  run.stop();

  selftest.expectTrue(
    s.read(
      androidPluginSource + "/com/cordova/empty/Empty.java"
    ).indexOf('change') === -1
  );
  selftest.expectTrue(
    s.read(
      androidPluginSource + "/com/cordova/empty/Empty.java"
    ).indexOf('CordovaPlugin') > -1
  );

  // Copy changed file to the plugin
  s.cp(
    pluginSource + '/src/android/Empty_changed.java',
    pluginPath + '/src/android/Empty.java'
  );

  // Check if the local plugin will be refreshed
  run = startAppOnAndroidEmulator(s);
  run.stop();

  selftest.expectTrue(
    s.read(
      androidPluginSource + "/com/cordova/empty/Empty.java"
    ).indexOf('change') > -1
  );

  // Now test the same scenario but with builds
  s.cp(
    pluginSource + '/src/android/Empty.java',
    pluginPath + '/src/android/Empty.java'
  );

  run = s.run("build", '../a', "--server", "localhost:3000");
  run.waitSecs(60);
  run.expectExit(0);

  selftest.expectTrue(
    s.read(
      '../a/android/project/src/com/cordova/empty/Empty.java'
    ).indexOf('change') === -1
  );

  checkCordovaPluginExists(s, "com.cordova.empty");

  s.cp(
    pluginSource + '/src/android/Empty_changed.java',
    pluginPath + '/src/android/Empty.java'
  );

  run = s.run("build", '../a', "--server", "localhost:3000");
  run.waitSecs(60);
  run.expectExit(0);

  selftest.expectTrue(
    s.read(
      '../a/android/project/src/com/cordova/empty/Empty.java'
    ).indexOf('change') > -1
  );
});

selftest.define("meteor exits when cordova plugins change", ["slow", "cordova"], function () {
  var s = new Sandbox();
  var run;

  s.createApp("myapp", "package-tests");
  s.cd("myapp");

  addPlatform(s, "android");

  run = startAppOnAndroidEmulator(s);

  // First add a plugin directly.
  var pluginRun = s.run("add", "cordova:cordova-plugin-camera@1.0.0");
  pluginRun.expectExit(0);

  run.waitSecs(60);
  run.matchErr("Your app's Cordova plugins have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);

  run = startAppOnAndroidEmulator(s);

  // This shouldn't cause an exit because it contains the same plugin
  // that we're already using.
  pluginRun = s.run("add", "contains-old-cordova-plugin");
  pluginRun.expectExit(0);
  run.waitSecs(60);
  run.match("restarted");

  pluginRun = s.run("remove", "contains-old-cordova-plugin");
  pluginRun.expectExit(0);
  run.waitSecs(60);
  run.match("restarted");

  // This exits because it contains a new plugin, facebookconnect.
  pluginRun = s.run("add", "contains-cordova-plugin");
  pluginRun.expectExit(0);
  run.waitSecs(60);
  run.matchErr("Your app's Cordova plugins have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);

  run = startAppOnAndroidEmulator(s);

  pluginRun = s.run("remove", "contains-cordova-plugin");
  pluginRun.expectExit(0);
  run.waitSecs(60);
  run.matchErr("Your app's Cordova plugins have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);

  run = startAppOnAndroidEmulator(s);

  pluginRun = s.run("remove", "cordova:cordova-plugin-camera");
  pluginRun.expectExit(0);
  run.waitSecs(60);
  run.matchErr("Your app's Cordova plugins have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);

  // Adding and removing just a Meteor package that contains plugins
  // should also cause the tool to exit.
  run = startAppOnAndroidEmulator(s);

  pluginRun = s.run("add", "contains-cordova-plugin");
  pluginRun.expectExit(0);
  run.waitSecs(60);
  run.matchErr("Your app's Cordova plugins have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);

  run = startAppOnAndroidEmulator(s);

  pluginRun = s.run("remove", "contains-cordova-plugin");
  pluginRun.expectExit(0);
  run.waitSecs(60);
  run.matchErr("Your app's Cordova plugins have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);

  // Adding a package with a newer version of a plugin that we're
  // already using should also cause us to restart.
  pluginRun = s.run("add", "contains-old-cordova-plugin");
  pluginRun.expectExit(0);

  run = startAppOnAndroidEmulator(s);

  pluginRun = s.run("add", "contains-camera-cordova-plugin");
  pluginRun.expectExit(0);

  run.matchErr("Your app's Cordova plugins have changed");
  run.matchErr("Restart meteor");
  run.expectExit(254);
});

var buildAndCheckPluginInStar = selftest.markStack(function (s, name, version) {
  var run = s.run(
    "build", '../a', "--server", "localhost:3000", "--directory");
  run.waitSecs(60);
  run.expectExit(0);

  var starJson = JSON.parse(s.read('../a/bundle/star.json'));
  var program = _.findWhere(starJson.programs, { name: "web.cordova" });
  if (! program) {
    selftest.fail("No cordova program in star.json?");
    return;
  }
  var plugins = program.cordovaDependencies;
  selftest.expectEqual(plugins[name], version);
});

selftest.define("cordova plugins in star.json, direct and transitive", ["slow", "cordova"], function () {
  var s = new Sandbox();
  var run;

  // Starting a run
  s.createApp("myapp", "package-tests");
  s.cd("myapp");
  s.set("METEOR_OFFLINE_CATALOG", "t");

  run = addPlatform(s, 'android');

  // Add a direct dependency: it should appear in star.json after we
  // build.
  run = s.run("add", "cordova:cordova-plugin-camera@1.0.0");
  run.expectExit(0);

  buildAndCheckPluginInStar(s, "cordova-plugin-camera", "1.0.0");

  // Add a Cordova dependency from a package, at a newer version: the
  // plugin should appear in star.json at the version added in the
  // direct dependency, even though it's older than the version that the
  // package uses.
  run = s.run("add", "contains-camera-cordova-plugin");
  run.expectExit(0);

  buildAndCheckPluginInStar(s, "cordova-plugin-camera", "1.0.0");

  // After removing the direct dependency, star.json should contain
  // camera@1.2.0, the version used by the package.
  run = s.run("remove", "cordova:cordova-plugin-camera");
  run.expectExit(0);

  buildAndCheckPluginInStar(s, "cordova-plugin-camera", "1.2.0");

  // If we add another package that uses an older version of the plugin,
  // the version in star.json shouldn't change.
  run = s.run("add", "contains-old-cordova-plugin");
  run.expectExit(0);

  buildAndCheckPluginInStar(s, "cordova-plugin-camera", "1.2.0");

  // If we remove the package that uses a newer version, the version in
  // star.json should change.
  run = s.run("remove", "contains-camera-cordova-plugin");
  run.expectExit(0);

  buildAndCheckPluginInStar(s, "cordova-plugin-camera", "1.0.0");
});


# Meteor packages used by this project, one per line.
#
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.

autopublish
insecure
test-package
standard-app-packages


var getCordovaPluginsList = function(sand) {

var env = files.currentEnvWithPathsAdded(files.getCurrentNodeBinDir());

var lines = selftest.execFileSync('cordova', ['plugins'],

cwd: files.pathJoin(sand.cwd, '.meteor', 'local', 'cordova-build'),

keep keep keep keep replace keep keep keep keep

var lines = execFileSync('cordova', ['plugins'],