@dr.pogodin/react-utils
Version:
Collection of generic ReactJS components and utils
229 lines (216 loc) • 9.04 kB
JavaScript
/**
* Jest environment for end-to-end SSR and client-side testing. It relies on
* the standard react-utils mechanics to execute SSR of given scene, and also
* Webpack build of the code for client-side execution, it further exposes
* Jsdom environment for the client-side testing of the outcomes.
*/
// BEWARE: The module is not imported into the JU module / the main assembly of
// the library, because doing so easily breaks stuff:
// 1) This module depends on Node-specific modules, which would make JU
// incompatible with JsDom if included into JU.
// 2) If this module is weakly imported from somewhere else in the lib,
// it seems to randomly break tests using it for a different reason,
// probably some sort of a require-loop, or some issues with weak
// require in that scenario.
// TODO: We need to add correct typing for environment options.
import path from 'node:path';
import { defaults, set } from 'lodash-es';
// As this environment is a part of the Jest testing utils,
// we assume development dependencies are available when it is used.
/* eslint-disable import/no-extraneous-dependencies */
import register from '@babel/register/experimental-worker';
import JsdomEnv from 'jest-environment-jsdom';
import { Volume, createFsFromVolume } from 'memfs';
import webpack from 'webpack';
/* eslint-enable import/no-extraneous-dependencies */
import ssrFactory from "../../../server/renderer";
import { setBuildInfo } from "../isomorphy/buildInfo";
function noop() {
// NOOP
}
export default class E2eSsrEnv extends JsdomEnv {
/**
* Loads Webpack config, and exposes it to the environment via global
* webpackConfig object.
*/
loadWebpackConfig() {
const optionsString = this.pragmas['webpack-config-options'];
const options = optionsString ? JSON.parse(optionsString) : {};
defaults(options, {
context: this.testFolder,
fs: this.global.webpackOutputFs
});
const factoryPath = this.pragmas['webpack-config-factory'];
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-require-imports
let factory = require(path.resolve(this.rootDir, factoryPath));
factory = 'default' in factory ? factory.default : factory;
this.global.webpackConfig = factory(options);
const fs = this.global.webpackOutputFs;
let buildInfo = `${options.context}/.build-info`;
if (fs.existsSync(buildInfo)) {
buildInfo = fs.readFileSync(buildInfo, 'utf8');
this.global.buildInfo = JSON.parse(buildInfo);
}
}
/**
* Executes Webpack build.
* @return {Promise}
*/
async runWebpack() {
this.loadWebpackConfig();
if (!this.global.webpackConfig) throw Error('Failed to load Webpack config');
const compiler = webpack(this.global.webpackConfig);
if (!compiler) throw Error('Failed to construct Webpack compiler');
// TODO: The "as typeof compiler.outputFileSystem" piece below is
// a workaround for the Webpack regression:
// https://github.com/webpack/webpack/issues/18242
compiler.outputFileSystem = this.global.webpackOutputFs;
return new Promise((done, fail) => {
compiler.run((err, stats) => {
if (err) fail(err);
if (stats?.hasErrors()) {
// eslint-disable-next-line no-console
console.error(stats.toJson().errors);
fail(Error('Webpack compilation failed'));
}
this.global.webpackStats = stats?.toJson();
// Keeps reference to the raw Webpack stats object, which should be
// explicitly passed to the server-side renderer alongside the request,
// so that it can to pick up asset paths for different named chunks.
this.webpackStats = stats;
done();
});
});
}
async runSsr() {
const optionsString = this.pragmas['ssr-options'];
const options = optionsString ? JSON.parse(optionsString) : {};
// TODO: This is temporary to shortcut the logging added to SSR.
options.logger ??= {
debug: noop,
info: noop,
log: noop,
warn: noop
};
options.buildInfo ??= this.global.buildInfo;
let cleanup;
if (options.entry) {
const p = path.resolve(this.testFolder, options.entry);
// TODO: This sure can be replaced by a dynamic import().
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-require-imports
const module = require(p);
if ('cleanup' in module) cleanup = module.cleanup;
const exportName = options.entryExportName || 'default';
if (exportName in module) {
options.Application = module[exportName];
}
}
const renderer = ssrFactory(this.global.webpackConfig, options);
let status = 200; // OK
const markup = await new Promise((done, fail) => {
void renderer(this.ssrRequest,
// TODO: This will do for now, with the current implementation of
// the renderer, but it will require a rework once the renderer is
// updated to do streaming.
{
cookie: noop,
send: done,
set: noop,
status: value => {
status = value;
},
// This is how up-to-date Webpack stats are passed to the server in
// development mode, and we use this here always, instead of having
// to pass some information via filesystem.
locals: {
webpack: {
devMiddleware: {
stats: this.webpackStats
}
}
}
}, error => {
// TODO: Strictly speaking, that error as Error casting is not all
// correct, but it works, so no need to spend time on it right now.
if (error) fail(error);else done('');
});
});
this.global.ssrMarkup = markup;
this.global.ssrOptions = options;
this.global.ssrStatus = status;
if (cleanup) cleanup();
}
constructor(config, context) {
const pragmas = context.docblockPragmas;
const requestString = pragmas['ssr-request'];
const request = requestString ? JSON.parse(requestString) : {};
request.url ??= '/';
request.csrfToken = noop;
// This ensures the initial JsDom URL matches the value we use for SSR.
set(config.projectConfig, 'testEnvironmentOptions.url', `http://localhost${request.url}`);
super(config, context);
this.global.dom = this.dom;
this.global.webpackOutputFs = createFsFromVolume(new Volume());
// Extracts necessary settings from config and context.
const {
projectConfig
} = config;
this.rootDir = projectConfig.rootDir;
this.testFolder = path.dirname(context.testPath);
this.withSsr = !pragmas['no-ssr'];
this.ssrRequest = request;
this.pragmas = pragmas;
// The usual "babel-jest" transformation setup does not apply to
// the environment code and imports from it, this workaround enables it.
const optionsString = this.pragmas['ssr-options'];
const options = optionsString ? JSON.parse(optionsString) : {};
let root;
switch (options.root) {
case 'TEST':
root = this.testFolder;
break;
default:
root = process.cwd();
}
register({
envName: options.babelEnv,
extensions: ['.js', '.jsx', '.ts', '.tsx', '.svg'],
root
});
}
async setup() {
await super.setup();
await this.runWebpack();
// NOTE: It is possible that the Webpack run above, and the SSR run below
// load different versions of the same module (CommonJS, and ES), and it may
// cause very confusing problems (e.g. see:
// https://github.com/birdofpreyru/react-utils/issues/413).
// It seems we can't reset the cache of ES modules, and Jest's module reset
// does not reset modules loaded in this enviroment module, and also only
// replacing entire cache object by and empty {} seems to help (in contrast
// to deleting all entries by their keys, as it is done within .teardown()
// method below). Thus, for now we do this as a hotfix, and we also reset
// build info to undefined, because ES module version not beeing reset
// triggers an error on the subsequent test using the environment.
// TODO: Look for a cleaner solution.
require.cache = {};
setBuildInfo(undefined, true);
if (this.withSsr) await this.runSsr();
this.global.REACT_UTILS_FORCE_CLIENT_SIDE = true;
}
async teardown() {
delete this.global.REACT_UTILS_FORCE_CLIENT_SIDE;
// Resets module cache and @babel/register. Effectively this ensures that
// the next time an instance of this environment is set up, all modules are
// transformed by Babel from scratch, thus taking into account the latest
// Babel config (which may change between different environment instances,
// which does not seem to be taken into account by Babel / Node caches
// automatically).
Object.keys(require.cache).forEach(key => {
delete require.cache[key];
});
register.revert();
await super.teardown();
}
}
//# sourceMappingURL=E2eSsrEnv.js.map