Files
meteor/tools/tool-testing/sandbox.js
2023-04-27 14:50:29 -03:00

625 lines
22 KiB
JavaScript

// 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;
}
async init() {
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');
await 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
// });
async 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) {
await PuppeteerClient.pushClients(this.clients, appConfig);
}
if (
clientOptions.browserstack &&
(await BrowserStackClient.prerequisitesMet())
) {
BrowserStackClient.pushClients(this.clients, appConfig);
}
}
const testNameAndFile = `${testFile ? `${testFile}: ` : ''}${testName ? `"${testName}" ` : ''}`;
console.log(`Running test ${testNameAndFile}with ${this.clients.length} client(s)...`);
const array = Object.keys(this.clients);
for (const [index, clientKey] of array.entries()) {
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;
await 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');
async createApp(to, template, options) {
options = options || {};
const absoluteTo = files.pathJoin(this.cwd, to);
const absoluteFrom = files.pathJoin(
files.convertToStandardPath(__dirname),
'..', 'tests', 'apps', template
);
await 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());
}
await 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.)
await this.cd(to, async () => {
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(150);
await 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');
async createPackage(packageDir, packageName, template) {
const packagePath = files.pathJoin(this.cwd, packageDir);
const templatePackagePath = files.pathJoin(
files.convertToStandardPath(__dirname), '..', 'tests', 'packages', template);
await 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) {
const ret = callback();
if (ret && typeof ret.then === "function") {
return ret.then(() => (this.cwd = previous));
} else {
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 interpreted 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 interpreted 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.
async _makeWarehouse(releases) {
// Ensure we have a tropohouse to copy stuff out of.
await setUpBuiltPackageTropohouse();
const serverUrl = this.env.METEOR_PACKAGE_SERVER_URL;
const packagesDirectoryName = getPackagesDirectoryName(serverUrl);
const builder = new Builder({outputPath: this.warehouse});
await builder.init();
await builder.copyDirectory({
from: files.pathJoin(builtPackageTropohouseDir, 'packages'),
to: packagesDirectoryName,
symlink: true
});
await builder.complete();
const stubCatalog = {
syncToken: {},
formatVersion: "1.0",
collections: {
packages: [],
versions: [],
builds: [],
releaseTracks: [],
releaseVersions: []
}
};
const packageVersions = {};
let toolPackageVersion = null;
await tropohouseIsopackCache.eachBuiltIsopack(async (packageName, isopack) => {
const packageRec = tropohouseLocalCatalog.getPackage(packageName);
if (! packageRec) {
throw Error("no package record for " + packageName);
}
stubCatalog.collections.packages.push(packageRec);
const versionRec = await 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();
await this.warehouseOfficialCatalog.initialize({
packageStorage: dataFile
});
this.warehouseOfficialCatalog.insertData(stubCatalog);
// And a cherry on top
// XXX this is hacky
await files.linkToMeteorScript(
files.pathJoin(this.warehouse, packagesDirectoryName, "meteor-tool", toolPackageVersion,
'mt-' + archInfoHost(), 'meteor'),
files.pathJoin(this.warehouse, 'meteor'));
}
}
async function doOrThrow(f) {
let ret;
const messages = await capture(async function () {
ret = await f();
});
if (messages.hasMessages()) {
throw Error(messages.formatMessages());
}
return ret;
}
async 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 = await newSelfTestCatalog();
const versions = {};
for (const packageName of tropohouseLocalCatalog.getAllNonTestPackageNames()) {
versions[packageName] =
await 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
});
await doOrThrow(function () {
return enterJob("building self-test packages", () => {
// Build the packages into the in-memory IsopackCache.
return 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.
await tropohouseIsopackCache.eachBuiltIsopack((name, isopack) => {
return 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",
"facts-base",
];
async 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 = await capture(
{ title: "scanning local core packages" },
async () => {
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).
await 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;
}