Skip to content

Commit

Permalink
assert: add assert.Snapshot
Browse files Browse the repository at this point in the history
PR-URL: nodejs#44095
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
  • Loading branch information
MoLow authored and Fyko committed Sep 15, 2022
1 parent dc660d7 commit 300cfc4
Show file tree
Hide file tree
Showing 20 changed files with 380 additions and 0 deletions.
28 changes: 28 additions & 0 deletions doc/api/assert.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions lib/assert.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions lib/internal/assert/snapshot.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/assert-snapshot/basic.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import assert from 'node:assert';

await assert.snapshot("test", "name");
4 changes: 4 additions & 0 deletions test/fixtures/assert-snapshot/multiple.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import assert from 'node:assert';

await assert.snapshot("test", "name");
await assert.snapshot("test", "another name");
4 changes: 4 additions & 0 deletions test/fixtures/assert-snapshot/non-existing-name.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import assert from 'node:assert';

await assert.snapshot("test", "another name");
await assert.snapshot("test", "non existing");
5 changes: 5 additions & 0 deletions test/fixtures/assert-snapshot/non-existing-name.snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
another name:
'test'
#*#*#*#*#*#*#*#*#*#*#*#
name:
'test'
11 changes: 11 additions & 0 deletions test/fixtures/assert-snapshot/random.mjs
Original file line number Diff line number Diff line change
@@ -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');
5 changes: 5 additions & 0 deletions test/fixtures/assert-snapshot/random.snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
random1:
'Random Value: *'
#*#*#*#*#*#*#*#*#*#*#*#
random2:
'Random Value: *'
11 changes: 11 additions & 0 deletions test/fixtures/assert-snapshot/serialize.mjs
Original file line number Diff line number Diff line change
@@ -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');
11 changes: 11 additions & 0 deletions test/fixtures/assert-snapshot/serialize.snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
function:
[Function: fn]
#*#*#*#*#*#*#*#*#*#*#*#
object:
{ foo: 'bar' }
#*#*#*#*#*#*#*#*#*#*#*#
set:
Set(3) { 1, 2, 3 }
#*#*#*#*#*#*#*#*#*#*#*#
map:
Map(2) { 'one' => 1, 'two' => 2 }
3 changes: 3 additions & 0 deletions test/fixtures/assert-snapshot/single.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import assert from 'node:assert';

await assert.snapshot("test", "snapshot");
3 changes: 3 additions & 0 deletions test/fixtures/assert-snapshot/value-changed.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import assert from 'node:assert';

await assert.snapshot("changed", "snapshot");
2 changes: 2 additions & 0 deletions test/fixtures/assert-snapshot/value-changed.snapshot
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
snapshot:
'original'
Loading

0 comments on commit 300cfc4

Please sign in to comment.