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 example in [`assert.throws()`][] carefully if using a string as the second
argument gets considered. 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])` ## `assert.strictEqual(actual, expected[, message])`
<!-- YAML <!-- YAML
@ -2442,6 +2468,7 @@ argument.
[Object wrappers]: https://developer.mozilla.org/en-US/docs/Glossary/Primitive#Primitive_wrapper_objects_in_JavaScript [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 [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 [`!=` 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/Strict_equality
[`==` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality [`==` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality
[`AssertionError`]: #class-assertassertionerror [`AssertionError`]: #class-assertassertionerror
@ -2473,5 +2500,6 @@ argument.
[`process.on('exit')`]: process.md#event-exit [`process.on('exit')`]: process.md#event-exit
[`tracker.calls()`]: #trackercallsfn-exact [`tracker.calls()`]: #trackercallsfn-exact
[`tracker.verify()`]: #trackerverify [`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 [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 [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 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. 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` ### `--use-bundled-ca`, `--use-openssl-ca`
<!-- YAML <!-- YAML
@ -1849,6 +1857,7 @@ Node.js options that are allowed are:
* `--trace-warnings` * `--trace-warnings`
* `--track-heap-objects` * `--track-heap-objects`
* `--unhandled-rejections` * `--unhandled-rejections`
* `--update-assert-snapshot`
* `--use-bundled-ca` * `--use-bundled-ca`
* `--use-largepages` * `--use-largepages`
* `--use-openssl-ca` * `--use-openssl-ca`
@ -2224,6 +2233,7 @@ done
[`NO_COLOR`]: https://no-color.org [`NO_COLOR`]: https://no-color.org
[`SlowBuffer`]: buffer.md#class-slowbuffer [`SlowBuffer`]: buffer.md#class-slowbuffer
[`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328 [`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.lookup()`]: dns.md#dnslookuphostname-options-callback
[`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder [`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder
[`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options [`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 exceptional logic violation that should never occur. These are raised typically
by the `node:assert` module. 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> <a id="ERR_ASYNC_CALLBACK"></a>
### `ERR_ASYNC_CALLBACK` ### `ERR_ASYNC_CALLBACK`

View File

@ -1052,6 +1052,9 @@ assert.doesNotMatch = function doesNotMatch(string, regexp, message) {
assert.CallTracker = CallTracker; assert.CallTracker = CallTracker;
const snapshot = require('internal/assert/snapshot');
assert.snapshot = snapshot;
/** /**
* Expose a strict only variant of assert. * Expose a strict only variant of assert.
* @param {...any} args * @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_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError);
E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError); E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError);
E('ERR_ASSERTION', '%s', Error); 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_CALLBACK', '%s must be a function', TypeError);
E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', 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); E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError);

View File

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

View File

@ -136,6 +136,7 @@ class EnvironmentOptions : public Options {
bool preserve_symlinks = false; bool preserve_symlinks = false;
bool preserve_symlinks_main = false; bool preserve_symlinks_main = false;
bool prof_process = false; bool prof_process = false;
bool update_assert_snapshot = false;
#if HAVE_INSPECTOR #if HAVE_INSPECTOR
std::string cpu_prof_dir; std::string cpu_prof_dir;
static const uint64_t kDefaultCpuProfInterval = 1000; 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$/);
});
});