assert: add assert.Snapshot

PR-URL: https://github.com/nodejs/node/pull/44095
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
This commit is contained in:
Moshe Atlow 2022-08-11 16:07:52 +03:00 committed by GitHub
parent 818bd6cb4d
commit 8f9d1ab5ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 380 additions and 0 deletions

View File

@ -2006,6 +2006,32 @@ argument, then `error` is assumed to be omitted and the string will be used for
example in [`assert.throws()`][] carefully if using a string as the second
argument gets considered.
## `assert.snapshot(value, name)`
<!-- YAML
added: REPLACEME
-->
> Stability: 1 - Experimental
* `value` {any} the value to snapshot
* `name` {string} the name of snapshot.
* Returns: {Promise}
reads a snapshot from a file, and compares `value` to the snapshot.
`value` is serialized with [`util.inspect()`][]
If the value is not strictly equal to the snapshot,
`assert.snapshot()` will return a rejected `Promise`
with an [`AssertionError`][].
If the snapshot file does not exist, the snapshot is written.
In case it is needed to force a snapshot update,
use [`--update-assert-snapshot`][];
By default, a snapshot is read and written to a file,
using the same name as the main entrypoint with `.snapshot` as the extension.
## `assert.strictEqual(actual, expected[, message])`
<!-- YAML
@ -2442,6 +2468,7 @@ argument.
[Object wrappers]: https://developer.mozilla.org/en-US/docs/Glossary/Primitive#Primitive_wrapper_objects_in_JavaScript
[Object.prototype.toString()]: https://tc39.github.io/ecma262/#sec-object.prototype.tostring
[`!=` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality
[`--update-assert-snapshot`]: cli.md#--update-assert-snapshot
[`===` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality
[`==` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality
[`AssertionError`]: #class-assertassertionerror
@ -2473,5 +2500,6 @@ argument.
[`process.on('exit')`]: process.md#event-exit
[`tracker.calls()`]: #trackercallsfn-exact
[`tracker.verify()`]: #trackerverify
[`util.inspect()`]: util.md#utilinspectobject-options
[enumerable "own" properties]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Enumerability_and_ownership_of_properties
[prototype-spec]: https://tc39.github.io/ecma262/#sec-ordinary-object-internal-methods-and-internal-slots

View File

@ -1488,6 +1488,14 @@ occurs. One of the following modes can be chosen:
If a rejection happens during the command line entry point's ES module static
loading phase, it will always raise it as an uncaught exception.
### `--update-assert-snapshot`
<!-- YAML
added: REPLACEME
-->
Force updating snapshot files for [`assert.snapshot()`][]
### `--use-bundled-ca`, `--use-openssl-ca`
<!-- YAML
@ -1849,6 +1857,7 @@ Node.js options that are allowed are:
* `--trace-warnings`
* `--track-heap-objects`
* `--unhandled-rejections`
* `--update-assert-snapshot`
* `--use-bundled-ca`
* `--use-largepages`
* `--use-openssl-ca`
@ -2224,6 +2233,7 @@ done
[`NO_COLOR`]: https://no-color.org
[`SlowBuffer`]: buffer.md#class-slowbuffer
[`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328
[`assert.snapshot()`]: assert.md#assertsnapshotvalue-name
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
[`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder
[`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options

View File

@ -705,6 +705,13 @@ A special type of error that can be triggered whenever Node.js detects an
exceptional logic violation that should never occur. These are raised typically
by the `node:assert` module.
<a id="ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED"></a>
### `ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED`
An attempt was made to use `assert.snapshot()` in an environment that
does not support snapshots, such as the REPL, or when using `node --eval`.
<a id="ERR_ASYNC_CALLBACK"></a>
### `ERR_ASYNC_CALLBACK`

View File

@ -1052,6 +1052,9 @@ assert.doesNotMatch = function doesNotMatch(string, regexp, message) {
assert.CallTracker = CallTracker;
const snapshot = require('internal/assert/snapshot');
assert.snapshot = snapshot;
/**
* Expose a strict only variant of assert.
* @param {...any} args

View File

@ -0,0 +1,129 @@
'use strict';
const {
ArrayPrototypeJoin,
ArrayPrototypeMap,
ArrayPrototypeSlice,
RegExp,
SafeMap,
SafeSet,
StringPrototypeSplit,
StringPrototypeReplace,
Symbol,
} = primordials;
const { codes: { ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED } } = require('internal/errors');
const AssertionError = require('internal/assert/assertion_error');
const { inspect } = require('internal/util/inspect');
const { getOptionValue } = require('internal/options');
const { validateString } = require('internal/validators');
const { once } = require('events');
const { createReadStream, createWriteStream } = require('fs');
const path = require('path');
const assert = require('assert');
const kUpdateSnapshot = getOptionValue('--update-assert-snapshot');
const kInitialSnapshot = Symbol('kInitialSnapshot');
const kDefaultDelimiter = '\n#*#*#*#*#*#*#*#*#*#*#*#\n';
const kDefaultDelimiterRegex = new RegExp(kDefaultDelimiter.replaceAll('*', '\\*').replaceAll('\n', '\r?\n'), 'g');
const kKeyDelimiter = /:\r?\n/g;
function getSnapshotPath() {
if (process.mainModule) {
const { dir, name } = path.parse(process.mainModule.filename);
return path.join(dir, `${name}.snapshot`);
}
if (!process.argv[1]) {
throw new ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED();
}
const { dir, name } = path.parse(process.argv[1]);
return path.join(dir, `${name}.snapshot`);
}
function getSource() {
return createReadStream(getSnapshotPath(), { encoding: 'utf8' });
}
let _target;
function getTarget() {
_target ??= createWriteStream(getSnapshotPath(), { encoding: 'utf8' });
return _target;
}
function serializeName(name) {
validateString(name, 'name');
return StringPrototypeReplace(`${name}`, kKeyDelimiter, '_');
}
let writtenNames;
let snapshotValue;
let counter = 0;
async function writeSnapshot({ name, value }) {
const target = getTarget();
if (counter > 1) {
target.write(kDefaultDelimiter);
}
writtenNames = writtenNames || new SafeSet();
if (writtenNames.has(name)) {
throw new AssertionError({ message: `Snapshot "${name}" already used` });
}
writtenNames.add(name);
const drained = target.write(`${name}:\n${value}`);
await drained || once(target, 'drain');
}
async function getSnapshot() {
if (snapshotValue !== undefined) {
return snapshotValue;
}
if (kUpdateSnapshot) {
snapshotValue = kInitialSnapshot;
return kInitialSnapshot;
}
try {
const source = getSource();
let data = '';
for await (const line of source) {
data += line;
}
snapshotValue = new SafeMap(
ArrayPrototypeMap(
StringPrototypeSplit(data, kDefaultDelimiterRegex),
(item) => {
const arr = StringPrototypeSplit(item, kKeyDelimiter);
return [
arr[0],
ArrayPrototypeJoin(ArrayPrototypeSlice(arr, 1), ':\n'),
];
}
));
} catch (e) {
if (e.code === 'ENOENT') {
snapshotValue = kInitialSnapshot;
} else {
throw e;
}
}
return snapshotValue;
}
async function snapshot(input, name) {
const snapshot = await getSnapshot();
counter = counter + 1;
name = serializeName(name);
const value = inspect(input);
if (snapshot === kInitialSnapshot) {
await writeSnapshot({ name, value });
} else if (snapshot.has(name)) {
const expected = snapshot.get(name);
// eslint-disable-next-line no-restricted-syntax
assert.strictEqual(value, expected);
} else {
throw new AssertionError({ message: `Snapshot "${name}" does not exist`, actual: inspect(snapshot) });
}
}
module.exports = snapshot;

View File

@ -936,6 +936,8 @@ module.exports = {
E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError);
E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError);
E('ERR_ASSERTION', '%s', Error);
E('ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED',
'Snapshot is not supported in this context ', TypeError);
E('ERR_ASYNC_CALLBACK', '%s must be a function', TypeError);
E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', TypeError);
E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError);

View File

@ -622,6 +622,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::force_repl);
AddAlias("-i", "--interactive");
AddOption("--update-assert-snapshot",
"update assert snapshot files",
&EnvironmentOptions::update_assert_snapshot,
kAllowedInEnvironment);
AddOption("--napi-modules", "", NoOp{}, kAllowedInEnvironment);
AddOption("--tls-keylog",

View File

@ -136,6 +136,7 @@ class EnvironmentOptions : public Options {
bool preserve_symlinks = false;
bool preserve_symlinks_main = false;
bool prof_process = false;
bool update_assert_snapshot = false;
#if HAVE_INSPECTOR
std::string cpu_prof_dir;
static const uint64_t kDefaultCpuProfInterval = 1000;

View File

@ -0,0 +1,3 @@
import assert from 'node:assert';
await assert.snapshot("test", "name");

View File

@ -0,0 +1,4 @@
import assert from 'node:assert';
await assert.snapshot("test", "name");
await assert.snapshot("test", "another name");

View File

@ -0,0 +1,4 @@
import assert from 'node:assert';
await assert.snapshot("test", "another name");
await assert.snapshot("test", "non existing");

View File

@ -0,0 +1,5 @@
another name:
'test'
#*#*#*#*#*#*#*#*#*#*#*#
name:
'test'

View File

@ -0,0 +1,11 @@
import assert from 'node:assert';
function random() {
return `Random Value: ${Math.random()}`;
}
function transform(value) {
return value.replaceAll(/Random Value: \d+\.+\d+/g, 'Random Value: *');
}
await assert.snapshot(transform(random()), 'random1');
await assert.snapshot(transform(random()), 'random2');

View File

@ -0,0 +1,5 @@
random1:
'Random Value: *'
#*#*#*#*#*#*#*#*#*#*#*#
random2:
'Random Value: *'

View File

@ -0,0 +1,11 @@
import assert from 'node:assert';
function fn() {
this.should.be.a.fn();
return false;
}
await assert.snapshot(fn, 'function');
await assert.snapshot({ foo: "bar" }, 'object');
await assert.snapshot(new Set([1, 2, 3]), 'set');
await assert.snapshot(new Map([['one', 1], ['two', 2]]), 'map');

View File

@ -0,0 +1,11 @@
function:
[Function: fn]
#*#*#*#*#*#*#*#*#*#*#*#
object:
{ foo: 'bar' }
#*#*#*#*#*#*#*#*#*#*#*#
set:
Set(3) { 1, 2, 3 }
#*#*#*#*#*#*#*#*#*#*#*#
map:
Map(2) { 'one' => 1, 'two' => 2 }

View File

@ -0,0 +1,3 @@
import assert from 'node:assert';
await assert.snapshot("test", "snapshot");

View File

@ -0,0 +1,3 @@
import assert from 'node:assert';
await assert.snapshot("changed", "snapshot");

View File

@ -0,0 +1,2 @@
snapshot:
'original'

View File

@ -0,0 +1,133 @@
import { spawnPromisified } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import tmpdir from '../common/tmpdir.js';
import assert from 'node:assert';
import path from 'node:path';
import { execPath } from 'node:process';
import { writeFile, readFile, unlink } from 'node:fs/promises';
import { describe, it } from 'node:test';
tmpdir.refresh();
function getSnapshotPath(filename) {
const { name, dir } = path.parse(filename);
return path.join(tmpdir.path, dir, `${name}.snapshot`);
}
async function spawnTmpfile(content = '', filename, extraFlags = []) {
const header = filename.endsWith('.mjs') ?
'import assert from \'node:assert\';' :
'const assert = require(\'node:assert\');';
await writeFile(path.join(tmpdir.path, filename), `${header}\n${content}`);
const { stdout, stderr, code } = await spawnPromisified(
execPath,
['--no-warnings', ...extraFlags, filename],
{ cwd: tmpdir.path });
const snapshotPath = getSnapshotPath(filename);
const snapshot = await readFile(snapshotPath, 'utf8').catch((err) => err);
return { stdout, stderr, code, snapshot, snapshotPath, filename };
}
async function spawnFixture(filename) {
const { dir, base, name } = path.parse(filename);
const { stdout, stderr, code } = await spawnPromisified(execPath, ['--no-warnings', base], { cwd: dir });
const snapshotPath = path.join(dir, `${name}.snapshot`);
const snapshot = await readFile(snapshotPath, 'utf8').catch((err) => err);
return { stdout, stderr, code, snapshot, snapshotPath };
}
describe('assert.snapshot', { concurrency: true }, () => {
it('should write snapshot', async () => {
const { stderr, code, snapshot, snapshotPath } = await spawnFixture(fixtures.path('assert-snapshot/basic.mjs'));
await unlink(snapshotPath);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.match(snapshot, /^name:\r?\n'test'$/);
});
it('should write multiple snapshots', async () => {
const { stderr, code, snapshot, snapshotPath } = await spawnFixture(fixtures.path('assert-snapshot/multiple.mjs'));
await unlink(snapshotPath);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.match(snapshot, /^name:\n'test'\r?\n#\*#\*#\*#\*#\*#\*#\*#\*#\*#\*#\*#\r?\nanother name:\r?\n'test'$/);
});
it('should succeed running multiple times', async () => {
let result = await spawnFixture(fixtures.path('assert-snapshot/single.mjs'), false);
assert.strictEqual(result.stderr, '');
assert.strictEqual(result.code, 0);
assert.match(result.snapshot, /^snapshot:\r?\n'test'$/);
result = await spawnFixture(fixtures.path('assert-snapshot/single.mjs'));
await unlink(result.snapshotPath);
assert.strictEqual(result.stderr, '');
assert.strictEqual(result.code, 0);
assert.match(result.snapshot, /^snapshot:\r?\n'test'$/);
});
it('should fail when name is not provided', async () => {
for (const name of [1, undefined, null, {}, function() {}]) {
await assert.rejects(() => assert.snapshot('', name), {
code: 'ERR_INVALID_ARG_TYPE',
name: 'TypeError',
message: /^The "name" argument must be of type string/
});
}
});
it('should fail when value does not match snapshot', async () => {
const { code, stderr, snapshot } = await spawnFixture(fixtures.path('assert-snapshot/value-changed.mjs'));
assert.match(stderr, /AssertionError \[ERR_ASSERTION\]/);
assert.strictEqual(code, 1);
assert.match(snapshot, /^snapshot:\r?\n'original'$/);
});
it('should fail when snapshot does not contain a named snapshot', async () => {
const { code, stderr, snapshot } = await spawnFixture(fixtures.path('assert-snapshot/non-existing-name.mjs'));
assert.match(stderr, /AssertionError \[ERR_ASSERTION\]/);
assert.match(stderr, /Snapshot "non existing" does not exist/);
assert.strictEqual(code, 1);
assert.match(snapshot, /^another name:\r?\n'test'\r?\n#\*#\*#\*#\*#\*#\*#\*#\*#\*#\*#\*#\r?\nname:\r?\n'test'$/);
});
it('should snapshot a random replaced value', async () => {
const originalSnapshot = await readFile(fixtures.path('assert-snapshot/random.snapshot'), 'utf8');
const { stderr, code, snapshot } = await spawnFixture(fixtures.path('assert-snapshot/random.mjs'));
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(snapshot, originalSnapshot);
});
it('should serialize values', async () => {
const originalSnapshot = await readFile(fixtures.path('assert-snapshot/serialize.snapshot'), 'utf8');
const { stderr, code, snapshot } = await spawnFixture(fixtures.path('assert-snapshot/serialize.mjs'));
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.strictEqual(snapshot, originalSnapshot);
});
it('should override snapshot when passing --update-assert-snapshot', async () => {
const filename = 'updated.mjs';
await writeFile(getSnapshotPath(filename), 'snapshot:\n\'test\'');
const { stderr, code, snapshot } = await spawnTmpfile('await assert.snapshot(\'changed\', \'snapshot\');',
filename, ['--update-assert-snapshot']);
assert.strictEqual(stderr, '');
assert.strictEqual(code, 0);
assert.match(snapshot, /^snapshot:\r?\n'changed'$/);
});
it('snapshot file should have the name of the module - esm', async () => {
const filename = 'name.mjs';
const { snapshotPath } = await spawnTmpfile('await assert.snapshot("test");', filename);
assert.match(snapshotPath, /name\.snapshot$/);
});
it('snapshot file should have the name of the module - common js', async () => {
const filename = 'name.js';
const { snapshotPath } = await spawnTmpfile('assert.snapshot("test").then(() => process.exit());', filename);
assert.match(snapshotPath, /name\.snapshot$/);
});
});