diff --git a/tools/auth.js b/tools/auth.js index 7f43d901fa..2fae2bb5fe 100644 --- a/tools/auth.js +++ b/tools/auth.js @@ -1009,8 +1009,9 @@ session.registrationUrl + "\n\n"); exports.tryRevokeOldTokens = tryRevokeOldTokens; -exports.getSessionId = function (domain) { - return getSession(readSessionData(), domain).session; +exports.getSessionId = function (domain, sessionData) { + sessionData = sessionData || readSessionData(); + return getSession(sessionData, domain).session; }; exports.setSessionId = function (domain, sessionId) { diff --git a/tools/project.js b/tools/project.js index bbbfa2d3aa..3eccdb6918 100644 --- a/tools/project.js +++ b/tools/project.js @@ -330,7 +330,9 @@ _.extend(Project.prototype, { return path.join(self.rootDir, '.meteor', 'packages'); }, - // Give the contents of the project's .meteor/versions file to the caller. + // Give the contents of the project's .meteor/versions file to the + // caller, possibly after recalculating dependencies and rewriting the + // versions file. // // Returns an object mapping package name to its string version. getVersions : function () { @@ -676,3 +678,4 @@ _.extend(Project.prototype, { // cumbersome, that is our general design pattern for singletons (ex: // packageCache.packageCache, etc) project.project = new Project(); +project.Project = Project; diff --git a/tools/run-app.js b/tools/run-app.js index 0521702172..7c37da1642 100644 --- a/tools/run-app.js +++ b/tools/run-app.js @@ -275,7 +275,7 @@ _.extend(AppProcess.prototype, { // // - Other options: appDirForVersionCheck (defaults to appDir), port, // mongoUrl, oplogUrl, buildOptions, rootUrl, settingsFile, program, -// proxy, dontRecordPackageUsage +// proxy, recordPackageUsage // // To use, construct an instance of AppRunner, and then call start() to start it // running. To stop it, either return false from onRunEnd, or call stop(). (But diff --git a/tools/selftest.js b/tools/selftest.js index 4c18c89a72..8bfbf622ca 100644 --- a/tools/selftest.js +++ b/tools/selftest.js @@ -9,6 +9,9 @@ var catalog = require('./catalog.js'); var archinfo = require('./archinfo.js'); var packageLoader = require('./package-loader.js'); var Future = require('fibers/future'); +var uniload = require('./uniload.js'); + +var Package = uniload.load({ packages: ["ejson"] }); // Exception representing a test failure var TestFailure = function (reason, details) { @@ -34,8 +37,7 @@ var fail = markStack(function (reason) { // with 'actual' being the value that the test got and 'expected' // being the expected value var expectEqual = markStack(function (actual, expected) { - // XXX Super janky. Should use EJSON.equals for a deep equality test. - if (JSON.stringify(actual) !== JSON.stringify(expected)) { + if (! Package.ejson.EJSON.equals(actual, expected)) { throw new TestFailure("not-equal", { expected: expected, actual: actual @@ -803,6 +805,7 @@ _.extend(Run.prototype, { stop: markStack(function () { var self = this; if (self.exitStatus === undefined) { + self._ensureStarted(); self.proc.kill(); self.expectExit(); } diff --git a/tools/stats.js b/tools/stats.js index db925a60b7..45712e3e4e 100644 --- a/tools/stats.js +++ b/tools/stats.js @@ -1,5 +1,6 @@ var Fiber = require("fibers"); var _ = require("underscore"); +var os = require("os"); var config = require("./config.js"); var files = require("./files.js"); @@ -7,6 +8,7 @@ var uniload = require("./uniload.js"); var project = require("./project.js"); var auth = require("./auth.js"); var ServiceConnection = require("./service-connection.js"); +var release = require("./release.js"); // The name of the package that you add to your app to opt out of // sending stats. @@ -16,16 +18,37 @@ var optOutPackageName = "package-stats-opt-out"; // indirectly. Formatted as a list of objects with 'name', 'version' // and 'direct', which is how the `recordAppPackages` method on the // stats server expects to get this list. -var packageList = function () { - var directDeps = project.project.getConstraints(); +// +// In tests, we want to use the same logic to calculate the list of +// packages for an app created in a sandbox, but we don't want to run +// the constraint solver, try to load local packages from the catalog, +// etc. In particular, we don't want to have to repoint project.project +// at whatever random app we just created in a sandbox and re-initialize +// the catalog with its local packages (and then have to undo all that +// after the test is over). So tests can pass a project.Project as an +// argument, and we'll calculate the list of packages just by looking at +// .meteor/packages and .meteor/versions, not by doing anything fancy +// like running the constraint solver. +// NOTE: This means that if you pass `_currentProjectForTest`, we assume +// that it is pointing to a root directory with an existing +// .meteor/versions file. +var packageList = function (_currentProjectForTest) { + var directDeps = (_currentProjectForTest || project.project).getConstraints(); + + var versions; + if (_currentProjectForTest) { + versions = _currentProjectForTest.dependencies; + } else { + versions = project.project.getVersions(); + } return _.map( - project.project.getVersions(), + versions, function (version, name) { return { name: name, version: version, - direct: _.contains(directDeps, name) + direct: _.has(directDeps, name) }; } ); @@ -43,6 +66,23 @@ var recordPackages = function () { // to the package stats server. If we can't connect, for example, we // don't care; we'll just miss out on recording these packages. Fiber(function () { + + var userAgentInfo = { + hostname: os.hostname(), + osPlatform: os.platform(), + osType: os.type(), + osRelease: os.release(), + osArch: os.arch(), + sessionId: auth.getSessionId(config.getAccountsDomain()) + }; + + if (! release.current.isCheckout()) { + userAgentInfo.meteorReleaseTrack = release.current.getReleaseTrack(); + userAgentInfo.meteorReleaseVersion = release.current.getReleaseVersion(); + userAgentInfo.meteorToolsPackageWithVersion = + release.current.getToolsPackageAtVersion(); + } + try { var conn = connectToPackagesStatsServer(); @@ -63,7 +103,8 @@ var recordPackages = function () { conn.call("recordAppPackages", project.project.getAppIdentifier(), - packages); + packages, + userAgentInfo); } catch (err) { logErrorIfRunningMeteorRelease(err); // Do nothing. A failure to record package stats shouldn't be @@ -83,10 +124,10 @@ var logErrorIfRunningMeteorRelease = function (err) { // Used in a test (and can only be used against the testing packages // server) to fetch one package stats entry for a given application. -var getPackagesForAppIdInTest = function () { +var getPackagesForAppIdInTest = function (currentProject) { return connectToPackagesStatsServer().call( "getPackagesForAppId", - project.project.getAppIdentifier()); + currentProject.getAppIdentifier()); }; var connectToPackagesStatsServer = function () { diff --git a/tools/tests/apps/package-stats-tests/.meteor/.gitignore b/tools/tests/apps/package-stats-tests/.meteor/.gitignore new file mode 100644 index 0000000000..4083037423 --- /dev/null +++ b/tools/tests/apps/package-stats-tests/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/tools/tests/apps/package-stats-tests/.meteor/packages b/tools/tests/apps/package-stats-tests/.meteor/packages new file mode 100644 index 0000000000..2b350e8d94 --- /dev/null +++ b/tools/tests/apps/package-stats-tests/.meteor/packages @@ -0,0 +1,5 @@ +# Meteor packages used by this project, one per line. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. +local-package \ No newline at end of file diff --git a/tools/tests/apps/package-stats-tests/.meteor/release b/tools/tests/apps/package-stats-tests/.meteor/release new file mode 100644 index 0000000000..621e94f0ec --- /dev/null +++ b/tools/tests/apps/package-stats-tests/.meteor/release @@ -0,0 +1 @@ +none diff --git a/tools/tests/apps/package-stats-tests/package-stats-tests.js b/tools/tests/apps/package-stats-tests/package-stats-tests.js new file mode 100644 index 0000000000..c9fcac2859 --- /dev/null +++ b/tools/tests/apps/package-stats-tests/package-stats-tests.js @@ -0,0 +1,10 @@ +// This app functions with no packages loaded, so it's good for testing releases +// with no packages. All it does is print "RUNNING" and run forever. +main = function () { + // Tell the runner we're up. + console.log("LISTENING"); + // Ensure Node doesn't kill us. + process.stdin.resume(); + // Ensure boot.js doesn't kill us. + return 'DAEMON'; +}; diff --git a/tools/tests/apps/package-stats-tests/packages/local-package/blah.js b/tools/tests/apps/package-stats-tests/packages/local-package/blah.js new file mode 100644 index 0000000000..f2eb4b3e6f --- /dev/null +++ b/tools/tests/apps/package-stats-tests/packages/local-package/blah.js @@ -0,0 +1 @@ +console.log("BLAH"); diff --git a/tools/tests/apps/package-stats-tests/packages/local-package/package.js b/tools/tests/apps/package-stats-tests/packages/local-package/package.js new file mode 100644 index 0000000000..83a22bb181 --- /dev/null +++ b/tools/tests/apps/package-stats-tests/packages/local-package/package.js @@ -0,0 +1,8 @@ +Package.describe({ + summary: "a package", + version: "1.0.0" +}); + +Package.on_use(function (api) { + api.add_files("blah.js"); +}); diff --git a/tools/tests/apps/package-stats-tests/packages/package-stats-opt-out/package.js b/tools/tests/apps/package-stats-tests/packages/package-stats-opt-out/package.js new file mode 100644 index 0000000000..3dfc770eb1 --- /dev/null +++ b/tools/tests/apps/package-stats-tests/packages/package-stats-opt-out/package.js @@ -0,0 +1,9 @@ +// This is a replica of the core package-stats-opt-out package. It +// exists to make it so that we can add package-stats-opt-out to this +// app without needing to be using a release that has core packages in +// it (such as using a sandbox created with a `warehouse` argument). + +Package.describe({ + summary: "a replica of a core package", + version: "1.0.0" +}); diff --git a/tools/tests/apps/package-stats-tests/programs/empty-control/attributes.json b/tools/tests/apps/package-stats-tests/programs/empty-control/attributes.json new file mode 100644 index 0000000000..11108c5002 --- /dev/null +++ b/tools/tests/apps/package-stats-tests/programs/empty-control/attributes.json @@ -0,0 +1 @@ +{"isControlProgram": true} diff --git a/tools/tests/apps/package-stats-tests/programs/empty-control/package.js b/tools/tests/apps/package-stats-tests/programs/empty-control/package.js new file mode 100644 index 0000000000..ceb498734a --- /dev/null +++ b/tools/tests/apps/package-stats-tests/programs/empty-control/package.js @@ -0,0 +1,4 @@ +// Empty program. Means we don't need a ctl package. +Package.describe({ + version: "1.0.0" +}); diff --git a/tools/tests/report-stats.js b/tools/tests/report-stats.js index fbb9667c6d..aa6b5bcf71 100644 --- a/tools/tests/report-stats.js +++ b/tools/tests/report-stats.js @@ -1,5 +1,9 @@ var _ = require('underscore'); +var os = require("os"); +var auth = require("../auth.js"); +var config = require("../config.js"); +var release = require("../release.js"); var selftest = require('../selftest.js'); var testUtils = require('../test-utils.js'); var stats = require('../stats.js'); @@ -9,81 +13,192 @@ var project = require('../project.js'); var testStatsServer = "https://test-package-stats.meteor.com"; process.env.METEOR_PACKAGE_STATS_SERVER_URL = testStatsServer; +var clientAddress; + +var checkMeta = function (appPackages, sessionId, useFakeRelease) { + if (! clientAddress) { + clientAddress = getClientAddress(); + } + + var expectedUserAgentInfo = { + hostname: os.hostname(), + osPlatform: os.platform(), + osType: os.type(), + osRelease: os.release(), + osArch: os.arch(), + clientAddress: clientAddress + }; + + if (sessionId) { + expectedUserAgentInfo.sessionId = sessionId; + } + + if (useFakeRelease) { + expectedUserAgentInfo.meteorReleaseTrack = + "METEOR-CORE"; + expectedUserAgentInfo.meteorReleaseVersion = + "v1"; + + // Check the tools package version against a regexp and then + // delete it from `appPackages.meta` so that we can check the + // rest of `appPackages.meta` with `expectEqual`. + var toolsPackageWithVersion = + appPackages.meta.meteorToolsPackageWithVersion; + if (! toolsPackageWithVersion.match( + /meteor-tool@\d.\d.\d(\+[a-z0-9]+)?/)) { + selftest.fail("Tools package with version is " + + "not correct: " + toolsPackageWithVersion); + } + delete appPackages.meta.meteorToolsPackageWithVersion; + } + + selftest.expectEqual(appPackages.meta, expectedUserAgentInfo); + +}; + // NOTE: This test will fail if your machine's time is skewed by more // than 30 minutes. This is because the `fetchAppPackageUsage` method // works by passing an hour time range. selftest.define("report-stats", ["slow"], function () { - var s = new Sandbox; + _.each( + // If we are currently running from a checkout, then we run this + // test twice (once in which the sandbox uses the current checkout, + // and another in which the sandbox uses a simulated release). If we + // are currently running from a release, then we can only have the + // sandbox use a simulated release that is the same as our current + // release (we can't simulate a checkout). + release.current.isCheckout() ? [true, false] : [false], + function (useFakeRelease) { + var sandboxOpts; + if (useFakeRelease) { + sandboxOpts = { + warehouse: { + v1: { recommended: true } + } + }; + } + var s = new Sandbox(sandboxOpts); + var run; - var run = s.run("create", "foo"); - run.expectExit(0); - s.cd("foo"); + if (useFakeRelease) { + // This makes packages not depend on meteor (specifically, makes + // our empty control program not depend on meteor). We don't + // want to depend on meteor when running from a fake release, + // because fake releases don't have any packages. + s.set("NO_METEOR_PACKAGE", "t"); + } - project.project.setRootDir(s.cwd); + s.createApp("foo", "package-stats-tests"); + s.cd("foo"); + if (useFakeRelease) { + s.write('.meteor/release', 'METEOR-CORE@v1'); + } - // verify that identifier file exists for new apps - var identifier = s.read(".meteor/identifier"); - selftest.expectEqual(!! identifier, true); - selftest.expectEqual(identifier.length > 0, true); + var sandboxProject = new project.Project(); + sandboxProject.setRootDir(s.cwd); - // verify that identifier file when running 'meteor bundle' on apps - // with no identifier file (eg pre-0.9.0 apps) - bundleWithFreshIdentifier(s); - identifier = s.read(".meteor/identifier"); - selftest.expectEqual(!! identifier, true); - selftest.expectEqual(identifier.length > 0, true); + var sessionId; - // we just ran 'meteor bundle' so let's test that we actually sent - // package usage stats - var usage = fetchPackageUsageForApp(identifier); - selftest.expectEqual(_.sortBy(usage.packages, "name"), - _.sortBy(stats.packageList(), "name")); + // verify that identifier file exists for new apps + var identifier = s.read(".meteor/identifier"); + selftest.expectEqual(!! identifier, true); + selftest.expectEqual(identifier.length > 0, true); - // verify that the stats server recorded that with no userId - var appPackages = stats.getPackagesForAppIdInTest(); - selftest.expectEqual(appPackages.appId, identifier); - selftest.expectEqual(appPackages.userId, null); - selftest.expectEqual(_.sortBy(appPackages.packages, "name"), - _.sortBy(stats.packageList(), "name")); + // verify that identifier file when running 'meteor bundle' on apps + // with no identifier file (eg pre-0.9.0 apps) + bundleWithFreshIdentifier(s, sandboxProject); - // now bundle again while logged in. verify that the stats server - // recorded that with the right userId - testUtils.login(s, "test", "testtest"); - bundleWithFreshIdentifier(s); - appPackages = stats.getPackagesForAppIdInTest(); - selftest.expectEqual(appPackages.userId, testUtils.getUserId(s)); + identifier = s.read(".meteor/identifier"); + selftest.expectEqual(!! identifier, true); + selftest.expectEqual(identifier.length > 0, true); - // Add the opt-out package, verify that no stats are recorded for the - // app. - run = s.run("add", "package-stats-opt-out"); - run.waitSecs(15); - run.expectExit(0); - bundleWithFreshIdentifier(s); - appPackages = stats.getPackagesForAppIdInTest(); - selftest.expectEqual(appPackages, undefined); + // we just ran 'meteor bundle' so let's test that we actually sent + // package usage stats + var usage = fetchPackageUsageForApp(identifier); + selftest.expectEqual(_.sortBy(usage.packages, "name"), + _.sortBy(stats.packageList(sandboxProject), "name")); - // Remove the opt-out package, verify that stats get sent again. - run = s.run("remove", "package-stats-opt-out"); - run.waitSecs(15); - run.expectExit(0); - bundle(s); - appPackages = stats.getPackagesForAppIdInTest(); - selftest.expectEqual(appPackages.userId, testUtils.getUserId(s)); - selftest.expectEqual(_.sortBy(appPackages.packages, "name"), - _.sortBy(stats.packageList(), "name")); + // Check that the direct dependency was recorded as such. + _.each(usage.packages, function (package) { + if (package.name === "local-package" && + ! package.direct) { + selftest.fail("local-package is not marked as a direct dependency"); + } + }); + + // verify that the stats server recorded that with no userId + var appPackages = stats.getPackagesForAppIdInTest(sandboxProject); + selftest.expectEqual(appPackages.appId, identifier); + selftest.expectEqual(appPackages.userId, null); + selftest.expectEqual(_.sortBy(appPackages.packages, "name"), + _.sortBy(stats.packageList(sandboxProject), "name")); + checkMeta(appPackages, sessionId, useFakeRelease); + + // now bundle again while logged in. verify that the stats server + // recorded that with the right userId and meta information + testUtils.login(s, "test", "testtest"); + sessionId = auth.getSessionId(config.getAccountsDomain(), + JSON.parse(s.readSessionFile())); + + bundleWithFreshIdentifier(s, sandboxProject); + appPackages = stats.getPackagesForAppIdInTest(sandboxProject); + selftest.expectEqual(appPackages.userId, testUtils.getUserId(s)); + checkMeta(appPackages, sessionId, useFakeRelease); + + // Log out, and then test that our session id still gets recorded. + testUtils.logout(s); + run = s.run("run"); + run.waitSecs(5); + run.match("BLAH"); + appPackages = stats.getPackagesForAppIdInTest(sandboxProject); + selftest.expectEqual(appPackages.userId, null); + checkMeta(appPackages, sessionId, useFakeRelease); + run.stop(); + + testUtils.login(s, "test", "testtest"); + + // Add the opt-out package, verify that no stats are recorded for the + // app. + // + // XXX The app has a local package-stats-opt-out package in it, so + // that we can add the opt-out package without needing to be using + // a release that knows about the opt-out package. (Our sandbox + // release only has the tool package and no others.) In the near + // future we should just have a way to simulate a release in a + // sandbox that knows about all the packages in the meteor release + // or checkout that is running 'meteor self-test'. That will + // simplify this test a lot. + run = s.run("add", "package-stats-opt-out"); + run.waitSecs(15); + run.expectExit(0); + bundleWithFreshIdentifier(s, sandboxProject); + appPackages = stats.getPackagesForAppIdInTest(sandboxProject); + selftest.expectEqual(appPackages, undefined); + + // Remove the opt-out package, verify that stats get sent again. + run = s.run("remove", "package-stats-opt-out"); + run.waitSecs(15); + run.expectExit(0); + bundle(s, sandboxProject); + appPackages = stats.getPackagesForAppIdInTest(sandboxProject); + selftest.expectEqual(appPackages.userId, testUtils.getUserId(s)); + selftest.expectEqual(_.sortBy(appPackages.packages, "name"), + _.sortBy(stats.packageList(sandboxProject), "name")); + } + ); }); // Bundle the app in the current working directory after deleting its // identifier file (meaning a new one will be created). // @param s {Sandbox} -var bundleWithFreshIdentifier = function (s) { +var bundleWithFreshIdentifier = function (s, sandboxProject) { s.unlink(".meteor/identifier"); - bundle(s); + bundle(s, sandboxProject); }; // Bundle the app in the current working directory. // @param s {Sandbox} -var bundle = function (s) { +var bundle = function (s, sandboxProject) { var run = s.run("bundle", "foo.tar.gz"); run.waitSecs(30); run.expectExit(0); @@ -91,7 +206,7 @@ var bundle = function (s) { // XXX not sure why this is necessary (i.e. why project can't detect // that .meteor/identifier or .meteor/packages has changed and figure // out that it needs to reload itself) - project.project.reload(); + sandboxProject.reload(); }; // Contact the package stats server and look for a given app @@ -125,3 +240,8 @@ var fetchPackageUsageForApp = function (identifier) { return found; }; + +var getClientAddress = function () { + var stats = testUtils.ddpConnect(testStatsServer); + return stats.call("getClientAddress"); +};