mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Instead of everything being red, have passed test be green and information text be white.
697 lines
19 KiB
JavaScript
697 lines
19 KiB
JavaScript
import { inspect } from 'util';
|
|
import * as files from '../fs/files';
|
|
import { createHash } from 'crypto';
|
|
import {
|
|
markBottom as parseStackMarkBottom,
|
|
markTop as parseStackMarkTop,
|
|
parse as parseStackParse,
|
|
} from '../utils/parse-stack';
|
|
import { Console } from '../console/console.js';
|
|
import { loadIsopackage } from '../tool-env/isopackets.js';
|
|
import TestFailure from './test-failure.js';
|
|
import { setRunningTest } from './run.js';
|
|
import Run from './run.js';
|
|
|
|
// These are accessed through selftest directly on many tests.
|
|
export { default as Sandbox } from './sandbox.js';
|
|
export { Run };
|
|
|
|
import "../tool-env/install-runtime.js";
|
|
|
|
// 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.
|
|
export { parseStackMarkTop as markStack };
|
|
|
|
// Call from a test to throw a TestFailure exception and bail out of the test
|
|
export const fail = parseStackMarkTop(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
|
|
export const expectEqual = parseStackMarkTop(function (actual, expected) {
|
|
if (! loadIsopackage('ejson').EJSON.equals(actual, expected)) {
|
|
throw new TestFailure("not-equal", {
|
|
expected,
|
|
actual,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Call from a test to assert that 'actual' is truthy.
|
|
export const expectTrue = parseStackMarkTop(function (actual) {
|
|
if (! actual) {
|
|
throw new TestFailure('not-true');
|
|
}
|
|
});
|
|
|
|
// Call from a test to assert that 'actual' is falsey.
|
|
export const expectFalse = parseStackMarkTop(function (actual) {
|
|
if (actual) {
|
|
throw new TestFailure('not-false');
|
|
}
|
|
});
|
|
|
|
export const expectThrows = parseStackMarkTop(function (f) {
|
|
let threw = false;
|
|
try {
|
|
f();
|
|
} catch (e) {
|
|
threw = true;
|
|
}
|
|
|
|
if (! threw) {
|
|
throw new TestFailure("expected-exception");
|
|
}
|
|
});
|
|
|
|
class Test {
|
|
constructor(options) {
|
|
this.name = options.name;
|
|
this.file = options.file;
|
|
this.fileHash = options.fileHash;
|
|
this.tags = options.tags || [];
|
|
this.f = options.func;
|
|
this.durationMs = null;
|
|
this.cleanupHandlers = [];
|
|
}
|
|
|
|
onCleanup(cleanupHandler) {
|
|
this.cleanupHandlers.push(cleanupHandler);
|
|
}
|
|
|
|
cleanup() {
|
|
this.cleanupHandlers.forEach((cleanupHandler) => {
|
|
cleanupHandler();
|
|
});
|
|
this.cleanupHandlers = [];
|
|
}
|
|
}
|
|
|
|
let allTests = null;
|
|
let fileBeingLoaded = null;
|
|
let fileBeingLoadedHash = null;
|
|
|
|
const getAllTests = () => {
|
|
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.
|
|
const testdir = files.pathJoin(__dirname, '..', 'tests');
|
|
const filenames = files.readdir(testdir);
|
|
filenames.forEach((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');
|
|
|
|
const fullPath = files.pathJoin(testdir, n);
|
|
const contents = files.readFile(fullPath, 'utf8');
|
|
fileBeingLoadedHash = createHash('sha1').update(contents).digest('hex');
|
|
|
|
require(files.pathJoin(testdir, n));
|
|
} finally {
|
|
fileBeingLoaded = null;
|
|
fileBeingLoadedHash = null;
|
|
}
|
|
});
|
|
|
|
return allTests;
|
|
};
|
|
|
|
export function define(name, tagsList, f) {
|
|
if (typeof tagsList === "function") {
|
|
// tagsList is optional
|
|
f = tagsList;
|
|
tagsList = [];
|
|
}
|
|
|
|
const tags = tagsList.slice();
|
|
tags.sort();
|
|
|
|
allTests.push(new Test({
|
|
name,
|
|
tags,
|
|
file: fileBeingLoaded,
|
|
fileHash: fileBeingLoadedHash,
|
|
func: f,
|
|
}));
|
|
}
|
|
|
|
// Prevent specific self-test's from being run.
|
|
// e.g. `selftest.skip.define("some test", ...` will skip running "some test".
|
|
const selftestDefine = define;
|
|
export const skip = {
|
|
define(name, tagsList, f) {
|
|
if (typeof tagsList === 'function') {
|
|
f = tagsList;
|
|
}
|
|
selftestDefine(name, ['manually-ignored'], f);
|
|
}
|
|
};
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// Choosing tests
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
const 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': "",
|
|
// These tests require a setup step which can be amortized across multiple
|
|
// similar tests, so it makes sense to segregate them
|
|
'custom-warehouse': "requires a custom warehouse",
|
|
'manually-ignored': 'excluded by selftest.skip'
|
|
};
|
|
|
|
// 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
|
|
function getFilteredTests(options) {
|
|
options = options || {};
|
|
let allTests = getAllTests();
|
|
let testState;
|
|
|
|
if (allTests.length) {
|
|
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((test) => {
|
|
const 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 (! test.tags.includes("galaxy")) {
|
|
newTags.push('non-galaxy');
|
|
}
|
|
|
|
if (! newTags.length) {
|
|
return test;
|
|
}
|
|
|
|
return Object.assign(
|
|
Object.create(Object.getPrototypeOf(test)),
|
|
test,
|
|
{
|
|
tags: test.tags.concat(newTags),
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
// (order of tags is significant to the "skip counts" that are displayed)
|
|
const 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 (options['without-tag']) {
|
|
tagsToSkip.push(options['without-tag']);
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
tagsToSkip.push("cordova");
|
|
tagsToSkip.push("yet-unsolved-windows-failure");
|
|
} else {
|
|
tagsToSkip.push("windows");
|
|
}
|
|
|
|
tagsToSkip.push('manually-ignored');
|
|
|
|
const tagsToMatch = options['with-tag'] ? [options['with-tag']] : [];
|
|
return new TestList(allTests, tagsToSkip, tagsToMatch, testState);
|
|
}
|
|
|
|
function groupTestsByFile(tests) {
|
|
const grouped = {};
|
|
tests.forEach((test) => {
|
|
grouped[test.file] = grouped[test.file] || [];
|
|
grouped[test.file].push(test);
|
|
});
|
|
|
|
return grouped;
|
|
}
|
|
|
|
// 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.
|
|
class TestList {
|
|
constructor(allTests, tagsToSkip, tagsToMatch, testState) {
|
|
tagsToSkip = (tagsToSkip || []);
|
|
testState = (testState || null); // optional
|
|
this.allTests = allTests;
|
|
this.failedTests = [];
|
|
this.skippedTags = tagsToSkip;
|
|
this.skipCounts = {};
|
|
this.testState = testState;
|
|
|
|
tagsToSkip.forEach((tag) => {
|
|
this.skipCounts[tag] = 0;
|
|
});
|
|
|
|
this.fileInfo = {}; // path -> {hash, hasSkips, hasFailures}
|
|
|
|
this.filteredTests = allTests.filter((test) => {
|
|
if (! this.fileInfo[test.file]) {
|
|
this.fileInfo[test.file] = {
|
|
hash: test.fileHash,
|
|
hasSkips: false,
|
|
hasFailures: false
|
|
};
|
|
}
|
|
|
|
const fileInfo = this.fileInfo[test.file];
|
|
|
|
if (tagsToMatch.length) {
|
|
const matches = tagsToMatch.some((tag) => test.tags.includes(tag));
|
|
if (!matches) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 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 !tagsToSkip.some((tag) => {
|
|
if (test.tags.includes(tag)) {
|
|
this.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.
|
|
notifyFailed(test, failureObject) {
|
|
// Mark the file that this test lives in as having failures.
|
|
this.fileInfo[test.file].hasFailures = true;
|
|
|
|
this.failedTests.push(test);
|
|
|
|
// Mark that the specific test failed.
|
|
test.failed = true;
|
|
|
|
// If there is a failure object, store that for potential output.
|
|
if (failureObject) {
|
|
test.failureObject = failureObject;
|
|
}
|
|
}
|
|
|
|
saveJUnitOutput(path) {
|
|
const grouped = groupTestsByFile(this.filteredTests);
|
|
|
|
// We'll form an collection of "testsuites"
|
|
const testSuites = [];
|
|
|
|
const attrSafe = attr => (attr || "").replace('"', """);
|
|
const durationForOutput = durationMs => durationMs / 1000;
|
|
|
|
// Each file is a testsuite.
|
|
Object.keys(grouped).forEach((file) => {
|
|
const testCases = [];
|
|
|
|
let countError = 0;
|
|
let countFailure = 0;
|
|
|
|
// Each test is a "testcase".
|
|
grouped[file].forEach((test) => {
|
|
const testCaseAttrs = [
|
|
`name="${attrSafe(test.name)}"`,
|
|
];
|
|
|
|
if (test.durationMs) {
|
|
testCaseAttrs.push(`time="${durationForOutput(test.durationMs)}"`);
|
|
}
|
|
|
|
const testCaseAttrsString = testCaseAttrs.join(' ');
|
|
|
|
if (test.failed) {
|
|
let failureElement = "";
|
|
|
|
if (test.failureObject instanceof TestFailure) {
|
|
countFailure++;
|
|
|
|
failureElement = [
|
|
`<error type="${test.failureObject.reason}">`,
|
|
'<![CDATA[',
|
|
inspect(test.failureObject.details, { depth: 4 }),
|
|
']]>',
|
|
'</error>',
|
|
].join('\n');
|
|
} else if (test.failureObject && test.failureObject.stack) {
|
|
countError++;
|
|
|
|
failureElement = [
|
|
'<failure>',
|
|
'<![CDATA[',
|
|
test.failureObject.stack,
|
|
']]>',
|
|
'</failure>',
|
|
].join('\n');
|
|
} else {
|
|
countError++;
|
|
|
|
failureElement = '<failure />';
|
|
}
|
|
|
|
testCases.push(
|
|
[
|
|
`<testcase ${testCaseAttrsString}>`,
|
|
failureElement,
|
|
'</testcase>',
|
|
].join('\n'),
|
|
);
|
|
} else {
|
|
testCases.push(`<testcase ${testCaseAttrsString}/>`);
|
|
}
|
|
});
|
|
|
|
const testSuiteAttrs = [
|
|
`name="${file}"`,
|
|
`tests="${testCases.length}"`,
|
|
`failures="${countFailure}"`,
|
|
`errors="${countError}"`,
|
|
`time="${durationForOutput(this.durationMs)}"`,
|
|
];
|
|
|
|
const testSuiteAttrsString = testSuiteAttrs.join(' ');
|
|
|
|
testSuites.push(
|
|
[
|
|
`<testsuite ${testSuiteAttrsString}>`,
|
|
testCases.join('\n'),
|
|
'</testsuite>',
|
|
].join('\n'),
|
|
);
|
|
});
|
|
|
|
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
|
|
const testSuitesString = testSuites.join('\n');
|
|
|
|
files.writeFile(path,
|
|
[
|
|
xmlHeader,
|
|
`<testsuites>`,
|
|
testSuitesString,
|
|
`</testsuites>`,
|
|
].join('\n'),
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
// 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.
|
|
saveTestState() {
|
|
const testState = this.testState;
|
|
if (! (testState && this.filteredTests.length)) {
|
|
return;
|
|
}
|
|
|
|
Object.keys(this.fileInfo).forEach((f) => {
|
|
const info = this.fileInfo[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"
|
|
generateSkipReport() {
|
|
let result = '';
|
|
|
|
this.skippedTags.forEach((tag) => {
|
|
const count = this.skipCounts[tag];
|
|
if (count) {
|
|
const noun = "test" + (count > 1 ? "s" : ""); // "test" or "tests"
|
|
// "non-matching tests" or "tests in other files"
|
|
const nounPhrase = (/ /.test(tag) ?
|
|
(noun + " " + tag) : (tag + " " + noun));
|
|
// " (foo)" or ""
|
|
const parenthetical = (tagDescriptions[tag] ? " (" +
|
|
tagDescriptions[tag] + ")" : '');
|
|
result += ("Skipped " + count + " " + nounPhrase + parenthetical + '\n');
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
function getTestStateFilePath() {
|
|
return files.pathJoin(files.getHomeDir(), '.meteortest');
|
|
}
|
|
|
|
function readTestState() {
|
|
const testStateFile = getTestStateFilePath();
|
|
let testState;
|
|
if (files.exists(testStateFile)) {
|
|
testState = JSON.parse(files.readFile(testStateFile, 'utf8'));
|
|
}
|
|
if (! testState || testState.version !== 1) {
|
|
testState = { version: 1, lastPassedHashes: {} };
|
|
}
|
|
return testState;
|
|
}
|
|
|
|
function writeTestState(testState) {
|
|
const testStateFile = getTestStateFilePath();
|
|
files.writeFile(testStateFile, JSON.stringify(testState), 'utf8');
|
|
}
|
|
|
|
// Same options as getFilteredTests. Writes to stdout and stderr.
|
|
export function listTests(options) {
|
|
const testList = getFilteredTests(options);
|
|
|
|
if (! testList.allTests.length) {
|
|
Console.error("No tests defined.\n");
|
|
return;
|
|
}
|
|
|
|
const grouped = groupTestsByFile(testList.filteredTests);
|
|
|
|
Object.keys(grouped).forEach((file) => {
|
|
Console.rawInfo(`${file}.js\n`);
|
|
grouped[file].forEach((test) => {
|
|
Console.rawInfo(' - test:' + test.name +
|
|
(test.tags.length ? ' [' + test.tags.join(' ') + ']'
|
|
: '') + '\n');
|
|
});
|
|
});
|
|
|
|
Console.error();
|
|
Console.error(testList.filteredTests.length + " tests listed.");
|
|
Console.error(testList.generateSkipReport());
|
|
}
|
|
|
|
const shouldSkipCurrentTest = ({currentTestIndex, options: {skip, limit} = {}}) => {
|
|
if (!skip && !limit) {
|
|
return false;
|
|
}
|
|
if (limit && skip) {
|
|
return currentTestIndex < skip || (currentTestIndex - skip) >= limit;
|
|
}
|
|
if (limit) {
|
|
return currentTestIndex >= limit;
|
|
}
|
|
if (skip) {
|
|
return currentTestIndex < skip;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// Running tests
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
// options: onlyChanged, offline, includeSlowTests, historyLines, testRegexp,
|
|
// fileRegexp,
|
|
// clients:
|
|
// - browserstack (need s3cmd credentials)
|
|
export function runTests(options) {
|
|
const testList = getFilteredTests(options);
|
|
|
|
if (! testList.allTests.length) {
|
|
Console.error("No tests defined.");
|
|
return 0;
|
|
}
|
|
|
|
testList.startTime = new Date;
|
|
|
|
let totalRun = 0;
|
|
|
|
testList.filteredTests.forEach((test, index) => {
|
|
totalRun++;
|
|
const shouldSkip = shouldSkipCurrentTest({
|
|
currentTestIndex: index,
|
|
options,
|
|
});
|
|
const skipMessage = shouldSkip
|
|
? options.preview
|
|
? 'will skip'
|
|
: 'skipped'
|
|
: options.preview
|
|
? 'will run'
|
|
: 'running';
|
|
const countMessage = `(${index + 1}/${testList.filteredTests.length})`;
|
|
const testMessage = `${test.file}.js test:${test.name} ...`;
|
|
Console.info(`${skipMessage} ${countMessage} ${testMessage}`);
|
|
|
|
if (shouldSkip || options.preview) {
|
|
return;
|
|
}
|
|
|
|
Run.runTest(
|
|
testList,
|
|
test,
|
|
parseStackMarkBottom(() => {
|
|
test.f(options);
|
|
}),
|
|
{
|
|
retries: options.retries,
|
|
}
|
|
);
|
|
});
|
|
|
|
testList.endTime = new Date;
|
|
testList.durationMs = testList.endTime - testList.startTime;
|
|
|
|
testList.saveTestState();
|
|
|
|
if (options.junit) {
|
|
testList.saveJUnitOutput(options.junit);
|
|
}
|
|
|
|
if (totalRun > 0) {
|
|
Console.error();
|
|
}
|
|
|
|
Console.error(testList.generateSkipReport());
|
|
|
|
const failureCount = testList.failedTests.length;
|
|
|
|
if (!totalRun) {
|
|
Console.error("No tests run.");
|
|
return 0;
|
|
} else if (!failureCount) {
|
|
let disclaimers = '';
|
|
if (totalRun < testList.allTests.length) {
|
|
disclaimers += " other";
|
|
}
|
|
Console.error("All" + disclaimers + " tests passed.");
|
|
return 0;
|
|
} else {
|
|
Console.error(failureCount + " failure" +
|
|
(failureCount > 1 ? "s" : "") + ":");
|
|
testList.failedTests.forEach((test) => {
|
|
Console.rawError(` - ${test.file}.js: test:${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.
|