current commit
260224.10 · test_runner: replace native methods with primordials
'use strict';
const {
  ArrayPrototypeJoin,
  ArrayPrototypeMap,
  ArrayPrototypeSlice,
  ArrayPrototypeSort,
  JSONStringify,
  ObjectKeys,
  SafeMap,
  String,
  StringPrototypeReplaceAll,
  StringPrototypeSlice,
} = primordials;
const {
  codes: {
    ERR_INVALID_STATE,
  },
} = require('internal/errors');
const { kEmptyObject } = require('internal/util');
let debug = require('internal/util/debuglog').debuglog('test_runner', (fn) => {
  debug = fn;
});
const {
  validateArray,
  validateFunction,
  validateObject,
  validateString,
} = require('internal/validators');
const { strictEqual } = require('assert');
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
const { dirname } = require('path');
const { createContext, runInContext } = require('vm');
const kMissingSnapshotTip = 'Missing snapshots can be generated by rerunning ' +
  'the command with the --test-update-snapshots flag.';
const defaultSerializers = [
  (value) => { return JSONStringify(value, null, 2); },
];

function defaultResolveSnapshotPath(testPath) {
  if (typeof testPath !== 'string') {
    return testPath;
  }

  return `${testPath}.snapshot`;
}

let resolveSnapshotPathFn = defaultResolveSnapshotPath;
let serializerFns = defaultSerializers;

function setResolveSnapshotPath(fn) {
  validateFunction(fn, 'fn');
  resolveSnapshotPathFn = fn;
}

function setDefaultSnapshotSerializers(serializers) {
  validateFunctionArray(serializers, 'serializers');
  serializerFns = ArrayPrototypeSlice(serializers);
}

class SnapshotFile {
  constructor(snapshotFile) {
    this.snapshotFile = snapshotFile;
    this.snapshots = { __proto__: null };
    this.nameCounts = new SafeMap();
    this.loaded = false;
  }

  getSnapshot(id) {
    if (!(id in this.snapshots)) {
      const err = new ERR_INVALID_STATE(`Snapshot '${id}' not found in ` +
        `'${this.snapshotFile}.' ${kMissingSnapshotTip}`);
      err.snapshot = id;
      err.filename = this.snapshotFile;
      throw err;
    }

    return this.snapshots[id];
  }

  setSnapshot(id, value) {
    this.snapshots[escapeSnapshotKey(id)] = value;
  }

  nextId(name) {
    const count = this.nameCounts.get(name) ?? 1;
    this.nameCounts.set(name, count + 1);
    return `${name} ${count}`;
  }

  readFile() {
    if (this.loaded) {
      debug('skipping read of snapshot file');
      return;
    }

    try {
      const source = readFileSync(this.snapshotFile, 'utf8');
      const context = { __proto__: null, exports: { __proto__: null } };

      createContext(context);
      runInContext(source, context);

      if (context.exports === null || typeof context.exports !== 'object') {
        throw new ERR_INVALID_STATE(
          `Malformed snapshot file '${this.snapshotFile}'.`,
        );
      }

      for (const key in context.exports) {
        this.snapshots[key] = templateEscape(context.exports[key]);
      }
      this.loaded = true;
    } catch (err) {
      throwReadError(err, this.snapshotFile);
    }
  }

  writeFile() {
    try {
      const keys = ArrayPrototypeSort(ObjectKeys(this.snapshots));
      const snapshotStrings = ArrayPrototypeMap(keys, (key) => {
        return `exports[\`${key}\`] = \`${this.snapshots[key]}\`;\n`;
      });
      const output = ArrayPrototypeJoin(snapshotStrings, '\n');
      mkdirSync(dirname(this.snapshotFile), { __proto__: null, recursive: true });
      writeFileSync(this.snapshotFile, output, 'utf8');
    } catch (err) {
      throwWriteError(err, this.snapshotFile);
    }
  }
}

class SnapshotManager {
  constructor(updateSnapshots) {
    // A manager instance will only read or write snapshot files based on the
    // updateSnapshots argument.
    this.updateSnapshots = updateSnapshots;
    this.cache = new SafeMap();
  }

