Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

assert: add snapshot assertion #44095

Merged
merged 12 commits into from
Aug 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this to check if we're in the REPL? wouldn't it be better to just check if there is no process.mainModule?.filename?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

esm does not have process.mainModule either, so argv[1] is used instead

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't read the code, but reminder that process.argv[1] == false when doing stdin eval and --eval, not just in REPL.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is documented to not be supported at this stage.
In a further stage we can support the source and target options that were in the original commit

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 = '';
MoLow marked this conversation as resolved.
Show resolved Hide resolved
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's kind of a bummer that this does string comparison and not deepStrictEqual or something similar with better dx here but I guess since it's util.inspect it's customizable. I think this can be revisited later/in a future PR.

} 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