// 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 import * as files from '../fs/files'; import PhantomClient from './clients/phantom/index.js'; import PuppeteerClient from './clients/puppeteer/index.js'; import BrowserStackClient from './clients/browserstack/index.js'; import Builder from '../isobuild/builder.js'; import Run from './run.js'; import { Console } from '../console/console.js'; import { getPackagesDirectoryName, getPackageStorage, } from '../meteor-services/config.js'; import { host as archInfoHost } from '../utils/archinfo'; import { current as releaseCurrent } from '../packaging/release.js'; import { FinishedUpgraders } from '../project-context.js'; import { allUpgraders } from '../upgraders.js'; import { DEFAULT_TRACK } from '../packaging/catalog/catalog.js'; import { RemoteCatalog } from '../packaging/catalog/catalog-remote.js'; import { IsopackCache } from '../isobuild/isopack-cache.js'; import { randomToken } from '../utils/utils.js'; import { Tropohouse } from '../packaging/tropohouse.js'; import { PackageMap } from '../packaging/package-map.js'; import { capture, enterJob } from '../utils/buildmessage.js'; const hasOwn = Object.prototype.hasOwnProperty; export default class Sandbox { constructor(options = {}) { this.options = options; this.root = files.mkdtemp(); this.warehouse = null; this.home = files.pathJoin(this.root, 'home'); files.mkdir(this.home, 0o755); this.cwd = this.home; this.env = {}; this.fakeMongo = this.options.fakeMongo; if (hasOwn.call(this.options, 'warehouse')) { if (!files.inCheckout()) { throw Error( "Fake warehouses are only possible when running from a checkout"); } this.warehouse = files.pathJoin(this.root, 'tropohouse'); this._makeWarehouse(this.options.warehouse); } const meteorScript = process.platform === "win32" ? "meteor.bat" : "meteor"; // Figure out the 'meteor' to run if (this.warehouse) { this.execPath = files.pathJoin(this.warehouse, meteorScript); } else { this.execPath = files.pathJoin(files.getCurrentToolsDir(), meteorScript); } } // Create a new test run of the tool in this sandbox. run(...args) { return new Run(this.execPath, { sandbox: this, args, cwd: this.cwd, env: this._makeEnv(), fakeMongo: this.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(f, options) { const { testName, testFile, args: argsParam } = options || {}; const args = (argsParam || []).filter(arg => arg); // Lazy-populate the clients, only when this method is called. if (typeof this.clients === "undefined") { this.clients = []; const clientOptions = this.options.clients || {}; const appConfig = { host: 'localhost', port: clientOptions.port || 3000, }; if (clientOptions.phantom) { PhantomClient.pushClients(this.clients, appConfig); } if (clientOptions.puppeteer) { PuppeteerClient.pushClients(this.clients, appConfig); } if (clientOptions.browserstack && BrowserStackClient.prerequisitesMet()) { BrowserStackClient.pushClients(this.clients, appConfig); } } const testNameAndFile = `${testFile ? `${testFile}: ` : ''}${testName ? `"${testName}" ` : ''}`; console.log(`Running test ${testNameAndFile}with ${this.clients.length} client(s)...`); Object.keys(this.clients).forEach((clientKey, index, array) => { const client = this.clients[clientKey]; console.log( `(${index+1}/${array.length}) Testing ${testNameAndFile}with ${client.name}...`); const run = new Run(this.execPath, { sandbox: this, args, cwd: this.cwd, env: this._makeEnv(), fakeMongo: this.fakeMongo, 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(to, template, options) { options = options || {}; const absoluteTo = files.pathJoin(this.cwd, to); const absoluteFrom = files.pathJoin( files.convertToStandardPath(__dirname), '..', 'tests', 'apps', template ); files.cp_r(absoluteFrom, absoluteTo, { ignore: [/^local$/], preserveSymlinks: true, }); // If the test isn't explicitly managing a mock warehouse, ensure that apps // run with our release by default. if (options.release) { this.write(files.pathJoin(to, '.meteor/release'), options.release); } else if (!this.warehouse && releaseCurrent.isProperRelease()) { this.write(files.pathJoin(to, '.meteor/release'), releaseCurrent.name); } // Make sure the apps don't run any upgraders, unless they intentionally // have a partial upgraders file const upgradersFile = new FinishedUpgraders({ projectDir: absoluteTo }); if (upgradersFile.readUpgraders().length === 0) { upgradersFile.appendUpgraders(allUpgraders()); } require("../cli/default-npm-deps.js").install(absoluteTo); 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.) this.cd(to, () => { const run = this.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(120); 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(packageDir, packageName, template) { const packagePath = files.pathJoin(this.cwd, packageDir); const templatePackagePath = files.pathJoin( files.convertToStandardPath(__dirname), '..', 'tests', 'packages', template); files.cp_r(templatePackagePath, packagePath, { preserveSymlinks: true, }); files.readdir(packagePath).forEach((file) => { if (file.match(/^package.*\.js$/)) { const 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(relativePath, callback) { const previous = this.cwd; this.cwd = files.pathResolve(this.cwd, relativePath); if (callback) { callback(); this.cwd = previous; } } // Set an environment variable for subsequent runs. set(name, value) { this.env[name] = value; } // Undo set(). unset(name) { delete this.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(filename, contents) { files.writeFile(files.pathJoin(this.cwd, filename), contents, 'utf8'); } // Like writeFile, but appends rather than writes. append(filename, contents) { files.appendFile(files.pathJoin(this.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(filename) { const file = files.pathJoin(this.cwd, filename); if (!files.exists(file)) { return null; } else { return files.readFile(files.pathJoin(this.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(from, to) { const contents = this.read(from); if (!contents) { throw new Error("File " + from + " does not exist."); }; this.write(to, contents); } // Delete a file in the sandbox. 'filename' is as in write(). unlink(filename) { files.unlink(files.pathJoin(this.cwd, filename)); } // Make a directory in the sandbox. 'filename' is as in write(). mkdir(dirname) { const dirPath = files.pathJoin(this.cwd, dirname); if (! files.exists(dirPath)) { files.mkdir(dirPath); } } // Rename something in the sandbox. 'oldName' and 'newName' are as in write(). rename(oldName, newName) { files.rename(files.pathJoin(this.cwd, oldName), files.pathJoin(this.cwd, newName)); } // Return the current contents of .meteorsession in the sandbox. readSessionFile() { return files.readFile(files.pathJoin(this.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(contents) { return files.writeFile(files.pathJoin(this.root, '.meteorsession'), contents, 'utf8'); } _makeEnv() { const env = Object.assign(Object.create(null), this.env); env.METEOR_SESSION_FILE = files.convertToOSPath( files.pathJoin(this.root, '.meteorsession')); if (this.warehouse) { // Tell it where the warehouse lives. env.METEOR_WAREHOUSE_DIR = files.convertToOSPath(this.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 (!this.warehouse && releaseCurrent.isProperRelease()) { env.METEOR_TEST_LATEST_RELEASE = releaseCurrent.name; } // Allow user to set TOOL_NODE_FLAGS for self-test app. if (process.env.TOOL_NODE_FLAGS && ! process.env.SELF_TEST_TOOL_NODE_FLAGS) console.log('Consider setting SELF_TEST_TOOL_NODE_FLAGS to configure ' + 'self-test test application spawns'); env.TOOL_NODE_FLAGS = process.env.SELF_TEST_TOOL_NODE_FLAGS || ''; return env; } // Writes a stub warehouse (really a tropohouse) to the directory // this.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(releases) { // Ensure we have a tropohouse to copy stuff out of. setUpBuiltPackageTropohouse(); const serverUrl = this.env.METEOR_PACKAGE_SERVER_URL; const packagesDirectoryName = getPackagesDirectoryName(serverUrl); const builder = new Builder({outputPath: this.warehouse}); builder.copyDirectory({ from: files.pathJoin(builtPackageTropohouseDir, 'packages'), to: packagesDirectoryName, symlink: true }); builder.complete(); const stubCatalog = { syncToken: {}, formatVersion: "1.0", collections: { packages: [], versions: [], builds: [], releaseTracks: [], releaseVersions: [] } }; const packageVersions = {}; let toolPackageVersion = null; tropohouseIsopackCache.eachBuiltIsopack((packageName, isopack) => { const packageRec = tropohouseLocalCatalog.getPackage(packageName); if (! packageRec) { throw Error("no package record for " + packageName); } stubCatalog.collections.packages.push(packageRec); const 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: 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: DEFAULT_TRACK, _id: randomToken() }); // Now create each requested release. Object.keys(releases).forEach((releaseName) => { const configuration = releases[releaseName]; // Release info stubCatalog.collections.releaseVersions.push({ track: 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 }); }); const dataFile = getPackageStorage({ root: this.warehouse, serverUrl: serverUrl }); this.warehouseOfficialCatalog = new RemoteCatalog(); this.warehouseOfficialCatalog.initialize({ packageStorage: dataFile }); this.warehouseOfficialCatalog.insertData(stubCatalog); // And a cherry on top // XXX this is hacky files.linkToMeteorScript( files.pathJoin(this.warehouse, packagesDirectoryName, "meteor-tool", toolPackageVersion, 'mt-' + archInfoHost(), 'meteor'), files.pathJoin(this.warehouse, 'meteor')); } } function doOrThrow(f) { let ret; const messages = capture(function () { ret = f(); }); if (messages.hasMessages()) { throw Error(messages.formatMessages()); } return ret; } function setUpBuiltPackageTropohouse() { if (builtPackageTropohouseDir) { return; } builtPackageTropohouseDir = files.mkdtemp('built-package-tropohouse'); if (getPackagesDirectoryName() !== 'packages') { throw Error("running self-test with METEOR_PACKAGE_SERVER_URL set?"); } const tropohouse = new Tropohouse(builtPackageTropohouseDir); tropohouseLocalCatalog = newSelfTestCatalog(); const versions = {}; tropohouseLocalCatalog.getAllNonTestPackageNames().forEach((packageName) => { versions[packageName] = tropohouseLocalCatalog.getLatestVersion(packageName).version; }); const packageMap = new 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 IsopackCache({ packageMap: packageMap, includeCordovaUnibuild: true }); doOrThrow(function () { enterJob("building self-test packages", () => { // 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((name, isopack) => { tropohouse._saveIsopack(isopack, name); }); } // 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. let builtPackageTropohouseDir = null; let tropohouseLocalCatalog = null; let 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.) const 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', 'blaze-hot', 'hot-module-replacement', "jquery", // necessary when using Blaze 'session', 'tracker', "autopublish", "insecure", "standard-minifier-css", "standard-minifier-js", "es5-shim", "shell-server", "modern-browsers", "ecmascript", "typescript", ]; function newSelfTestCatalog() { if (! files.inCheckout()) { throw Error("Only can build packages from a checkout"); } const catalogLocal = require('../packaging/catalog/catalog-local.js'); const selfTestCatalog = new catalogLocal.LocalCatalog; const messages = capture( { title: "scanning local core packages" }, () => { const packagesDir = files.pathJoin(files.getCurrentToolsDir(), 'packages'); // 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: [ packagesDir, files.pathJoin(packagesDir, "non-core"), files.pathJoin(packagesDir, "non-core", "*", "packages"), ], }); }); if (messages.hasMessages()) { Console.arrowError("Errors while scanning core packages:"); Console.printMessages(messages); throw new Error("scan failed?"); } return selfTestCatalog; }