  resolveSnapshotFile(entryFile) {
    let snapshotFile = this.cache.get(entryFile);

    if (snapshotFile === undefined) {
      const resolved = resolveSnapshotPathFn(entryFile);

      if (typeof resolved !== 'string') {
        const err = new ERR_INVALID_STATE('Invalid snapshot filename.');
        err.filename = resolved;
        throw err;
      }

      snapshotFile = new SnapshotFile(resolved);
      snapshotFile.loaded = this.updateSnapshots;
      this.cache.set(entryFile, snapshotFile);
    }

    return snapshotFile;
  }

  serialize(input, serializers = serializerFns) {
    try {
      const value = serializeValue(input, serializers);
      return `\n${templateEscape(value)}\n`;
    } catch (err) {
      throwSerializationError(input, err);
    }
  }

  serializeWithoutEscape(input, serializers = serializerFns) {
    try {
      return serializeValue(input, serializers);
    } catch (err) {
      throwSerializationError(input, err);
    }
  }

  writeSnapshotFiles() {
    if (!this.updateSnapshots) {
      debug('skipping write of snapshot files');
      return;
    }

    this.cache.forEach((snapshotFile) => {
      snapshotFile.writeFile();
    });
  }

  createAssert() {
    const manager = this;

    return function snapshotAssertion(actual, options = kEmptyObject) {
      validateObject(options, 'options');
      const {
        serializers = serializerFns,
      } = options;
      validateFunctionArray(serializers, 'options.serializers');
      const { filePath, fullName } = this;
      const snapshotFile = manager.resolveSnapshotFile(filePath);
      const value = manager.serialize(actual, serializers);
      const id = snapshotFile.nextId(fullName);

      if (manager.updateSnapshots) {
        snapshotFile.setSnapshot(id, value);
      } else {
        snapshotFile.readFile();
        strictEqual(value, snapshotFile.getSnapshot(id));
      }
    };
  }

  createFileAssert() {
    const manager = this;

    return function fileSnapshotAssertion(actual, path, options = kEmptyObject) {
      validateString(path, 'path');
      validateObject(options, 'options');
      const {
        serializers = serializerFns,
      } = options;
      validateFunctionArray(serializers, 'options.serializers');
      const value = manager.serializeWithoutEscape(actual, serializers);

      if (manager.updateSnapshots) {
        try {
          mkdirSync(dirname(path), { __proto__: null, recursive: true });
          writeFileSync(path, value, 'utf8');
        } catch (err) {
          throwWriteError(err, path);
        }
      } else {
        let expected;

        try {
          expected = readFileSync(path, 'utf8');
        } catch (err) {
          throwReadError(err, path);
        }

        strictEqual(value, expected);
      }
    };
  }
}

function throwReadError(err, filename) {
  let msg = `Cannot read snapshot file '${filename}.'`;

  if (err?.code === 'ENOENT') {
    msg += ` ${kMissingSnapshotTip}`;
  }

  const error = new ERR_INVALID_STATE(msg);
  error.cause = err;
  error.filename = filename;
  throw error;
}

function throwWriteError(err, filename) {
  const msg = `Cannot write snapshot file '${filename}.'`;
  const error = new ERR_INVALID_STATE(msg);
  error.cause = err;
  error.filename = filename;
  throw error;
}

function throwSerializationError(input, err) {
  const error = new ERR_INVALID_STATE(
    'The provided serializers did not generate a string.',
  );
  error.input = input;
  error.cause = err;
  throw error;
}

function serializeValue(value, serializers) {
  let v = value;

  for (let i = 0; i < serializers.length; ++i) {
    const fn = serializers[i];
    v = fn(v);
  }

  return v;
}

function validateFunctionArray(fns, name) {
  validateArray(fns, name);
  for (let i = 0; i < fns.length; ++i) {
    validateFunction(fns[i], `${name}[${i}]`);
  }
}

function escapeSnapshotKey(str) {
  let result = StringPrototypeSlice(JSONStringify(str, null, 2), 1, -1);
  result = StringPrototypeReplaceAll(result, '`', '\\`');
  result = StringPrototypeReplaceAll(result, '${', '\\${');
  return result;
}

function templateEscape(str) {
  let result = String(str);
  result = StringPrototypeReplaceAll(result, '\\', '\\\\');
  result = StringPrototypeReplaceAll(result, '`', '\\`');
  result = StringPrototypeReplaceAll(result, '${', '\\${');
  return result;
}

module.exports = {
  SnapshotManager,
  defaultResolveSnapshotPath, // Exported for testing only.
  defaultSerializers,         // Exported for testing only.
  setDefaultSnapshotSerializers,
  setResolveSnapshotPath,
};