tunnckoCore/gibon

View on GitHub
@packages/jest-runner-rollup/src/runner.js

Summary

Maintainability
D
1 day
Test Coverage
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const builtins = require('builtin-modules');
const { pass, fail, skip } = require('@tunnckocore/create-jest-runner');
const {
  getWorkspacesAndExtensions,
  isMonorepo,
} = require('@tunnckocore/utils');
const { rollup } = require('rollup');
const { cosmiconfigSync } = require('cosmiconfig');

const jestRunnerConfig = cosmiconfigSync('jest-runner');
const jestRunnerRollupConfig = cosmiconfigSync('jest-runner-rollup');
const rollupConfigFile = cosmiconfigSync('rollup');

const ROLLUP_CACHE = {};
const CACHE_DIR = path.join(
  process.cwd(), // to do
  'node_modules',
  '.cache',
  'jest-runner-rollup',
);

process.env.NODE_ENV = 'bundle';

/* eslint-disable max-statements */
module.exports = async function jestRunnerRollup({ testPath, config }) {
  const start = new Date();

  /** Load config from possible places */
  const cfg = await tryLoadConfig({ testPath, config, start });
  if (cfg.hasError) return cfg.error;

  /** Find input file */
  const hasExtension = path.extname(testPath).length > 0;
  const inputFile = await tryCatch(testPath, start, () =>
    hasExtension ? testPath : tryExtensions(testPath, config),
  );
  if (inputFile.hasError) return inputFile.error;

  /** Find correct root path */
  const pkgRoot = isMonorepo(config.cwd)
    ? path.dirname(path.dirname(inputFile))
    : config.rootDir;

  const { hooks, skipCached = false } = cfg;
  const allHooks = {
    onPkg: typeof hooks.onPkg === 'function' ? hooks.onPkg : () => {},
    onFormat: typeof hooks.onFormat === 'function' ? hooks.onFormat : (x) => x,
    onWrite: typeof hooks.onWrite === 'function' ? hooks.onWrite : (x) => x,
  };

  const { fresh = process.env.ROLLUP_FORCE_RELOAD, ...rollupConfig } = cfg;

  const fileContents = fs.readFileSync(inputFile, 'utf8');
  const contentsHash = revHash(fileContents);
  const configHash = revHash(serializeJson(rollupConfig));
  const fileHash = revHash(inputFile);
  const cacheFile = path.join(CACHE_DIR, `${fileHash}.json`);
  let hasCache = !fresh && fs.existsSync(cacheFile);
  hasCache = hasCache ? fs.existsSync(path.join(pkgRoot, 'dist')) : 0;

  let cacheCollection = null;
  if (hasCache) {
    try {
      // eslint-disable-next-line import/no-dynamic-require, global-require
      cacheCollection = require(cacheFile);
    } catch {
      cacheCollection = {};
    }
  }
  hasCache = hasCache && cacheCollection.configHash === configHash ? 1 : 0;
  hasCache = hasCache && cacheCollection.contentsHash === contentsHash ? 1 : 0;

  /** Some sane defaults */
  const { dependencies = {} } = await tryCatch(
    // eslint-disable-next-line import/no-dynamic-require, global-require
    () => require(path.join(pkgRoot, 'package.json')),
    { testPath, start },
  );
  const externals = Object.keys(dependencies).concat(builtins);

  if (Array.isArray(rollupConfig.external)) {
    externals.push(...rollupConfig.external);
  }

  /** Roll that bundle */
  const bundle = !hasCache
    ? await tryCatch(inputFile, start, () =>
        rollup({
          ...rollupConfig,
          input: inputFile,
          cache: ROLLUP_CACHE[inputFile],
          external: (id) =>
            typeof rollupConfig.external === 'function'
              ? rollupConfig.external(id, externals)
              : externals.includes(id) && id.includes('/'),
        }),
      )
    : {};
  if (bundle.hasError) return bundle.error;

  if (!hasCache) {
    ROLLUP_CACHE[cacheFile] = ROLLUP_CACHE[cacheFile] || bundle.cache;
    fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
    fs.writeFileSync(cacheFile, serializeJson({ configHash, contentsHash }));
  }

  /** Normalize outputs */
  const outputOpts = [cfg.output].flat().filter(Boolean);
  const outputOptions = outputOpts.map((opt) => {
    const opts = { file: 'dist/index.js', ...opt };

    const dest = path.dirname(opts.file);
    const dist = opts.file.includes(
      opts.format,
    ) /*  || outputOpts.length === 1 */
      ? dest
      : path.join(dest, opts.format);

    const outputFile = path.join(pkgRoot, dist, path.basename(opts.file));

    return hooker(allHooks.onFormat, {
      outputOptions: { ...opts, dist, file: outputFile },
      pkgRoot,
      testPath,
      inputFile,
    });
  });

  // If has cache, make passing/skip test report
  // it's just for the sake to not confuse user
  if (hasCache) {
    const ret = await tryCatch(inputFile, start, () =>
      Promise.all(
        outputOptions.map(async (ctx) => {
          const { outputOptions: outOpts } = await ctx;

          return (skipCached ? skip : pass)({
            start,
            end: Date.now(),
            test: {
              path: outOpts.file,
              title: 'Rollup',
            },
          });
        }),
      ),
    );
    if (ret.hasError) return ret.error;

    return ret;
  }

  /** Write output file for each format */
  const res = await tryCatch(inputFile, start, () =>
    Promise.all(
      outputOptions.map(async (ctx) => {
        const { outputOptions: outOpts } = await ctx;

        const opts = await hooker(allHooks.onWrite, {
          outputOptions: outOpts,
          pkgRoot,
          testPath,
          inputFile,
        });

        return (
          bundle
            .write(opts.outputOptions)
            /** If bundled without problems, print the output file filename */
            .then(async () =>
              pass({
                start,
                end: Date.now(),
                test: {
                  path: opts.outputOptions.file,
                  title: 'Rollup',
                },
              }),
            )
            .catch((err) => {
              /** If there is problem bundling, re-throw appending output filename */
              err.outputFile = opts.outputOptions.file;
              throw err;
            })
        );
      }),
    )
      /** Bundling process for each format completed successfuly */
      .then(async (testRes) => {
        await hooker(allHooks.onPkg, {
          jestConfig: config,
          rollupConfig: {
            ...rollupConfig,
            output: outputOptions,
          },
          pkgRoot,
          testPath,
          inputFile,
        });

        return testRes;
      })
      /** Bundling for some of the formats failed */
      .catch((err) =>
        fail({
          start,
          end: new Date(),
          test: {
            path: err.outputFile,
            title: 'Rollup',
            errorMessage: `jest-runner-rollup: ${err.stack || err.message}`,
          },
        }),
      ),
  );
  if (res.hasError) return res.error;

  return res;
};

