var _ = require('underscore'); var files = require('./files.js'); var utils = require('./utils.js'); var parseStack = require('./parse-stack.js'); var release = require('./release.js'); var catalog = require('./catalog.js'); var archinfo = require('./archinfo.js'); var Future = require('fibers/future'); var isopackets = require("./isopackets.js"); var config = require('./config.js'); var buildmessage = require('./buildmessage.js'); var util = require('util'); var child_process = require('child_process'); var webdriver = require('browserstack-webdriver'); var phantomjs = require('phantomjs'); var catalogRemote = require('./catalog-remote.js'); var Console = require('./console.js').Console; var tropohouseModule = require('./tropohouse.js'); var packageMapModule = require('./package-map.js'); var isopackCacheModule = require('./isopack-cache.js'); // Exception representing a test failure var TestFailure = function (reason, details) { var self = this; self.reason = reason; self.details = details || {}; self.stack = (new Error).stack; }; // Use this to decorate functions that throw TestFailure. Decorate the // first function that should not be included in the call stack shown // to the user. var markStack = function (f) { return parseStack.markTop(f); }; // Call from a test to throw a TestFailure exception and bail out of the test var fail = markStack(function (reason) { throw new TestFailure(reason); }); // Call from a test to assert that 'actual' is equal to 'expected', // with 'actual' being the value that the test got and 'expected' // being the expected value var expectEqual = markStack(function (actual, expected) { var Package = isopackets.load('ejson'); if (! Package.ejson.EJSON.equals(actual, expected)) { throw new TestFailure("not-equal", { expected: expected, actual: actual }); } }); // Call from a test to assert that 'actual' is truthy. var expectTrue = markStack(function (actual) { if (! actual) { throw new TestFailure('not-true'); } }); // Call from a test to assert that 'actual' is falsey. var expectFalse = markStack(function (actual) { if (actual) { throw new TestFailure('not-false'); } }); var expectThrows = markStack(function (f) { var threw = false; try { f(); } catch (e) { threw = true; } if (! threw) throw new TestFailure("expected-exception"); }); // Execute a command synchronously, discarding stderr. var execFileSync = function (binary, args, opts) { return Future.wrap(function(cb) { var cb2 = function(err, stdout, stderr) { cb(err, stdout); }; child_process.execFile(binary, args, opts, cb2); })().wait(); }; var doOrThrow = function (f) { var ret; var messages = buildmessage.capture(function () { ret = f(); }); if (messages.hasMessages()) { throw Error(messages.formatMessages()); } return ret; }; // Our current strategy for running tests that need warehouses is to build all // packages from the checkout into this temporary tropohouse directory, and for // each test that need a fake warehouse, copy the built packages into the // test-specific warehouse directory. This isn't particularly fast, but it'll // do for now. We build the packages during the first test that needs them. var builtPackageTropohouseDir = null; var tropohouseLocalCatalog = null; var tropohouseIsopackCache = null; // Let's build a minimal set of packages that's enough to get self-test // working. (And that doesn't need us to download any Atmosphere packages.) var ROOT_PACKAGES_TO_BUILD_IN_SANDBOX = [ // We need the tool in order to run from the fake warehouse at all. "meteor-tool", // We need the packages in the skeleton app in order to test 'meteor create'. "meteor-platform", "autopublish", "insecure" ]; var setUpBuiltPackageTropohouse = function () { if (builtPackageTropohouseDir) return; builtPackageTropohouseDir = files.mkdtemp('built-package-tropohouse'); if (config.getPackagesDirectoryName() !== 'packages') throw Error("running self-test with METEOR_PACKAGE_SERVER_URL set?"); var tropohouse = new tropohouseModule.Tropohouse(builtPackageTropohouseDir); tropohouseLocalCatalog = newSelfTestCatalog(); var versions = {}; _.each( tropohouseLocalCatalog.getAllNonTestPackageNames(), function (packageName) { versions[packageName] = tropohouseLocalCatalog.getLatestVersion(packageName).version; }); var packageMap = new packageMapModule.PackageMap(versions, { localCatalog: tropohouseLocalCatalog }); // Make an isopack cache that doesn't automatically save isopacks to disk and // has no access to versioned packages. tropohouseIsopackCache = new isopackCacheModule.IsopackCache({ packageMap: packageMap, includeCordovaUnibuild: true }); doOrThrow(function () { buildmessage.enterJob("building self-test packages", function () { // Build the packages into the in-memory IsopackCache. tropohouseIsopackCache.buildLocalPackages( ROOT_PACKAGES_TO_BUILD_IN_SANDBOX); }); }); // Save all the isopacks into builtPackageTropohouseDir/packages. (Note that // we are always putting them into the default 'packages' (assuming // $METEOR_PACKAGE_SERVER_URL is not set in the self-test process itself) even // though some tests will want them to be under // 'packages-for-server/test-packages'; we'll fix this in _makeWarehouse. tropohouseIsopackCache.eachBuiltIsopack(function (name, isopack) { tropohouse._saveIsopack(isopack, name); }); }; var newSelfTestCatalog = function () { if (! files.inCheckout()) throw Error("Only can build packages from a checkout"); var catalogLocal = require('./catalog-local.js'); var selfTestCatalog = new catalogLocal.LocalCatalog; var messages = buildmessage.capture( { title: "scanning local core packages" }, function () { // When building a fake warehouse from a checkout, we use local packages, // but *ONLY THOSE FROM THE CHECKOUT*: not app packages or $PACKAGE_DIRS // packages. One side effect of this: we really really expect them to all // build, and we're fine with dying if they don't (there's no worries // about needing to springboard). selfTestCatalog.initialize({ localPackageSearchDirs: [files.pathJoin( files.getCurrentToolsDir(), 'packages')] }); }); if (messages.hasMessages()) { Console.arrowError("Errors while scanning core packages:"); Console.printMessages(messages); throw new Error("scan failed?"); } return selfTestCatalog; }; /////////////////////////////////////////////////////////////////////////////// // Matcher /////////////////////////////////////////////////////////////////////////////// // Handles the job of waiting until text is seen that matches a // regular expression. var Matcher = function (run) { var self = this; self.buf = ""; self.ended = false; self.matchPattern = null; self.matchFuture = null; self.matchStrict = null; self.run = run; // used only to set a field on exceptions }; _.extend(Matcher.prototype, { write: function (data) { var self = this; self.buf += data; self._tryMatch(); }, match: function (pattern, timeout, strict) { var self = this; if (self.matchFuture) throw new Error("already have a match pending?"); self.matchPattern = pattern; self.matchStrict = strict; var f = self.matchFuture = new Future; self._tryMatch(); // could clear self.matchFuture var timer = null; if (timeout) { timer = setTimeout(function () { self.matchPattern = null; self.matchStrict = null; self.matchFuture = null; f['throw'](new TestFailure('match-timeout', { run: self.run })); }, timeout * 1000); } try { return f.wait(); } finally { if (timer) clearTimeout(timer); } }, end: function () { var self = this; self.ended = true; self._tryMatch(); }, matchEmpty: function () { var self = this; if (self.buf.length > 0) { Console.info("Extra junk is :", self.buf); throw new TestFailure('junk-at-end', { run: self.run }); } }, _tryMatch: function () { var self = this; var f = self.matchFuture; if (! f) return; var ret = null; if (self.matchPattern instanceof RegExp) { var m = self.buf.match(self.matchPattern); if (m) { if (self.matchStrict && m.index !== 0) { self.matchFuture = null; self.matchStrict = null; self.matchPattern = null; f['throw'](new TestFailure('junk-before', { run: self.run })); return; } ret = m; self.buf = self.buf.slice(m.index + m[0].length); } } else { var i = self.buf.indexOf(self.matchPattern); if (i !== -1) { if (self.matchStrict && i !== 0) { self.matchFuture = null; self.matchStrict = null; self.matchPattern = null; f['throw'](new TestFailure('junk-before', { run: self.run })); return; } ret = self.matchPattern; self.buf = self.buf.slice(i + self.matchPattern.length); } } if (ret !== null) { self.matchFuture = null; self.matchStrict = null; self.matchPattern = null; f['return'](ret); return; } if (self.ended) { var failure = new TestFailure('no-match', { run: self.run, pattern: self.matchPattern }); self.matchFuture = null; self.matchStrict = null; self.matchPattern = null; f['throw'](failure); return; } } }); /////////////////////////////////////////////////////////////////////////////// // OutputLog /////////////////////////////////////////////////////////////////////////////// // Maintains a line-by-line merged log of multiple output channels // (eg, stdout and stderr). var OutputLog = function (run) { var self = this; // each entry is an object with keys 'channel', 'text', and if it is // the last entry and there was no newline terminator, 'bare' self.lines = []; // map from a channel name to an object representing a partially // read line of text on that channel. That object has keys 'text' // (text read), 'offset' (cursor position, equal to text.length // unless a '\r' has been read). self.buffers = {}; // a Run, exclusively for inclusion in exceptions self.run = run; }; _.extend(OutputLog.prototype, { write: function (channel, text) { var self = this; if (! _.has(self.buffers, 'channel')) self.buffers[channel] = { text: '', offset: 0}; var b = self.buffers[channel]; while (text.length) { var m = text.match(/^[^\n\r]+/); if (m) { // A run of non-control characters. b.text = b.text.substr(0, b.offset) + m[0] + b.text.substr(b.offset + m[0].length); b.offset += m[0].length; text = text.substr(m[0].length); continue; } if (text[0] === '\r') { b.offset = 0; text = text.substr(1); continue; } if (text[0] === '\n') { self.lines.push({ channel: channel, text: b.text }); b.text = ''; b.offset = 0; text = text.substr(1); continue; } throw new Error("conditions should have been exhaustive?"); } }, end: function () { var self = this; _.each(_.keys(self.buffers), function (channel) { if (self.buffers[channel].text.length) { self.lines.push({ channel: channel, text: self.buffers[channel].text, bare: true }); self.buffers[channel] = { text: '', offset: 0}; } }); }, forbid: function (pattern, channel) { var self = this; _.each(self.lines, function (line) { if (channel && channel !== line.channel) return; var match = (pattern instanceof RegExp) ? (line.text.match(pattern)) : (line.text.indexOf(pattern) !== -1); if (match) throw new TestFailure('forbidden-string-present', { run: self.run }); }); }, get: function () { var self = this; return self.lines; } }); /////////////////////////////////////////////////////////////////////////////// // Sandbox /////////////////////////////////////////////////////////////////////////////// // Represents an install of the tool. Creating this creates a private // sandbox with its own state, separate from the state of the current // meteor install or checkout, from the user's homedir, and from the // state of any other sandbox. It also creates an empty directory // which will be, by default, the cwd for runs created inside the // sandbox (you can change this with the cd() method). // // This will throw TestFailure if it has to build packages to set up // the sandbox and the build fails. So, only call it from inside // tests. // // options: // - warehouse: set to sandbox the warehouse too. If you don't do // this, the tests are run in the same context (checkout or // warehouse) as the actual copy of meteor you're running (the // meteor in 'meteor self-test'. This may only be set when you're // running 'meteor self-test' from a checkout. If it is set, it // should look something like this: // { // version1: { tools: 'tools1', notices: (...) }, // version2: { tools: 'tools2', upgraders: ["a"], // notices: (...), latest: true } // } // This would set up a simulated warehouse with two releases in it, // one called 'version1' and having a tools version of 'tools1', and // similarly with 'version2'/'tools2', with the latter being marked // as the latest release, and the latter also having a single // upgrader named "a". The releases are made by building the // checkout into a release, and are identical except for their // version names. If you pass 'notices' (which is optional), set it // to the verbatim contents of the notices.json file for the // release, as an object. // - fakeMongo: if set, set an environment variable that causes our // 'fake-mongod' stub process to be started instead of 'mongod'. The // tellMongo method then becomes available on Runs for controlling // the stub. // - clients // - browserstack: true if browserstack clients should be used // - port: the port that the clients should run on var Sandbox = function (options) { var self = this; // default options options = _.extend({ clients: {} }, options); self.root = files.mkdtemp(); self.warehouse = null; self.home = files.pathJoin(self.root, 'home'); files.mkdir(self.home, 0755); self.cwd = self.home; self.env = {}; self.fakeMongo = options.fakeMongo; // By default, tests use the package server that this meteor binary is built // with. If a test is tagged 'test-package-server', it uses the test // server. Tests that publish packages should have this flag; tests that // assume that the release's packages can be found on the server should not. // Note that this only affects subprocess meteor runs, not direct invocation // of packageClient! if (_.contains(runningTest.tags, 'test-package-server')) { self.set('METEOR_PACKAGE_SERVER_URL', exports.testPackageServerUrl); } if (_.has(options, 'warehouse')) { if (!files.inCheckout()) throw Error("make only use a fake warehouse in a checkout"); self.warehouse = files.pathJoin(self.root, 'tropohouse'); self._makeWarehouse(options.warehouse); } self.clients = [new PhantomClient({ host: 'localhost', port: options.clients.port || 3000 })]; if (options.clients && options.clients.browserstack) { var browsers = [ { browserName: 'firefox' }, { browserName: 'chrome' }, { browserName: 'internet explorer', browserVersion: '11' }, { browserName: 'internet explorer', browserVersion: '8', timeout: 60 }, { browserName: 'safari' }, { browserName: 'android' } ]; _.each(browsers, function (browser) { self.clients.push(new BrowserStackClient({ host: 'localhost', port: 3000, browserName: browser.browserName, browserVersion: browser.browserVersion, timeout: browser.timeout })); }); } var meteorScript = process.platform === "win32" ? "meteor.bat" : "meteor"; // Figure out the 'meteor' to run if (self.warehouse) self.execPath = files.pathJoin(self.warehouse, meteorScript); else self.execPath = files.pathJoin(files.getCurrentToolsDir(), meteorScript); }; _.extend(Sandbox.prototype, { // Create a new test run of the tool in this sandbox. run: function (/* arguments */) { var self = this; return new Run(self.execPath, { sandbox: self, args: _.toArray(arguments), cwd: self.cwd, env: self._makeEnv(), fakeMongo: self.fakeMongo }); }, // Tests a set of clients with the argument function. Each call to f(run) // instantiates a Run with a different client. // Use: // sandbox.testWithAllClients(function (run) { // // pre-connection checks // run.connectClient(); // // post-connection checks // }); testWithAllClients: function (f) { var self = this; var argsArray = _.compact(_.toArray(arguments).slice(1)); console.log("running test with " + self.clients.length + " client(s)."); _.each(self.clients, function (client) { console.log("testing with " + client.name + "..."); var run = new Run(self.execPath, { sandbox: self, args: argsArray, cwd: self.cwd, env: self._makeEnv(), fakeMongo: self.fakeMongo, client: client }); run.baseTimeout = client.timeout; f(run); }); }, // Copy an app from a template into the current directory in the // sandbox. 'to' is the subdirectory to put the app in, and // 'template' is a subdirectory of tools/tests/apps to copy. // // Note that the arguments are the opposite order from 'cp'. That // seems more intuitive to me -- if you disagree, my apologies. // // For example: // s.createApp('myapp', 'empty'); // s.cd('myapp'); createApp: function (to, template, options) { var self = this; options = options || {}; files.cp_r(files.pathJoin(files.convertToStandardPath(__dirname), 'tests', 'apps', template), files.pathJoin(self.cwd, to), { ignore: [/^local$/] }); // If the test isn't explicitly managing a mock warehouse, ensure that apps // run with our release by default. if (options.release) { self.write(files.pathJoin(to, '.meteor/release'), options.release); } else if (!self.warehouse && release.current.isProperRelease()) { self.write(files.pathJoin(to, '.meteor/release'), release.current.name); } if (options.dontPrepareApp) return; // Prepare the app (ie, build or download packages). We give this a nice // long timeout, which allows the next command to not need a bloated // timeout. (meteor create does this anyway.) self.cd(to, function () { var run = self.run("--prepare-app"); // XXX Can we cache the output of running this once somewhere, so that // multiple calls to createApp with the same template get the same cache? // This is a little tricky because isopack-buildinfo.json uses absolute // paths. run.waitSecs(20); run.expectExit(0); }); }, // Same as createApp, but with a package. // // @param packageDir {String} The directory in which to create the package // @param packageName {String} The package name to create. This string will // replace all appearances of ~package-name~ // in any package*.js files in the template // @param template {String} The package template to use. Found as a // subdirectory in tests/packages/ // // For example: // s.createPackage('me_mypack', me:mypack', 'empty'); // s.cd('me_mypack'); createPackage: function (packageDir, packageName, template) { var self = this; var packagePath = files.pathJoin(self.cwd, packageDir); var templatePackagePath = files.pathJoin( files.convertToStandardPath(__dirname), 'tests', 'packages', template); files.cp_r(templatePackagePath, packagePath); _.each(files.readdir(packagePath), function (file) { if (file.match(/^package.*\.js$/)) { var packageJsFile = files.pathJoin(packagePath, file); files.writeFile( packageJsFile, files.readFile(packageJsFile, "utf8") .replace("~package-name~", packageName)); } }); }, // Change the cwd to be used for subsequent runs. For example: // s.run('create', 'myapp').expectExit(0); // s.cd('myapp'); // s.run('add', 'somepackage') ... // If you provide a callback, it will invoke the callback and then // change the cwd back to the previous value. eg: // s.cd('app1', function () { // s.run('add', 'somepackage'); // }); // s.cd('app2', function () { // s.run('add', 'somepackage'); // }); cd: function (relativePath, callback) { var self = this; var previous = self.cwd; self.cwd = files.pathResolve(self.cwd, relativePath); if (callback) { callback(); self.cwd = previous; } }, // Set an environment variable for subsequent runs. set: function (name, value) { var self = this; self.env[name] = value; }, // Undo set(). unset: function (name) { var self = this; delete self.env[name]; }, // Write to a file in the sandbox, overwriting its current contents // if any. 'filename' is a path intepreted relative to the Sandbox's // cwd. 'contents' is a string (utf8 is assumed). write: function (filename, contents) { var self = this; files.writeFile(files.pathJoin(self.cwd, filename), contents, 'utf8'); }, // Reads a file in the sandbox as a utf8 string. 'filename' is a // path intepreted relative to the Sandbox's cwd. Returns null if // file does not exist. read: function (filename) { var self = this; var file = files.pathJoin(self.cwd, filename); if (!files.exists(file)) return null; else return files.readFile(files.pathJoin(self.cwd, filename), 'utf8'); }, // 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. cp: function(from, to) { var self = this; var contents = self.read(from); if (!contents) { throw new Error("File " + from + " does not exist."); }; self.write(to, contents); }, // Delete a file in the sandbox. 'filename' is as in write(). unlink: function (filename) { var self = this; files.unlink(files.pathJoin(self.cwd, filename)); }, // Make a directory in the sandbox. 'filename' is as in write(). mkdir: function (dirname) { var self = this; var dirPath = files.pathJoin(self.cwd, dirname); if (! files.exists(dirPath)) { files.mkdir(dirPath); } }, // Rename something in the sandbox. 'oldName' and 'newName' are as in write(). rename: function (oldName, newName) { var self = this; files.rename(files.pathJoin(self.cwd, oldName), files.pathJoin(self.cwd, newName)); }, // Return the current contents of .meteorsession in the sandbox. readSessionFile: function () { var self = this; return files.readFile(files.pathJoin(self.root, '.meteorsession'), 'utf8'); }, // Overwrite .meteorsession in the sandbox with 'contents'. You // could use this in conjunction with readSessionFile to save and // restore authentication states. writeSessionFile: function (contents) { var self = this; return files.writeFile(files.pathJoin(self.root, '.meteorsession'), contents, 'utf8'); }, _makeEnv: function () { var self = this; var env = _.clone(self.env); env.METEOR_SESSION_FILE = files.convertToOSPath( files.pathJoin(self.root, '.meteorsession')); if (self.warehouse) { // Tell it where the warehouse lives. env.METEOR_WAREHOUSE_DIR = files.convertToOSPath(self.warehouse); if (! _.contains(runningTest.tags, 'test-package-server')) { // Don't ever try to refresh the stub catalog we made. // (It's OK to refresh a catalog for the test package server though.) env.METEOR_OFFLINE_CATALOG = "t"; } } // By default (ie, with no mock warehouse and no --release arg) we should be // testing the actual release this is built in, so we pretend that it is the // latest release. if (!self.warehouse && release.current.isProperRelease()) env.METEOR_TEST_LATEST_RELEASE = release.current.name; return env; }, // Writes a stub warehouse (really a tropohouse) to the directory // self.warehouse. This warehouse only contains a meteor-tool package and some // releases containing that tool only (and no packages). // // packageServerUrl indicates which package server we think we are using. Use // the default, if we do not pass this in; you should pass it in any case that // you will be specifying $METEOR_PACKAGE_SERVER_URL in the environment of a // command you are running in this sandbox. _makeWarehouse: function (releases) { var self = this; // Ensure we have a tropohouse to copy stuff out of. setUpBuiltPackageTropohouse(); var serverUrl = self.env.METEOR_PACKAGE_SERVER_URL; var packagesDirectoryName = config.getPackagesDirectoryName(serverUrl); files.cp_r(files.pathJoin(builtPackageTropohouseDir, 'packages'), files.pathJoin(self.warehouse, packagesDirectoryName), { preserveSymlinks: true }); var stubCatalog = { syncToken: {}, formatVersion: "1.0", collections: { packages: [], versions: [], builds: [], releaseTracks: [], releaseVersions: [] } }; var packageVersions = {}; var toolPackageVersion = null; tropohouseIsopackCache.eachBuiltIsopack(function (packageName, isopack) { var packageRec = tropohouseLocalCatalog.getPackage(packageName); if (! packageRec) throw Error("no package record for " + packageName); stubCatalog.collections.packages.push(packageRec); var versionRec = tropohouseLocalCatalog.getLatestVersion(packageName); if (! versionRec) throw Error("no version record for " + packageName); stubCatalog.collections.versions.push(versionRec); stubCatalog.collections.builds.push({ architecture: isopack.buildArchitectures(), versionId: versionRec._id, _id: utils.randomToken() }); if (packageName === "meteor-tool") { toolPackageVersion = versionRec.version; } else { packageVersions[packageName] = versionRec.version; } }); if (! toolPackageVersion) throw Error("no meteor-tool?"); stubCatalog.collections.releaseTracks.push({ name: catalog.DEFAULT_TRACK, _id: utils.randomToken() }); // Now create each requested release. _.each(releases, function (configuration, releaseName) { // Release info stubCatalog.collections.releaseVersions.push({ track: catalog.DEFAULT_TRACK, _id: Math.random().toString(), version: releaseName, orderKey: releaseName, description: "test release " + releaseName, recommended: !!configuration.recommended, // XXX support multiple tools packages for springboard tests tool: "meteor-tool@" + toolPackageVersion, packages: packageVersions }); }); var dataFile = config.getPackageStorage({ root: self.warehouse, serverUrl: serverUrl }); var tmpCatalog = new catalogRemote.RemoteCatalog(); tmpCatalog.initialize({ packageStorage: dataFile }); tmpCatalog.insertData(stubCatalog); // And a cherry on top // XXX this is hacky files.linkToMeteorScript( files.pathJoin(self.warehouse, packagesDirectoryName, "meteor-tool", toolPackageVersion, 'mt-' + archinfo.host(), 'meteor'), files.pathJoin(self.warehouse, 'meteor')); } }); /////////////////////////////////////////////////////////////////////////////// // Client /////////////////////////////////////////////////////////////////////////////// var Client = function (options) { var self = this; self.host = options.host; self.port = options.port; self.url = "http://" + self.host + ":" + self.port + '/' + (Math.random() * 0x100000000 + 1).toString(36); self.timeout = options.timeout || 40; if (! self.connect || ! self.stop) { console.log("Missing methods in subclass of Client."); } }; // PhantomClient var PhantomClient = function (options) { var self = this; Client.apply(this, arguments); self.name = "phantomjs"; self.process = null; self._logError = true; }; util.inherits(PhantomClient, Client); _.extend(PhantomClient.prototype, { connect: function () { var self = this; var phantomPath = phantomjs.path; var scriptPath = files.pathJoin(files.getCurrentToolsDir(), "tools", "phantom", "open-url.js"); self.process = child_process.execFile(phantomPath, ["--load-images=no", files.convertToOSPath(scriptPath), self.url], {}, function (error, stdout, stderr) { if (self._logError && error) { console.log("PhantomJS exited with error ", error, "\nstdout:\n", stdout, "\nstderr:\n", stderr); } }); }, stop: function() { var self = this; // Suppress the expected SIGTERM exit 'failure' self._logError = false; self.process && self.process.kill(); self.process = null; } }); // BrowserStackClient var browserStackKey = null; var BrowserStackClient = function (options) { var self = this; Client.apply(this, arguments); self.tunnelProcess = null; self.driver = null; self.browserName = options.browserName; self.browserVersion = options.browserVersion; self.name = "BrowserStack - " + self.browserName; if (self.browserVersion) { self.name += " " + self.browserVersion; } }; util.inherits(BrowserStackClient, Client); _.extend(BrowserStackClient.prototype, { connect: function () { var self = this; // memoize the key if (browserStackKey === null) browserStackKey = self._getBrowserStackKey(); if (! browserStackKey) throw new Error("BrowserStack key not found. Ensure that you " + "have installed your S3 credentials."); var capabilities = { 'browserName' : self.browserName, 'browserstack.user' : 'meteor', 'browserstack.local' : 'true', 'browserstack.key' : browserStackKey }; if (self.browserVersion) { capabilities.browserVersion = self.browserVersion; } self._launchBrowserStackTunnel(function (error) { if (error) throw error; self.driver = new webdriver.Builder(). usingServer('http://hub.browserstack.com/wd/hub'). withCapabilities(capabilities). build(); self.driver.get(self.url); }); }, stop: function() { var self = this; self.tunnelProcess && self.tunnelProcess.kill(); self.tunnelProcess = null; self.driver && self.driver.quit(); self.driver = null; }, _getBrowserStackKey: function () { var outputDir = files.pathJoin(files.mkdtemp(), "key"); try { execFileSync("s3cmd", ["get", "s3://meteor-browserstack-keys/browserstack-key", outputDir ]); return files.readFile(outputDir, "utf8").trim(); } catch (e) { return null; } }, _launchBrowserStackTunnel: function (callback) { var self = this; var browserStackPath = files.pathJoin(files.getDevBundle(), 'bin', 'BrowserStackLocal'); files.chmod(browserStackPath, 0755); var args = [ browserStackPath, browserStackKey, [self.host, self.port, 0].join(','), // Disable Live Testing and Screenshots, just test with Automate. '-onlyAutomate', // Do not wait for the server to be ready to spawn the process. '-skipCheck' ]; self.tunnelProcess = child_process.execFile( '/bin/bash', ['-c', args.join(' ')] ); // Called when the SSH tunnel is established. self.tunnelProcess.stdout.on('data', function(data) { if (data.toString().match(/You can now access your local server/)) callback(); }); } }); /////////////////////////////////////////////////////////////////////////////// // Run /////////////////////////////////////////////////////////////////////////////// // Represents a test run of the tool (except we also use it in // tests/old.js to run Node scripts). Typically created through the // run() method on Sandbox, but can also be created directly, say if // you want to do something other than invoke the 'meteor' command in // a nice sandbox. // // Options: args, cwd, env // // The 'execPath' argument and the 'cwd' option are assumed to be standard // paths. // // Arguments in the 'args' option are not assumed to be standard paths, so // calling any of the 'files.*' methods on them is not safe. var Run = function (execPath, options) { var self = this; self.execPath = execPath; self.cwd = options.cwd || files.convertToStandardPath(process.cwd()); self.env = options.env || {}; self._args = []; self.proc = null; self.baseTimeout = 20; self.extraTime = 0; self.client = options.client; self.stdoutMatcher = new Matcher(self); self.stderrMatcher = new Matcher(self); self.outputLog = new OutputLog(self); self.exitStatus = undefined; // 'null' means failed rather than exited self.exitFutures = []; var opts = options.args || []; self.args.apply(self, opts || []); self.fakeMongoPort = null; self.fakeMongoConnection = null; if (options.fakeMongo) { self.fakeMongoPort = require('./utils.js').randomPort(); self.env.METEOR_TEST_FAKE_MONGOD_CONTROL_PORT = self.fakeMongoPort; } runningTest.onCleanup(function () { self._stopWithoutWaiting(); }); }; _.extend(Run.prototype, { // Set command-line arguments. This may be called multiple times as // long as the run has not yet started (the run starts after the // first call to a function that requires it, like match()). // // Pass as many arguments as you want. Non-object values will be // cast to string, and object values will be treated as maps from // option names to values. args: function (/* arguments */) { var self = this; if (self.proc) throw new Error("already started?"); _.each(_.toArray(arguments), function (a) { if (typeof a !== "object") { self._args.push('' + a); } else { _.each(a, function (value, key) { self._args.push("--" + key); self._args.push('' + value); }); } }); }, connectClient: function () { var self = this; if (! self.client) throw new Error("Must create Run with a client to use connectClient()."); self._ensureStarted(); self.client.connect(); }, _exited: function (status) { var self = this; if (self.exitStatus !== undefined) throw new Error("already exited?"); self.client && self.client.stop(); self.exitStatus = status; var exitFutures = self.exitFutures; self.exitFutures = null; _.each(exitFutures, function (f) { f['return'](); }); self.stdoutMatcher.end(); self.stderrMatcher.end(); }, _ensureStarted: function () { var self = this; if (self.proc) return; var env = _.clone(process.env); _.extend(env, self.env); self.proc = child_process.spawn(files.convertToOSPath(self.execPath), self._args, { cwd: files.convertToOSPath(self.cwd), env: env }); self.proc.on('close', function (code, signal) { if (self.exitStatus === undefined) self._exited({ code: code, signal: signal }); }); self.proc.on('exit', function (code, signal) { if (self.exitStatus === undefined) self._exited({ code: code, signal: signal }); }); self.proc.on('error', function (err) { if (self.exitStatus === undefined) self._exited(null); }); self.proc.stdout.setEncoding('utf8'); self.proc.stdout.on('data', function (data) { self.outputLog.write('stdout', data); self.stdoutMatcher.write(data); }); self.proc.stderr.setEncoding('utf8'); self.proc.stderr.on('data', function (data) { self.outputLog.write('stderr', data); self.stderrMatcher.write(data); }); }, // Wait until we get text on stdout that matches 'pattern', which // may be a regular expression or a string. Consume stdout up to // that point. If this pattern does not appear after a timeout (or // the program exits before emitting the pattern), fail. match: markStack(function (pattern, _strict) { var self = this; self._ensureStarted(); var timeout = self.baseTimeout + self.extraTime; timeout *= utils.timeoutScaleFactor; self.extraTime = 0; return self.stdoutMatcher.match(pattern, timeout, _strict); }), // As expect(), but for stderr instead of stdout. matchErr: markStack(function (pattern, _strict) { var self = this; self._ensureStarted(); var timeout = self.baseTimeout + self.extraTime; timeout *= utils.timeoutScaleFactor; self.extraTime = 0; return self.stderrMatcher.match(pattern, timeout, _strict); }), // Like match(), but won't skip ahead looking for a match. It must // follow immediately after the last thing we matched or read. read: markStack(function (pattern) { return this.match(pattern, true); }), // As read(), but for stderr instead of stdout. readErr: markStack(function (pattern) { return this.matchErr(pattern, true); }), // Assert that 'pattern' (again, a regexp or string) has not // occurred on stdout at any point so far in this run. Currently // this works on complete lines, so unlike match() and read(), // 'pattern' cannot span multiple lines, and furthermore if it is // called before the end of the program, it may not see text on a // partially read line. We could lift these restrictions easily, but // there may not be any benefit since the usual way to use this is // to call it after expectExit or expectEnd. forbid: markStack(function (pattern) { this._ensureStarted(); this.outputLog.forbid(pattern, 'stdout'); }), // As forbid(), but for stderr instead of stdout. forbidErr: markStack(function (pattern) { this._ensureStarted(); this.outputLog.forbid(pattern, 'stderr'); }), // Combination of forbid() and forbidErr(). Forbids the pattern on // both stdout and stderr. forbidAll: markStack(function (pattern) { this._ensureStarted(); this.outputLog.forbid(pattern); }), // Expect the program to exit without anything further being // printed on either stdout or stderr. expectEnd: markStack(function () { var self = this; self._ensureStarted(); var timeout = self.baseTimeout + self.extraTime; timeout *= utils.timeoutScaleFactor; self.extraTime = 0; self.expectExit(); self.stdoutMatcher.matchEmpty(); self.stderrMatcher.matchEmpty(); }), // Expect the program to exit with the given (numeric) exit // status. Fail if the process exits with a different code, or if // the process does not exit after a timeout. You can also omit the // argument to simply wait for the program to exit. expectExit: markStack(function (code) { var self = this; self._ensureStarted(); if (self.exitStatus === undefined) { var timeout = self.baseTimeout + self.extraTime; timeout *= utils.timeoutScaleFactor; self.extraTime = 0; var fut = new Future; self.exitFutures.push(fut); var timer = setTimeout(function () { self.exitFutures = _.without(self.exitFutures, fut); fut['throw'](new TestFailure('exit-timeout', { run: self })); }, timeout * 1000); try { fut.wait(); } finally { clearTimeout(timer); } } if (! self.exitStatus) throw new TestFailure('spawn-failure', { run: self }); if (code !== undefined && self.exitStatus.code !== code) { throw new TestFailure('wrong-exit-code', { expected: { code: code }, actual: self.exitStatus, run: self }); } }), // Extend the timeout for the next operation by 'secs' seconds. waitSecs: function (secs) { var self = this; self.extraTime += secs; }, // Send 'string' to the program on its stdin. write: function (string) { var self = this; self._ensureStarted(); self.proc.stdin.write(string); }, // Kill the program and then wait for it to actually exit. stop: markStack(function () { var self = this; if (self.exitStatus === undefined) { self._ensureStarted(); self.client && self.client.stop(); self._killProcess(); self.expectExit(); } }), // Like stop, but doesn't wait for it to exit. _stopWithoutWaiting: function () { var self = this; if (self.exitStatus === undefined && self.proc) { self.client && self.client.stop(); self._killProcess(); } }, // Kills the running process and it's child processes _killProcess: function () { if (!this.proc) throw new Error("Unexpected: `this.proc` undefined when calling _killProcess"); if (process.platform === "win32") { // looks like in Windows `self.proc.kill()` doesn't kill child // processes. utils.execFileSync("taskkill", ["/pid", this.proc.pid, '/f', '/t']); } else { this.proc.kill(); } }, // If the fakeMongo option was set, sent a command to the stub // mongod. Available commands currently are: // // - { stdout: "xyz" } to make fake-mongod write "xyz" to stdout // - { stderr: "xyz" } likewise for stderr // - { exit: 123 } to make fake-mongod exit with code 123 // // Blocks until a connection to fake-mongod can be // established. Throws a TestFailure if it cannot be established. tellMongo: markStack(function (command) { var self = this; if (! self.fakeMongoPort) throw new Error("fakeMongo option on sandbox must be set"); self._ensureStarted(); // If it's the first time we've called tellMongo on this sandbox, // open a connection to fake-mongod. Wait up to 10 seconds for it // to accept the connection, retrying every 100ms. // // XXX we never clean up this connection. Hopefully once // fake-mongod has dropped its end of the connection, and we hold // no reference to our end, it will get gc'd. If not, that's not // great, but it probably doesn't actually create any practical // problems since this is only for testing. if (! self.fakeMongoConnection) { var net = require('net'); var lastStartTime = 0; for (var attempts = 0; ! self.fakeMongoConnection && attempts < 100; attempts ++) { // Throttle attempts to one every 100ms utils.sleepMs((lastStartTime + 100) - (+ new Date)); lastStartTime = +(new Date); // Use an anonymous function so that each iteration of the // loop gets its own values of 'fut' and 'conn'. (function () { var fut = new Future; var conn = net.connect(self.fakeMongoPort, function () { if (fut) fut['return'](true); }); conn.setNoDelay(); conn.on('error', function () { if (fut) fut['return'](false); }); setTimeout(function () { if (fut) fut['return'](false); // 100ms connection timeout }, 100); // This is all arranged so that if a previous attempt // belatedly succeeds, somehow, we ignore it. if (fut.wait()) self.fakeMongoConnection = conn; fut = null; })(); } if (! self.fakeMongoConnection) throw new TestFailure("mongo-not-running", { run: self }); } self.fakeMongoConnection.write(JSON.stringify(command) + "\n"); // If we told it to exit, then we should close our end and connect again if // asked to send more. if (command.exit) { self.fakeMongoConnection.end(); self.fakeMongoConnection = null; } }) }); /////////////////////////////////////////////////////////////////////////////// // Defining tests /////////////////////////////////////////////////////////////////////////////// var Test = function (options) { var self = this; self.name = options.name; self.file = options.file; self.fileHash = options.fileHash; self.tags = options.tags || []; self.f = options.func; self.cleanupHandlers = []; }; _.extend(Test.prototype, { onCleanup: function (cleanupHandler) { this.cleanupHandlers.push(cleanupHandler); }, cleanup: function () { var self = this; _.each(self.cleanupHandlers, function (cleanupHandler) { cleanupHandler(); }); self.cleanupHandlers = []; } }); var allTests = null; var fileBeingLoaded = null; var fileBeingLoadedHash = null; var runningTest = null; var getAllTests = function () { if (allTests) return allTests; allTests = []; // Load all files in the 'tests' directory that end in .js. They // are supposed to then call define() to register their tests. var testdir = files.pathJoin(__dirname, 'tests'); var filenames = files.readdir(testdir); _.each(filenames, function (n) { if (! n.match(/^[^.].*\.js$/)) // ends in '.js', doesn't start with '.' return; try { if (fileBeingLoaded) throw new Error("called recursively?"); fileBeingLoaded = files.pathBasename(n, '.js'); var fullPath = files.pathJoin(testdir, n); var contents = files.readFile(fullPath, 'utf8'); fileBeingLoadedHash = require('crypto').createHash('sha1').update(contents).digest('hex'); require(files.pathJoin(testdir, n)); } finally { fileBeingLoaded = null; fileBeingLoadedHash = null; } }); return allTests; }; var define = function (name, tagsList, f) { if (typeof tagsList === "function") { // tagsList is optional f = tagsList; tagsList = []; } var tags = tagsList.slice(); tags.sort(); allTests.push(new Test({ name: name, tags: tags, file: fileBeingLoaded, fileHash: fileBeingLoadedHash, func: f })); }; /////////////////////////////////////////////////////////////////////////////// // Choosing tests /////////////////////////////////////////////////////////////////////////////// var tagDescriptions = { checkout: 'can only run from checkouts', net: 'require an internet connection', slow: 'take quite a long time; use --slow to include', cordova: 'requires Cordova support in tool (eg not on Windows)', windows: 'runs only on Windows', // these are pseudo-tags, assigned to tests when you specify // --changed, --file, or a pattern argument unchanged: 'unchanged since last pass', 'non-matching': "don't match specified pattern", 'in other files': "" }; // Returns a TestList object representing a filtered list of tests, // according to the options given (which are based closely on the // command-line arguments). Used as the first step of both listTests // and runTests. // // Options: testRegexp, fileRegexp, onlyChanged, offline, includeSlowTests var getFilteredTests = function (options) { options = options || {}; var allTests = getAllTests(); if (allTests.length) { var testState = readTestState(); // Add pseudo-tags 'non-matching', 'unchanged', and 'in other files' // (but only so that we can then skip tests with those tags) allTests = allTests.map(function (test) { var newTags = []; if (options.fileRegexp && ! options.fileRegexp.test(test.file)) { newTags.push('in other files'); } else if (options.testRegexp && ! options.testRegexp.test(test.name)) { newTags.push('non-matching'); } else if (options.onlyChanged && test.fileHash === testState.lastPassedHashes[test.file]) { newTags.push('unchanged'); } if (! newTags.length) { return test; } return _.extend({}, test, { tags: test.tags.concat(newTags) }); }); } // (order of tags is significant to the "skip counts" that are displayed) var tagsToSkip = []; if (options.fileRegexp) { tagsToSkip.push('in other files'); } if (options.testRegexp) { tagsToSkip.push('non-matching'); } if (options.onlyChanged) { tagsToSkip.push('unchanged'); } if (! files.inCheckout()) { tagsToSkip.push('checkout'); } if (options.offline) { tagsToSkip.push('net'); } if (! options.includeSlowTests) { tagsToSkip.push('slow'); } if (process.platform === "win32") { tagsToSkip.push("cordova"); tagsToSkip.push("yet-unsolved-windows-failure"); } else { tagsToSkip.push("windows"); } return new TestList(allTests, tagsToSkip, testState); }; // A TestList is the result of getFilteredTests. It holds the original // list of all tests, the filtered list, and stats on how many tests // were skipped (see generateSkipReport). // // TestList also has code to save the hashes of files where all tests // ran and passed (for the `--changed` option). If a testState is // provided, the notifyFailed and saveTestState can be used to modify // the testState appropriately and write it out. var TestList = function (allTests, tagsToSkip, testState) { tagsToSkip = (tagsToSkip || []); testState = (testState || null); // optional var self = this; self.allTests = allTests; self.skippedTags = tagsToSkip; self.skipCounts = {}; self.testState = testState; _.each(tagsToSkip, function (tag) { self.skipCounts[tag] = 0; }); self.fileInfo = {}; // path -> {hash, hasSkips, hasFailures} self.filteredTests = _.filter(allTests, function (test) { if (! self.fileInfo[test.file]) { self.fileInfo[test.file] = { hash: test.fileHash, hasSkips: false, hasFailures: false }; } var fileInfo = self.fileInfo[test.file]; // We look for tagsToSkip *in order*, and when we decide to // skip a test, we don't keep looking at more tags, and we don't // add the test to any further "skip counts". return !_.any(tagsToSkip, function (tag) { if (_.contains(test.tags, tag)) { self.skipCounts[tag]++; fileInfo.hasSkips = true; return true; } else { return false; } }); }); }; // Mark a test's file as having failures. This prevents // saveTestState from saving its hash as a potentially // "unchanged" file to be skipped in a future run. TestList.prototype.notifyFailed = function (test) { this.fileInfo[test.file].hasFailures = true; }; // If this TestList was constructed with a testState, // modify it and write it out based on which tests // were skipped and which tests had failures. TestList.prototype.saveTestState = function () { var self = this; var testState = self.testState; if (! (testState && self.filteredTests.length)) { return; } _.each(self.fileInfo, function (info, f) { if (info.hasFailures) { delete testState.lastPassedHashes[f]; } else if (! info.hasSkips) { testState.lastPassedHashes[f] = info.hash; } }); writeTestState(testState); }; // Return a string like "Skipped 1 foo test\nSkipped 5 bar tests\n" TestList.prototype.generateSkipReport = function () { var self = this; var result = ''; _.each(self.skippedTags, function (tag) { var count = self.skipCounts[tag]; if (count) { var noun = "test" + (count > 1 ? "s" : ""); // "test" or "tests" // "non-matching tests" or "tests in other files" var nounPhrase = (/ /.test(tag) ? (noun + " " + tag) : (tag + " " + noun)); // " (foo)" or "" var parenthetical = (tagDescriptions[tag] ? " (" + tagDescriptions[tag] + ")" : ''); result += ("Skipped " + count + " " + nounPhrase + parenthetical + '\n'); } }); return result; }; var getTestStateFilePath = function () { return files.pathJoin(files.getHomeDir(), '.meteortest'); }; var readTestState = function () { var testStateFile = getTestStateFilePath(); var testState; if (files.exists(testStateFile)) testState = JSON.parse(files.readFile(testStateFile, 'utf8')); if (! testState || testState.version !== 1) testState = { version: 1, lastPassedHashes: {} }; return testState; }; var writeTestState = function (testState) { var testStateFile = getTestStateFilePath(); files.writeFile(testStateFile, JSON.stringify(testState), 'utf8'); }; // Same options as getFilteredTests. Writes to stdout and stderr. var listTests = function (options) { var testList = getFilteredTests(options); if (! testList.allTests.length) { Console.error("No tests defined.\n"); return; } _.each(_.groupBy(testList.filteredTests, 'file'), function (tests, file) { Console.rawInfo(file + ':\n'); _.each(tests, function (test) { Console.rawInfo(' - ' + test.name + (test.tags.length ? ' [' + test.tags.join(' ') + ']' : '') + '\n'); }); }); Console.error(); Console.error(testList.filteredTests.length + " tests listed."); Console.error(testList.generateSkipReport()); }; /////////////////////////////////////////////////////////////////////////////// // Running tests /////////////////////////////////////////////////////////////////////////////// // options: onlyChanged, offline, includeSlowTests, historyLines, testRegexp, // fileRegexp, // clients: // - browserstack (need s3cmd credentials) var runTests = function (options) { var testList = getFilteredTests(options); if (! testList.allTests.length) { Console.error("No tests defined."); return 0; } var totalRun = 0; var failedTests = []; _.each(testList.filteredTests, function (test) { totalRun++; Console.error(test.file + ": " + test.name + " ... "); var failure = null; try { runningTest = test; var startTime = +(new Date); test.f(options); } catch (e) { failure = e; } finally { runningTest = null; test.cleanup(); } if (failure) { Console.error("... fail!", Console.options({ indent: 2 })); failedTests.push(test); testList.notifyFailed(test); if (failure instanceof TestFailure) { var frames = parseStack.parse(failure); var relpath = files.pathRelative(files.getCurrentToolsDir(), frames[0].file); Console.rawError(" => " + failure.reason + " at " + relpath + ":" + frames[0].line + "\n"); if (failure.reason === 'no-match') { Console.arrowError("Pattern: " + failure.details.pattern, 2); } if (failure.reason === "wrong-exit-code") { var s = function (status) { return status.signal || ('' + status.code) || "???"; }; Console.rawError( " => " + "Expected: " + s(failure.details.expected) + "; actual: " + s(failure.details.actual) + "\n"); } if (failure.reason === 'expected-exception') { } if (failure.reason === 'not-equal') { Console.rawError( " => " + "Expected: " + JSON.stringify(failure.details.expected) + "; actual: " + JSON.stringify(failure.details.actual) + "\n"); } if (failure.details.run) { failure.details.run.outputLog.end(); var lines = failure.details.run.outputLog.get(); if (! lines.length) { Console.arrowError("No output", 2); } else { var historyLines = options.historyLines || 100; Console.arrowError("Last " + historyLines + " lines:", 2); _.each(lines.slice(-historyLines), function (line) { Console.rawError(" " + (line.channel === "stderr" ? "2| " : "1| ") + line.text + (line.bare ? "%" : "") + "\n"); }); } } if (failure.details.messages) { Console.arrowError("Errors while building:", 2); Console.rawError(failure.details.messages.formatMessages() + "\n"); } } else { Console.rawError(" => Test threw exception: " + failure.stack + "\n"); } } else { var durationMs = +(new Date) - startTime; Console.error( "... ok (" + durationMs + " ms)", Console.options({ indent: 2 })); } }); testList.saveTestState(); if (totalRun > 0) Console.error(); Console.error(testList.generateSkipReport()); if (testList.filteredTests.length === 0) { Console.error("No tests run."); return 0; } else if (failedTests.length === 0) { var disclaimers = ''; if (testList.filteredTests.length < testList.allTests.length) disclaimers += " other"; Console.error("All" + disclaimers + " tests passed."); return 0; } else { var failureCount = failedTests.length; Console.error(failureCount + " failure" + (failureCount > 1 ? "s" : "") + ":"); _.each(failedTests, function (test) { Console.rawError(" - " + test.file + ": " + test.name + "\n"); }); return 1; } }; // To create self-tests: // // Create a new .js file in the tests directory. It will be picked // up automatically. // // Start your file with something like: // var selftest = require('../selftest.js'); // var Sandbox = selftest.Sandbox; // // Define tests with: // selftest.define("test-name", ['tag1', 'tag2'], function () { // ... // }); // // The tags are used to group tests. Currently used tags: // - 'checkout': should only be run when we're running from a // checkout as opposed to a released copy. // - 'net': test requires an internet connection. Not going to work // if you're on a plane; will be skipped if we appear to be // offline unless run with 'self-test --force-online'. // - 'slow': test is slow enough that you don't want to run it // except on purpose. Won't run unless you say 'self-test --slow'. // // If you don't want to set any tags, you can omit that parameter // entirely. // // Inside your test function, first create a Sandbox object, then call // the run() method on the sandbox to set up a new run of meteor with // arguments of your choice, and then use functions like match(), // write(), and expectExit() to script that run. _.extend(exports, { runTests: runTests, listTests: listTests, markStack: markStack, define: define, Sandbox: Sandbox, Run: Run, fail: fail, expectEqual: expectEqual, expectThrows: expectThrows, expectTrue: expectTrue, expectFalse: expectFalse, execFileSync: execFileSync, doOrThrow: doOrThrow, testPackageServerUrl: config.getTestPackageServerUrl() });