testbook
Version:
486 lines • 22.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.startServer = exports.runComponentTest = exports.findModuleTests = exports.serialiseError = void 0;
const vm_1 = require("vm");
const mocks_1 = require("./mocks");
const dom_1 = require("./dom");
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const express_1 = __importDefault(require("express"));
const glob_1 = __importDefault(require("glob"));
const uuid_1 = require("uuid");
const jsdom_1 = require("jsdom");
const webpack_1 = __importDefault(require("webpack"));
const propTypes_1 = require("./propTypes");
const openBrowser_1 = __importDefault(require("react-dev-utils/openBrowser"));
const promise_1 = require("./promise");
const logger_1 = require("./logger");
const hostNodeModulesPath = path_1.default.join(process.cwd(), "node_modules");
const { act } = require(path_1.default.join(hostNodeModulesPath, "react-dom/test-utils"));
const serialiseError = (error) => ({
name: error.name,
message: error.message,
stack: error.stack,
componentStack: error instanceof RenderError ? error.componentStack : undefined,
});
exports.serialiseError = serialiseError;
const loadModuleTests = (file) => getFile(file).then((f) => [file, f]);
const findModuleTests = (searchPath) => new Promise((resolve, reject) => {
glob_1.default(path_1.default.join(searchPath, "**/*.tests.json"), null, (err, files) => {
console.log(`Test files found: ${files}`);
if (err) {
return reject(err);
}
Promise.all(files.map(loadModuleTests))
.then((files) => files.map(([fileName, file]) => ({
file: fileName
.replace(/\.tests\.json$/, "")
.replace(new RegExp(`^src${path_1.default.sep}`), ""),
components: file.components.map((c) => (Object.assign(Object.assign({}, c), { exportName: c.name, name: getExportComponentName(c.name, fileName).replace(/\.(.+)\.tests$/, "") }))),
})))
.then(resolve)
.catch(reject);
});
});
exports.findModuleTests = findModuleTests;
const findModulesWithComponents = (searchPath) => new Promise((resolve, reject) => {
glob_1.default(path_1.default.join(searchPath, "**/*.{js,jsx,ts,tsx}"), {
ignore: "**/*.test.js",
}, (err, files) => {
console.log(`js/ts files found: ${files}`);
if (err) {
return reject(err);
}
promise_1.promiseBatch(files.map((file) => () => loadModuleWithComponents(file)), 3)
.then((m) => m
.filter((m) => !m.error && m.components.length)
.map((m) => (Object.assign(Object.assign({}, m), { components: m.components.map((c) => (Object.assign(Object.assign({}, c), (c.error && { error: exports.serialiseError(c.error) })))), file: m.file.replace(new RegExp(`^src${path_1.default.sep}`), "") }))))
.then(resolve);
});
});
const loadModuleWithComponents = (modulePath) => compileModuleWithHostWebpack(modulePath)
.then(([outputPath, filename]) => compileWrapperAndModuleWithWebpack(require.resolve("./findComponentsInModule"), outputPath, filename))
.then((moduleCode) => {
console.log(`Loading ${modulePath}`);
const script = new vm_1.Script(moduleCode);
const context = createDOM().getInternalVMContext();
script.runInContext(context);
context.close();
return {
file: modulePath,
components: context.result &&
context.result
.filter(([, isComponent, error]) => isComponent || error)
.map(([exportName, , error]) => (Object.assign({ exportName, name: getExportComponentName(exportName, modulePath) }, (error && { error }))))
.sort((a, b) => (b.exportName === "default" ? 1 : -1)),
};
})
.catch((error) => {
console.log(`Load failed ${error}`);
return {
file: modulePath,
components: [],
error,
};
});
const getExportComponentName = (exportName, filePath) => {
if (exportName === "default") {
const fileName = path_1.default.parse(filePath).name;
if (fileName === "index") {
return path_1.default.basename(path_1.default.dirname(filePath));
}
return fileName;
}
return exportName;
};
const compileModuleWithHostWebpack = (modulePath, externals = null) => {
const craWebpackConfig = require(path_1.default.join(hostNodeModulesPath, "react-scripts/config/webpack.config"))("production");
const outputModulePath = modulePath.replace(/\.[^.]+$/, ".js");
return new Promise((resolve, reject) => webpack_1.default(Object.assign(Object.assign({}, craWebpackConfig), { entry: [path_1.default.resolve(modulePath)], output: {
filename: outputModulePath,
libraryTarget: "umd",
}, optimization: Object.assign(Object.assign({}, craWebpackConfig.optimization), { splitChunks: false, runtimeChunk: false }), externals: Object.assign({ react: "react", "react-dom": "react-dom" }, externals) }), (err, stats) => {
if (err) {
return reject(err);
}
if (stats.hasErrors()) {
return reject(stats.toJson().errors);
}
return resolve([stats.toJson().outputPath, outputModulePath]);
}));
};
const compileWithWebpack = (modulePath) => new Promise((resolve, reject) => webpack_1.default({
entry: [modulePath],
output: {
filename: path_1.default.basename(modulePath),
},
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
happyPackMode: true,
},
},
],
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
},
}, (err, stats) => {
if (err) {
return reject(err);
}
if (stats.hasErrors()) {
return reject(stats.toJson().errors);
}
return resolve(path_1.default.resolve(path_1.default.join(stats.toJson().outputPath, path_1.default.basename(modulePath))));
})).then((wrapperModulePath) => new Promise((resolve, reject) => fs_1.default.readFile(wrapperModulePath, function (err, data) {
if (err) {
return reject(err);
}
resolve(data.toString());
})));
const compileWrapperAndModuleWithWebpack = (wrapperModulePath, modulePath, moduleFilename, aliases = null) => new Promise((resolve, reject) => webpack_1.default({
entry: [wrapperModulePath],
output: {
filename: path_1.default.join(path_1.default.basename(wrapperModulePath), moduleFilename),
},
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
happyPackMode: true,
},
},
],
exclude: /node_modules/,
},
],
},
resolve: {
extensions: [".tsx", ".ts", ".js"],
alias: Object.assign({ module: modulePath && path_1.default.resolve(path_1.default.join(modulePath, moduleFilename)), react: path_1.default.join(hostNodeModulesPath, "react"), "react-dom": path_1.default.join(hostNodeModulesPath, "react-dom") }, aliases),
},
}, (err, stats) => {
if (err) {
return reject(err);
}
if (stats.hasErrors()) {
return reject(stats.toJson().errors);
}
return resolve(path_1.default.resolve(path_1.default.join(stats.toJson().outputPath, path_1.default.basename(wrapperModulePath), moduleFilename)));
})).then((wrapperModulePath) => new Promise((resolve, reject) => fs_1.default.readFile(wrapperModulePath, function (err, data) {
if (err) {
return reject(err);
}
resolve(data.toString());
})));
const getComponent = (file, exportName) => getTestFile(file).then((f) => f.components.find((c) => c.name === exportName));
const getComponentTests = (file, exportName) => getComponent(file, exportName).then((c) => (c ? c.tests : []), () => []);
const getComponentTestStatuses = (file, exportName) => getComponentTests(file, exportName)
.then((tests) => Promise.all(tests.map((t) => exports.runComponentTest(file, exportName, t.id).then(([results]) => [
t.id,
results,
]))))
.then((listOfIdsAndResults) => listOfIdsAndResults.reduce((prev, [testId, results]) => (Object.assign(Object.assign({}, prev), { [testId]: results.every((r) => r.result === "success")
? "success"
: "error" })), {}));
const getComponentTest = (file, exportName, testId) => getComponentTests(file, exportName).then((t) => t.find((t) => t.id === testId));
const getOrCreateFileJson = (file) => getTestFile(file).catch(() => ({
components: [],
}));
const getFile = (file) => new Promise((resolve, reject) => fs_1.default.readFile(file, "utf8", (err, data) => err ? reject(err) : resolve(JSON.parse(data))));
const getTestFile = (file) => getFile(`${file}.tests.json`);
const writeFile = (file, payload) => new Promise((resolve, reject) => fs_1.default.writeFile(`${file}.tests.json`, JSON.stringify(payload, null, 2), (err) => {
err ? reject() : resolve(payload);
}));
const getOrCreateComponent = (fileJson, exportName) => {
const component = fileJson.components.find((c) => c.name === exportName);
return component
? fileJson
: {
components: [
...fileJson.components,
{
name: exportName,
tests: [],
},
],
};
};
const createTest = (file, exportName) => {
const test = {
id: uuid_1.v1(),
steps: [
{
type: "render",
definition: {
props: {},
},
},
],
};
return getOrCreateFileJson(file)
.then((fileJson) => getOrCreateComponent(fileJson, exportName))
.then((fileJson) => writeFile(file, fileJsonWithAddedTest(fileJson, exportName, test)))
.then(() => test);
};
const updatedTest = (test, updates) => (Object.assign(Object.assign({}, test), updates));
const componentWithAddedTest = (component, test) => (Object.assign(Object.assign({}, component), { tests: component.tests.concat([test]) }));
const fileJsonWithAddedTest = (fileJson, exportName, test) => (Object.assign(Object.assign({}, fileJson), { components: fileJson.components.map((c) => c.name === exportName ? componentWithAddedTest(c, test) : c) }));
const componentWithUpdatedTest = (component, testId, testUpdates) => (Object.assign(Object.assign({}, component), { tests: component.tests.map((t) => t.id === testId ? updatedTest(t, testUpdates) : t) }));
const fileJsonWithUpdatedTest = (fileJson, exportName, testId, testUpdates) => (Object.assign(Object.assign({}, fileJson), { components: fileJson.components.map((c) => c.name === exportName ? componentWithUpdatedTest(c, testId, testUpdates) : c) }));
const updateTestName = (file, exportName, testId, name) => getTestFile(file).then((fileJson) => writeFile(file, fileJsonWithUpdatedTest(fileJson, exportName, testId, { name })));
const updateTestSteps = (file, exportName, testId, steps) => getTestFile(file).then((fileJson) => writeFile(file, fileJsonWithUpdatedTest(fileJson, exportName, testId, { steps })));
class RenderError extends Error {
constructor(name, message = undefined, stack = undefined, componentStack = undefined) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
this.name = name;
this.stack = stack;
this.componentStack = componentStack;
}
}
class EventError extends Error {
constructor(message) {
super(message);
this.name = "EventError";
}
}
class AssertionError extends Error {
constructor(message) {
super(message);
this.name = "AssertionError";
}
}
const createDOM = (logger) => new jsdom_1.JSDOM('<!doctype html><html lang="en-GB"><body /></html>', {
pretendToBeVisual: true,
runScripts: "dangerously",
url: "http://localhost",
virtualConsole: logger
? new jsdom_1.VirtualConsole().sendTo(logger)
: new jsdom_1.VirtualConsole(),
});
const render = (file, exportName, props, context, wrapper) => compileModuleWithHostWebpack(file, wrapper ? { [wrapper.file]: wrapper.file } : null)
.then(([moduleOutputPath, moduleFilename]) => compileWrapperAndModuleWithWebpack(require.resolve("./render"), moduleOutputPath, moduleFilename, Object.assign({ wrapper: wrapper
? path_1.default.join(hostNodeModulesPath, wrapper.file)
: require.resolve("./noop") }, (wrapper
? { [wrapper.file]: path_1.default.join(hostNodeModulesPath, wrapper.file) }
: null))))
.then((moduleCode) => {
const script = new vm_1.Script(moduleCode);
context.exportName = exportName;
context.props = props;
context.wrapperExportName = wrapper && wrapper.exportName;
context.wrapperProps = wrapper && wrapper.props;
script.runInContext(context);
return context.result;
})
.catch(([errorException, componentError]) => {
throw new RenderError(errorException.name, errorException.message, errorException.stack, componentError && componentError.componentStack);
});
const setupVmContextWithContainerAndMocks = () => compileWithWebpack(require.resolve("./setupContainerAndMocks")).then((moduleCode) => {
const script = new vm_1.Script(moduleCode);
const logger = logger_1.setupConsoleLogger();
const context = createDOM(logger).getInternalVMContext();
script.runInContext(context);
return [context, logger];
});
const renderComponentSideEffects = (file, exportName, testId, step) => exports.runComponentTest(file, exportName, testId, step).then(([, context, logger]) => {
const { container, mocks } = context;
if (!container) {
return {
regions: [],
mocks: [],
};
}
const elements = dom_1.findTextNodes(container).map(([e, text]) => ({
text,
type: ["BUTTON", "A"].includes(e.nodeName) ? "button" : "text",
xpath: dom_1.getElementTreeXPath(e, context),
}));
return {
regions: elements.map((e) => (Object.assign(Object.assign({}, e), { unique: !elements.find((f) => f.text === e.text && f.xpath !== e.xpath) }))),
mocks: mocks.map(({ name, mock }) => ({
name,
calls: mock.getCalls(),
})),
logs: logger.getLog(),
};
});
const runRenderStep = (file, exportName, { definition: { props, wrapper } }, context) => render(file, exportName, props, context, wrapper)
.then(() => ["success", context])
.catch((e) => [e, context]);
const runEventStep = (file, exportName, { definition: { type, target } }, context) => {
const { container } = context;
const node = dom_1.findTextNodes(container).find(([, text]) => text === target);
if (!node) {
return Promise.resolve([
new EventError(`Text '${target}' could not be found`),
context,
]);
}
const [targetNode] = node;
act(() => {
targetNode.dispatchEvent(new context.Event(type, {
bubbles: true,
cancelable: false,
composed: false,
}));
});
return new Promise((resolve) => setTimeout(() => resolve(["success", context]), 10));
};
const runAssertionStep = (file, exportName, { definition: { type, target } }, context) => {
const { container, mocks } = context;
if (type === "text") {
const matches = dom_1.findTextNodes(container).filter(([, text]) => text === target);
return Promise.resolve([
matches.length
? "success"
: new AssertionError(`Text '${target}' could not be found`),
context,
]);
}
if (type === "mock") {
const mock = mocks.find((m) => m.name === target.name);
const calls = mock ? mock.mock.getCalls() : [];
const callsWithMatchingArgs = calls.filter((args) => JSON.stringify(args) === JSON.stringify(target.args));
return Promise.resolve([
callsWithMatchingArgs.length
? "success"
: new AssertionError(`Mock '${target.name}' was not called with args '${target.args}'`),
context,
]);
}
return Promise.resolve([
new AssertionError(`Invalid assertion type '${type}'`),
context,
]);
};
const STEP_RUNNERS = {
render: runRenderStep,
event: runEventStep,
assertion: runAssertionStep,
mock: mocks_1.runMockStep,
};
const runStep = (file, exportName, step, context) => {
console.log(`Running step ${JSON.stringify(step)}`);
return STEP_RUNNERS[step.type](file, exportName, step, context);
};
const runComponentTest = (file, exportName, testId, step) => Promise.all([
setupVmContextWithContainerAndMocks(),
getComponentTest(file, exportName, testId),
])
.then(([[context, logger], { steps }]) => steps.reduce((resultsAndContext, s, idx) => resultsAndContext.then(([results, context]) => idx <= (step === undefined ? steps.length - 1 : step) &&
!results.find((r) => r.result instanceof Error)
? runStep(file, exportName, s, context).then(([result, newContext,]) => [
[...results, { result }],
newContext,
logger,
])
: Promise.resolve([
results,
context,
logger,
])), Promise.resolve([[], context, logger])))
.then(([results, context, logger]) => [
results,
context.close() || context,
logger,
]);
exports.runComponentTest = runComponentTest;
const getComponentPropTypes = (modulePath, exportName) => {
if (/tsx?$/.test(modulePath)) {
return propTypes_1.getTsPropTypes(modulePath, exportName, require(path_1.default.join(hostNodeModulesPath, "react-scripts/config/paths"))
.appTsConfig);
}
return compileModuleWithHostWebpack(modulePath)
.then(([moduleOutputPath, moduleFilename]) => compileWrapperAndModuleWithWebpack(require.resolve("./getComponentPropTypes"), moduleOutputPath, moduleFilename))
.then((moduleCode) => {
const context = createDOM().getInternalVMContext();
const script = new vm_1.Script(moduleCode);
context.exportName = exportName;
script.runInContext(context);
context.close();
return context.result;
});
};
const getWrapperOptions = () => Promise.resolve([
{
file: "react-router-dom",
exportName: "MemoryRouter",
propTypes: {},
},
]);
const app = express_1.default();
const PORT = 9010;
app.use(express_1.default.json());
const SEARCH_PATH = "./src";
app.get("/module-test", (req, res, next) => exports.findModuleTests(SEARCH_PATH)
.then((moduleComponentTests) => res.send(moduleComponentTests))
.catch(next));
app.get("/module-component", (req, res, next) => findModulesWithComponents(SEARCH_PATH)
.then((modulesWithComponents) => res.send(modulesWithComponents))
.catch(next));
app.get("/test", (req, res, next) => getComponentTests(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName)
.then((tests) => res.send(tests))
.catch(next));
app.get("/test/status", (req, res, next) => getComponentTestStatuses(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName)
.then((statuses) => res.send(statuses))
.catch(next));
app.get("/test/:testId", (req, res, next) => getComponentTest(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName, req.params.testId)
.then((test) => res.send(test))
.catch(next));
app.post("/test", (req, res, next) => createTest(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName)
.then((test) => res.send(test))
.catch(next));
app.put("/test/:testId/steps", (req, res, next) => updateTestSteps(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName, req.params.testId, req.body)
.then((testSteps) => res.send(testSteps))
.catch(next));
app.put("/test/:testId/name", (req, res, next) => updateTestName(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName, req.params.testId, req.body.name)
.then((test) => res.send(test))
.catch(next));
app.get("/test/:testId/render/side-effects", (req, res, next) => renderComponentSideEffects(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName, req.params.testId, parseInt(req.query.step, 10))
.then((testSideEffects) => res.send(testSideEffects))
.catch(next));
app.get("/test/:testId/run", (req, res, next) => exports.runComponentTest(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName, req.params.testId)
.then(([results]) => results.map(({ result }) => result instanceof Error
? { result: "error", error: exports.serialiseError(result) }
: { result }))
.then((results) => res.send(results))
.catch(next));
app.get("/component/prop-types", (req, res, next) => getComponentPropTypes(path_1.default.join(SEARCH_PATH, req.query.file), req.query.exportName)
.then((propTypes) => res.send(propTypes))
.catch(next));
app.get("/component/wrapper", (req, res, next) => getWrapperOptions()
.then((wrappers) => res.send(wrappers))
.catch(next));
const startServer = (client_static_root) => {
app.use("/", express_1.default.static(path_1.default.join(__dirname, client_static_root)));
app.get("/*", (req, res) => res.sendFile(path_1.default.join(__dirname, client_static_root, "index.html")));
app.listen(PORT, () => {
console.log(`Started Testbook http://localhost:${PORT}.`);
openBrowser_1.default(`http://localhost:${PORT}`);
});
};
exports.startServer = startServer;
const isCLI = require.main === module;
if (isCLI) {
const [client_static_root] = process.argv.slice(2);
exports.startServer(client_static_root);
}
//# sourceMappingURL=server.js.map