async function tryCatch(testPath, start, fn) {
  try {
    return await fn();
  } catch (err) {
    return {
      hasError: true,
      error: fail({
        start,
        end: new Date(),
        test: {
          path: testPath,
          title: 'Rollup',
          errorMessage: `jest-runner-rollup: ${err.stack || err.message}`,
        },
      }),
    };
  }
}

async function tryLoadConfig({ testPath, config: jestConfig, start }) {
  const cfg = await tryCatch(testPath, start, () => {
    let result = null;
    result = jestRunnerConfig.search();

    if (
      // if `jest-runner.config.js` not found
      !result ||
      // or found
      (result && // but // the `rollup` property is not an object
        ((result.config.rollup && typeof result.config.rollup !== 'object') ||
          // or, the `rolldown` property is not an object
          (result.config.rolldown &&
            typeof result.config.rolldown !== 'object') ||
          // or, there is not such fields
          !result.config.rollup ||
          !result.config.rolldown))
    ) {
      // then we trying `jest-runner-rollup.config.js`
      result = jestRunnerRollupConfig.search();
    } else {
      // if `jest-runner.config.js` found, we try one of both properties
      result = {
        ...result,
        config: result.config.rollup || result.config.rolldown,
      };
    }

    // if still not found, try regular/original rollup configs,
    // like `rollup.config.js`, `.rolluprc.js`, a `rollup` field in package.json,
    // or a `.rolluprc.json` and etc
    if (!result) {
      result = rollupConfigFile.search();
    }

    return result;
  });

  if (cfg && cfg.hasError) return cfg;

  if (!cfg || (cfg && !cfg.config)) {
    const filepath = cfg && path.relative(cfg.filepath, jestConfig.cwd);
    const message = cfg
      ? `Empty configuration, found at: ${filepath}`
      : 'Cannot find configuration for Rollup.';

    return {
      hasError: true,
      error: fail({
        start,
        end: new Date(),
        test: {
          path: testPath,
          title: 'Rollup',
          errorMessage: `jest-runner-rollup: ${message}`,
        },
      }),
    };
  }

  return cfg.config;
}

function tryExtensions(filepath, config) {
  const { extensions } = getWorkspacesAndExtensions(config.cwd);
  const hasExtension = path.extname(filepath).length > 0;

  if (hasExtension) {
    return filepath;
  }

  const extension = extensions.find((ext) => fs.existsSync(filepath + ext));
  if (!extension) {
    throw new Error(`Cannot find input file: ${filepath}`);
  }

  return filepath + extension;
}

async function hooker(hookFn, options = {}) {
  const hookResult = await hookFn({ ...options });
  const res = { ...hookResult };

  if (res.outputOptions) {
    return { ...options, ...res };
  }

  return { ...options, outputOptions: res };
}

function revHash(input, len = 20) {
  return crypto.createHash('md5').update(input).digest('hex').slice(0, len);
}

function serializeJson(input) {
  return JSON.stringify(input, (key, val) => {
    if (typeof val === 'function') {
      return `[Function ${val.name || 'anonymous'}: ${val.toString()}]`;
    }
    return val;
  });
}