UNPKG

@dr.pogodin/react-utils

Version:

Collection of generic ReactJS components and utils

229 lines (216 loc) 9.04 kB
/** * 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