Skip to content

Commit fb5884a

Browse files
MoLowdanielleadams
authored andcommitted
assert: add assert.Snapshot
PR-URL: #44095 Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
1 parent 2a75bce commit fb5884a

20 files changed

+380
-0
lines changed

doc/api/assert.md

+28
Original file line numberDiff line numberDiff line change
@@ -2006,6 +2006,32 @@ argument, then `error` is assumed to be omitted and the string will be used for
20062006
example in [`assert.throws()`][] carefully if using a string as the second
20072007
argument gets considered.
20082008

2009+
## `assert.snapshot(value, name)`
2010+
2011+
<!-- YAML
2012+
added: REPLACEME
2013+
-->
2014+
2015+
> Stability: 1 - Experimental
2016+
2017+
* `value` {any} the value to snapshot
2018+
* `name` {string} the name of snapshot.
2019+
* Returns: {Promise}
2020+
2021+
reads a snapshot from a file, and compares `value` to the snapshot.
2022+
`value` is serialized with [`util.inspect()`][]
2023+
If the value is not strictly equal to the snapshot,
2024+
`assert.snapshot()` will return a rejected `Promise`
2025+
with an [`AssertionError`][].
2026+
2027+
If the snapshot file does not exist, the snapshot is written.
2028+
2029+
In case it is needed to force a snapshot update,
2030+
use [`--update-assert-snapshot`][];
2031+
2032+
By default, a snapshot is read and written to a file,
2033+
using the same name as the main entrypoint with `.snapshot` as the extension.
2034+
20092035
## `assert.strictEqual(actual, expected[, message])`
20102036

