mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
These tests have long been flaky, and we are changing some of the infrastructure that the test server relies on.
1992 lines
60 KiB
JavaScript
1992 lines
60 KiB
JavaScript
var _ = require('underscore');
|
|
var util = require('util');
|
|
var Future = require('fibers/future');
|
|
var fiberHelpers = require('../utils/fiber-helpers.js');
|
|
var child_process = require('child_process');
|
|
var phantomjs = require('phantomjs');
|
|
var webdriver = require('browserstack-webdriver');
|
|
|
|
var files = require('../fs/files.js');
|
|
var utils = require('../utils/utils.js');
|
|
var parseStack = require('../utils/parse-stack.js');
|
|
var Console = require('../console/console.js').Console;
|
|
var archinfo = require('../utils/archinfo.js');
|
|
var config = require('../meteor-services/config.js');
|
|
var buildmessage = require('../utils/buildmessage.js');
|
|
|
|
var catalog = require('../packaging/catalog/catalog.js');
|
|
var catalogRemote = require('../packaging/catalog/catalog-remote.js');
|
|
var isopackCacheModule = require('../isobuild/isopack-cache.js');
|
|
var isopackets = require('../tool-env/isopackets.js');
|
|
|
|
var tropohouseModule = require('../packaging/tropohouse.js');
|
|
var packageMapModule = require('../packaging/package-map.js');
|
|
var release = require('../packaging/release.js');
|
|
|
|
var projectContextModule = require('../project-context.js');
|
|
var upgraders = require('../upgraders.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 Promise.denodeify(child_process.execFile)(
|
|
binary, args, opts
|
|
).await();
|
|
};
|
|
|
|
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-base',
|
|
'mobile-experience',
|
|
'mongo',
|
|
'blaze-html-templates',
|
|
'session',
|
|
'jquery',
|
|
'tracker',
|
|
"autopublish",
|
|
"insecure",
|
|
"standard-minifier-css",
|
|
"standard-minifier-js",
|
|
"es5-shim"
|
|
];
|
|
|
|
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('../packaging/catalog/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.matchPromise = 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.matchPromise) {
|
|
throw new Error("already have a match pending?");
|
|
}
|
|
self.matchPattern = pattern;
|
|
self.matchStrict = strict;
|
|
var mp = self.matchPromise = fiberHelpers.makeFulfillablePromise();
|
|
self._tryMatch(); // could clear self.matchPromise
|
|
|
|
var timer = null;
|
|
if (timeout) {
|
|
timer = setTimeout(function () {
|
|
var pattern = self.matchPattern;
|
|
self.matchPattern = null;
|
|
self.matchStrict = null;
|
|
self.matchPromise = null;
|
|
mp.reject(new TestFailure('match-timeout', {
|
|
run: self.run,
|
|
pattern: pattern
|
|
}));
|
|
}, timeout * 1000);
|
|
}
|
|
|
|
try {
|
|
return mp.await();
|
|
} 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 mp = self.matchPromise;
|
|
if (! mp) {
|
|
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.matchPromise = null;
|
|
self.matchStrict = null;
|
|
self.matchPattern = null;
|
|
Console.info("Extra junk is: ", self.buf.substr(0, m.index));
|
|
mp.reject(new TestFailure('junk-before', {
|
|
run: self.run,
|
|
pattern: self.matchPattern
|
|
}));
|
|
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.matchPromise = null;
|
|
self.matchStrict = null;
|
|
self.matchPattern = null;
|
|
Console.info("Extra junk is: ", self.buf.substr(0, i));
|
|
mp.reject(new TestFailure('junk-before', {
|
|
run: self.run,
|
|
pattern: self.matchPattern
|
|
}));
|
|
return;
|
|
}
|
|
ret = self.matchPattern;
|
|
self.buf = self.buf.slice(i + self.matchPattern.length);
|
|
}
|
|
}
|
|
|
|
if (ret !== null) {
|
|
self.matchPromise = null;
|
|
self.matchStrict = null;
|
|
self.matchPattern = null;
|
|
mp.resolve(ret);
|
|
return;
|
|
}
|
|
|
|
if (self.ended) {
|
|
var failure = new TestFailure('no-match', { run: self.run,
|
|
pattern: self.matchPattern });
|
|
self.matchPromise = null;
|
|
self.matchStrict = null;
|
|
self.matchPattern = null;
|
|
mp.reject(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, 0o755);
|
|
self.cwd = self.home;
|
|
self.env = {};
|
|
self.fakeMongo = options.fakeMongo;
|
|
|
|
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 (...args) {
|
|
var self = this;
|
|
|
|
return new Run(self.execPath, {
|
|
sandbox: self,
|
|
args: args,
|
|
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, ...args) {
|
|
var self = this;
|
|
args = _.compact(args);
|
|
|
|
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: args,
|
|
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 || {};
|
|
var absoluteTo = files.pathJoin(self.cwd, to);
|
|
files.cp_r(files.pathJoin(
|
|
files.convertToStandardPath(__dirname), '..', 'tests', 'apps', template),
|
|
absoluteTo, { 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);
|
|
}
|
|
|
|
// Make sure the apps don't run any upgraders, unless they intentionally
|
|
// have a partial upgraders file
|
|
var upgradersFile =
|
|
new projectContextModule.FinishedUpgraders({projectDir: absoluteTo});
|
|
if (_.isEmpty(upgradersFile.readUpgraders())) {
|
|
upgradersFile.appendUpgraders(upgraders.allUpgraders());
|
|
}
|
|
|
|
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');
|
|
},
|
|
|
|
// Like writeFile, but appends rather than writes.
|
|
append: function (filename, contents) {
|
|
var self = this;
|
|
files.appendFile(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);
|
|
|
|
// Don't ever try to refresh the stub catalog we made.
|
|
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({
|
|
buildArchitectures: 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,
|
|
tool: configuration.tool || "meteor-tool@" + toolPackageVersion,
|
|
packages: packageVersions
|
|
});
|
|
});
|
|
|
|
var dataFile = config.getPackageStorage({
|
|
root: self.warehouse,
|
|
serverUrl: serverUrl
|
|
});
|
|
self.warehouseOfficialCatalog = new catalogRemote.RemoteCatalog();
|
|
self.warehouseOfficialCatalog.initialize({
|
|
packageStorage: dataFile
|
|
});
|
|
self.warehouseOfficialCatalog.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",
|
|
"tool-testing", "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, 0o755);
|
|
|
|
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(
|
|
'/usr/bin/env',
|
|
['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());
|
|
// default env variables
|
|
self.env = _.extend({ SELFTEST: "t", METEOR_NO_WORDWRAP: "t" }, 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.exitPromiseResolvers = [];
|
|
var opts = options.args || [];
|
|
self.args.apply(self, opts || []);
|
|
|
|
self.fakeMongoPort = null;
|
|
self.fakeMongoConnection = null;
|
|
if (options.fakeMongo) {
|
|
self.fakeMongoPort = require('../utils/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 (...args) {
|
|
var self = this;
|
|
|
|
if (self.proc) {
|
|
throw new Error("already started?");
|
|
}
|
|
|
|
_.each(args, 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 exitPromiseResolvers = self.exitPromiseResolvers;
|
|
self.exitPromiseResolvers = null;
|
|
_.each(exitPromiseResolvers, function (resolve) {
|
|
resolve();
|
|
});
|
|
|
|
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.
|
|
//
|
|
// Example:
|
|
// run = s.run("--help");
|
|
// run.expectExit(1); // <<-- improtant to actually run the command
|
|
// run.forbidErr("unwanted string"); // <<-- important to run **after** the
|
|
// // command ran the process.
|
|
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 timer;
|
|
var promise = new Promise(function (resolve, reject) {
|
|
self.exitPromiseResolvers.push(resolve);
|
|
timer = setTimeout(function () {
|
|
self.exitPromiseResolvers = _.without(self.exitPromiseResolvers, resolve);
|
|
reject(new TestFailure('exit-timeout', { run: self }));
|
|
}, timeout * 1000);
|
|
});
|
|
|
|
try {
|
|
promise.await();
|
|
} 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);
|
|
|
|
new Promise(function (resolve) {
|
|
// This is all arranged so that if a previous attempt
|
|
// belatedly succeeds, somehow, we ignore it.
|
|
var conn = net.connect(self.fakeMongoPort, function () {
|
|
if (resolve) {
|
|
self.fakeMongoConnection = conn;
|
|
resolve(true);
|
|
resolve = null;
|
|
}
|
|
});
|
|
conn.setNoDelay();
|
|
function fail() {
|
|
if (resolve) {
|
|
resolve(false);
|
|
resolve = null;
|
|
}
|
|
}
|
|
conn.on('error', fail);
|
|
setTimeout(fail, 100); // 100ms connection timeout
|
|
}).await();
|
|
}
|
|
|
|
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',
|
|
galaxy: 'galaxy-specific test testing galaxy integration',
|
|
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, galaxyOnly
|
|
var getFilteredTests = function (options) {
|
|
options = options || {};
|
|
var allTests = getAllTests();
|
|
|
|
if (allTests.length) {
|
|
var testState = readTestState();
|
|
|
|
// Add pseudo-tags 'non-matching', 'unchanged', 'non-galaxy' 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');
|
|
} else if (options.excludeRegexp &&
|
|
options.excludeRegexp.test(test.name)) {
|
|
newTags.push('excluded');
|
|
}
|
|
|
|
// We make sure to not run galaxy tests unless the user explicitly asks us
|
|
// to. Someday, this might not be the case.
|
|
if ( _.indexOf(test.tags, "galaxy") === -1) {
|
|
newTags.push('non-galaxy');
|
|
}
|
|
|
|
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.excludeRegexp) {
|
|
tagsToSkip.push('excluded');
|
|
}
|
|
if (options.onlyChanged) {
|
|
tagsToSkip.push('unchanged');
|
|
}
|
|
if (! files.inCheckout()) {
|
|
tagsToSkip.push('checkout');
|
|
}
|
|
if (options.galaxyOnly) {
|
|
// We consider `galaxy` to imply `slow` and `net` since almost all galaxy
|
|
// tests involve deploying an app to a (probably) remote server.
|
|
tagsToSkip.push('non-galaxy');
|
|
} else {
|
|
tagsToSkip.push('galaxy');
|
|
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).outsideFiber;
|
|
var relpath = files.pathRelative(files.getCurrentToolsDir(),
|
|
frames[0].file);
|
|
Console.rawError(" => " + failure.reason + " at " +
|
|
relpath + ":" + frames[0].line + "\n");
|
|
if (failure.reason === 'no-match' || failure.reason === 'junk-before' ||
|
|
failure.reason === 'match-timeout') {
|
|
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
|
|
});
|