20112037
<!-- YAML
@@ -2442,6 +2468,7 @@ argument.
24422468
[Object wrappers]: https://developer.mozilla.org/en-US/docs/Glossary/Primitive#Primitive_wrapper_objects_in_JavaScript
24432469
[Object.prototype.toString()]: https://tc39.github.io/ecma262/#sec-object.prototype.tostring
24442470
[`!=` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Inequality
2471+
[`--update-assert-snapshot`]: cli.md#--update-assert-snapshot
24452472
[`===` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality
24462473
[`==` operator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality
24472474
[`AssertionError`]: #class-assertassertionerror
@@ -2473,5 +2500,6 @@ argument.
24732500
[`process.on('exit')`]: process.md#event-exit
24742501
[`tracker.calls()`]: #trackercallsfn-exact
24752502
[`tracker.verify()`]: #trackerverify
2503+
[`util.inspect()`]: util.md#utilinspectobject-options
24762504
[enumerable "own" properties]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Enumerability_and_ownership_of_properties
24772505
[prototype-spec]: https://tc39.github.io/ecma262/#sec-ordinary-object-internal-methods-and-internal-slots

doc/api/cli.md

+10
Original file line numberDiff line numberDiff line change
@@ -1461,6 +1461,14 @@ occurs. One of the following modes can be chosen:
14611461
If a rejection happens during the command line entry point's ES module static
14621462
loading phase, it will always raise it as an uncaught exception.
14631463

1464+
### `--update-assert-snapshot`
1465+
1466+
<!-- YAML
1467+
added: REPLACEME
1468+
-->
1469+
1470+
Force updating snapshot files for [`assert.snapshot()`][]
1471+
14641472
### `--use-bundled-ca`, `--use-openssl-ca`
14651473

14661474
<!-- YAML
@@ -1819,6 +1827,7 @@ Node.js options that are allowed are:
18191827
* `--trace-warnings`
18201828
* `--track-heap-objects`
18211829
* `--unhandled-rejections`
1830+
* `--update-assert-snapshot`
18221831
* `--use-bundled-ca`
18231832
* `--use-largepages`
18241833
* `--use-openssl-ca`
@@ -2189,6 +2198,7 @@ done
21892198
[`NO_COLOR`]: https://no-color.org
21902199
[`SlowBuffer`]: buffer.md#class-slowbuffer
21912200
[`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328
2201+
[`assert.snapshot()`]: assert.md#assertsnapshotvalue-name
21922202
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
21932203
[`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder
21942204
[`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options

doc/api/errors.md

+7
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,13 @@ A special type of error that can be triggered whenever Node.js detects an
705705
exceptional logic violation that should never occur. These are raised typically
706706
by the `node:assert` module.
707707

708+
<a id="ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED"></a>
709+
710+
### `ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED`
711+
712+
An attempt was made to use `assert.snapshot()` in an environment that
713+
does not support snapshots, such as the REPL, or when using `node --eval`.
714+
708715
<a id="ERR_ASYNC_CALLBACK"></a>
709716

710717
### `ERR_ASYNC_CALLBACK`

lib/assert.js

+3
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,9 @@ assert.doesNotMatch = function doesNotMatch(string, regexp, message) {
10521052

10531053
assert.CallTracker = CallTracker;
10541054

1055+
const snapshot = require('internal/assert/snapshot');
1056+
assert.snapshot = snapshot;
1057+
10551058
/**
10561059
* Expose a strict only variant of assert.
10571060
* @param {...any} args

lib/internal/assert/snapshot.js

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
'use strict';
2+
3+
const {
4+
ArrayPrototypeJoin,
5+
ArrayPrototypeMap,
6+
ArrayPrototypeSlice,
7+
RegExp,
8+
SafeMap,
9+
SafeSet,
10+
StringPrototypeSplit,
11+
StringPrototypeReplace,
12+
Symbol,
13+
} = primordials;
14+
15+
const { codes: { ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED } } = require('internal/errors');
16+
const AssertionError = require('internal/assert/assertion_error');
17+
const { inspect } = require('internal/util/inspect');
18+
const { getOptionValue } = require('internal/options');
19+
const { validateString } = require('internal/validators');
20+
const { once } = require('events');
21+
const { createReadStream, createWriteStream } = require('fs');
22+
const path = require('path');
23+
const assert = require('assert');
24+
25+
const kUpdateSnapshot = getOptionValue('--update-assert-snapshot');
26+
const kInitialSnapshot = Symbol('kInitialSnapshot');
27+
const kDefaultDelimiter = '\n#*#*#*#*#*#*#*#*#*#*#*#\n';
28+
const kDefaultDelimiterRegex = new RegExp(kDefaultDelimiter.replaceAll('*', '\\*').replaceAll('\n', '\r?\n'), 'g');
29+
const kKeyDelimiter = /:\r?\n/g;
30+
31+
function getSnapshotPath() {
32+
if (process.mainModule) {
33+
const { dir, name } = path.parse(process.mainModule.filename);
34+
return path.join(dir, `${name}.snapshot`);
35+
}
36+
if (!process.argv[1]) {
37+
throw new ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED();
38+
}
39+
const { dir, name } = path.parse(process.argv[1]);
40+
return path.join(dir, `${name}.snapshot`);
41+
}
42+
43+
function getSource() {
44+
return createReadStream(getSnapshotPath(), { encoding: 'utf8' });
45+
}
46+
47+
let _target;
48+
function getTarget() {
49+
_target ??= createWriteStream(getSnapshotPath(), { encoding: 'utf8' });
50+
return _target;
51+
}
52+
53+
function serializeName(name) {
54+
validateString(name, 'name');
55+
return StringPrototypeReplace(`${name}`, kKeyDelimiter, '_');
56+
}
57+
58+
let writtenNames;
59+
let snapshotValue;
60+
let counter = 0;
61+
62+
async function writeSnapshot({ name, value }) {
63+
const target = getTarget();
64+
if (counter > 1) {
65+
target.write(kDefaultDelimiter);
66+
}
67+
writtenNames = writtenNames || new SafeSet();
68+
if (writtenNames.has(name)) {
69+
throw new AssertionError({ message: `Snapshot "${name}" already used` });
70+
}
71+
writtenNames.add(name);
72+
const drained = target.write(`${name}:\n${value}`);
73+
await drained || once(target, 'drain');
74+
}
75+
76+
async function getSnapshot() {
77+
if (snapshotValue !== undefined) {
78+
return snapshotValue;
79+
}
80+
if (kUpdateSnapshot) {
81+
snapshotValue = kInitialSnapshot;
82+
return kInitialSnapshot;
83+
}
84+
try {
85+
const source = getSource();
86+
let data = '';
87+
for await (const line of source) {
88+
data += line;
89+
}
90+
snapshotValue = new SafeMap(
91+
ArrayPrototypeMap(
92+
StringPrototypeSplit(data, kDefaultDelimiterRegex),
93+
(item) => {
94+
const arr = StringPrototypeSplit(item, kKeyDelimiter);
95+
return [
96+
arr[0],
97+
ArrayPrototypeJoin(ArrayPrototypeSlice(arr, 1), ':\n'),
98+
];
99+
}
100+
));
101+
} catch (e) {
102+
if (e.code === 'ENOENT') {
103+
snapshotValue = kInitialSnapshot;
104+
} else {
105+
throw e;
106+
}
107+
}
108+
return snapshotValue;
109+
}
110+
111+
112+
async function snapshot(input, name) {
113+
const snapshot = await getSnapshot();
114+
counter = counter + 1;
115+
name = serializeName(name);
116+
117+
const value = inspect(input);
118+
if (snapshot === kInitialSnapshot) {
119+
await writeSnapshot({ name, value });
120+
} else if (snapshot.has(name)) {
121+
const expected = snapshot.get(name);
122+
// eslint-disable-next-line no-restricted-syntax
123+
assert.strictEqual(value, expected);
124+
} else {
125+
throw new AssertionError({ message: `Snapshot "${name}" does not exist`, actual: inspect(snapshot) });
126+
}
127+
}
128+
129+
module.exports = snapshot;

lib/internal/errors.js

+2
Original file line numberDiff line numberDiff line change
@@ -936,6 +936,8 @@ module.exports = {
936936
E('ERR_AMBIGUOUS_ARGUMENT', 'The "%s" argument is ambiguous. %s', TypeError);
937937
E('ERR_ARG_NOT_ITERABLE', '%s must be iterable', TypeError);
938938
E('ERR_ASSERTION', '%s', Error);
939+
E('ERR_ASSERT_SNAPSHOT_NOT_SUPPORTED',
940+
'Snapshot is not supported in this context ', TypeError);
939941
E('ERR_ASYNC_CALLBACK', '%s must be a function', TypeError);
940942
E('ERR_ASYNC_TYPE', 'Invalid name for async "type": %s', TypeError);
941943
E('ERR_BROTLI_INVALID_PARAM', '%s is not a valid Brotli parameter', RangeError);

src/node_options.cc

+5
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,11 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
618618
&EnvironmentOptions::force_repl);
619619
AddAlias("-i", "--interactive");
620620

621+
AddOption("--update-assert-snapshot",
622+
"update assert snapshot files",
623+
&EnvironmentOptions::update_assert_snapshot,
624+
kAllowedInEnvironment);
625+
621626
AddOption("--napi-modules", "", NoOp{}, kAllowedInEnvironment);
622627

623628
AddOption("--tls-keylog",

src/node_options.h

+1
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ class EnvironmentOptions : public Options {
136136
bool preserve_symlinks = false;
137137
bool preserve_symlinks_main = false;
138138
bool prof_process = false;
139+
bool update_assert_snapshot = false;
139140
#if HAVE_INSPECTOR
140141
std::string cpu_prof_dir;
141142
static const uint64_t kDefaultCpuProfInterval = 1000;
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import assert from 'node:assert';
2+
3+
await assert.snapshot("test", "name");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import assert from 'node:assert';
2+
3+
await assert.snapshot("test", "name");
4+
await assert.snapshot("test", "another name");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import assert from 'node:assert';
2+
3+
await assert.snapshot("test", "another name");
4+
await assert.snapshot("test", "non existing");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
another name:
2+
'test'
3+
#*#*#*#*#*#*#*#*#*#*#*#
4+
name:
5+
'test'
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import assert from 'node:assert';
2+
3+
function random() {
4+
return `Random Value: ${Math.random()}`;
5+
}
6+
function transform(value) {
7+
return value.replaceAll(/Random Value: \d+\.+\d+/g, 'Random Value: *');
8+
}
9+
10+
await assert.snapshot(transform(random()), 'random1');
11+
await assert.snapshot(transform(random()), 'random2');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
random1:
2+
'Random Value: *'
3+
#*#*#*#*#*#*#*#*#*#*#*#
4+
random2:
5+
'Random Value: *'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import assert from 'node:assert';
2+
3+
function fn() {
4+
this.should.be.a.fn();
5+
return false;
6+
}
7+
8+
await assert.snapshot(fn, 'function');
9+
await assert.snapshot({ foo: "bar" }, 'object');
10+
await assert.snapshot(new Set([1, 2, 3]), 'set');
11+
await assert.snapshot(new Map([['one', 1], ['two', 2]]), 'map');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
function:
2+
[Function: fn]
3+
#*#*#*#*#*#*#*#*#*#*#*#
4+
object:
5+
{ foo: 'bar' }
6+
#*#*#*#*#*#*#*#*#*#*#*#
7+
set:
8+
Set(3) { 1, 2, 3 }
9+
#*#*#*#*#*#*#*#*#*#*#*#
10+
map:
11+
Map(2) { 'one' => 1, 'two' => 2 }
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import assert from 'node:assert';
2+
3+
await assert.snapshot("test", "snapshot");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import assert from 'node:assert';
2+
3+
await assert.snapshot("changed", "snapshot");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
snapshot:
2+
'original'

0 commit comments

Comments
 (0)