Skip to content

Commit 42f2deb

Browse files
LiviaMedeirosdanielleadams
authored andcommitted
test: add common.mustNotMutateObjectDeep()
This function returns a Proxy object that throws on attempt to mutate it Functions and primitives are returned directly PR-URL: #43196 Reviewed-By: Darshan Sen <[email protected]> Reviewed-By: Antoine du Hamel <[email protected]>
1 parent a057510 commit 42f2deb

File tree

4 files changed

+309
-0
lines changed

4 files changed

+309
-0
lines changed

test/common/README.md

+35
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,40 @@ If `fn` is not provided, an empty function will be used.
299299
Returns a function that triggers an `AssertionError` if it is invoked. `msg` is
300300
used as the error message for the `AssertionError`.
301301

302+
### `mustNotMutateObjectDeep([target])`
303+
304+
* `target` [\<any>][<any>] default = `undefined`
305+
* return [\<any>][<any>]
306+
307+
If `target` is an Object, returns a proxy object that triggers
308+
an `AssertionError` on mutation attempt, including mutation of deeply nested
309+
Objects. Otherwise, it returns `target` directly.
310+
311+
Use of this function is encouraged for relevant regression tests.
312+
313+
```mjs
314+
import { open } from 'node:fs/promises';
315+
import { mustNotMutateObjectDeep } from '../common/index.mjs';
316+
317+
const _mutableOptions = { length: 4, position: 8 };
318+
const options = mustNotMutateObjectDeep(_mutableOptions);
319+
320+
// In filehandle.read or filehandle.write, attempt to mutate options will throw
321+
// In the test code, options can still be mutated via _mutableOptions
322+
const fh = await open('/path/to/file', 'r+');
323+
const { buffer } = await fh.read(options);
324+
_mutableOptions.position = 4;
325+
await fh.write(buffer, options);
326+
327+
// Inline usage
328+
const stats = await fh.stat(mustNotMutateObjectDeep({ bigint: true }));
329+
console.log(stats.size);
330+
```
331+
332+
Caveats: built-in objects that make use of their internal slots (for example,
333+
`Map`s and `Set`s) might not work with this function. It returns Functions
334+
directly, not preventing their mutation.
335+
302336
### `mustSucceed([fn])`
303337

304338
* `fn` [\<Function>][<Function>] default = () => {}
@@ -1024,6 +1058,7 @@ See [the WPT tests README][] for details.
10241058
[<Function>]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function
10251059
[<Object>]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object
10261060
[<RegExp>]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp
1061+
[<any>]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Data_types
10271062
[<bigint>]: https://github.com/tc39/proposal-bigint
10281063
[<boolean>]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type
10291064
[<number>]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type

test/common/index.js

+47
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,52 @@ function mustNotCall(msg) {
519519
};
520520
}
521521

522+
const _mustNotMutateObjectDeepProxies = new WeakMap();
523+
524+
function mustNotMutateObjectDeep(original) {
525+
// Return primitives and functions directly. Primitives are immutable, and
526+
// proxied functions are impossible to compare against originals, e.g. with
527+
// `assert.deepEqual()`.
528+
if (original === null || typeof original !== 'object') {
529+
return original;
530+
}
531+
532+
const cachedProxy = _mustNotMutateObjectDeepProxies.get(original);
533+
if (cachedProxy) {
534+
return cachedProxy;
535+
}
536+
537+
const _mustNotMutateObjectDeepHandler = {
538+
__proto__: null,
539+
defineProperty(target, property, descriptor) {
540+
assert.fail(`Expected no side effects, got ${inspect(property)} ` +
541+
'defined');
542+
},
543+
deleteProperty(target, property) {
544+
assert.fail(`Expected no side effects, got ${inspect(property)} ` +
545+
'deleted');
546+
},
547+
get(target, prop, receiver) {
548+
return mustNotMutateObjectDeep(Reflect.get(target, prop, receiver));
549+
},
550+
preventExtensions(target) {
551+
assert.fail('Expected no side effects, got extensions prevented on ' +
552+
inspect(target));
553+
},
554+
set(target, property, value, receiver) {
555+
assert.fail(`Expected no side effects, got ${inspect(value)} ` +
556+
`assigned to ${inspect(property)}`);
557+
},
558+
setPrototypeOf(target, prototype) {
559+
assert.fail(`Expected no side effects, got set prototype to ${prototype}`);
560+
}
561+
};
562+
563+
const proxy = new Proxy(original, _mustNotMutateObjectDeepHandler);
564+
_mustNotMutateObjectDeepProxies.set(original, proxy);
565+
return proxy;
566+
}
567+
522568
function printSkipMessage(msg) {
523569
console.log(`1..0 # Skipped: ${msg}`);
524570
}
@@ -827,6 +873,7 @@ const common = {
827873
mustCall,
828874
mustCallAtLeast,
829875
mustNotCall,
876+
mustNotMutateObjectDeep,
830877
mustSucceed,
831878
nodeProcessAborted,
832879
PIPE,

test/common/index.mjs

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const {
3535
canCreateSymLink,
3636
getCallSite,
3737
mustNotCall,
38+
mustNotMutateObjectDeep,
3839
printSkipMessage,
3940
skip,
4041
nodeProcessAborted,
@@ -81,6 +82,7 @@ export {
8182
canCreateSymLink,
8283
getCallSite,
8384
mustNotCall,
85+
mustNotMutateObjectDeep,
8486
printSkipMessage,
8587
skip,
8688
nodeProcessAborted,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { mustNotMutateObjectDeep } from '../common/index.mjs';
2+
import assert from 'node:assert';
3+
import { promisify } from 'node:util';
4+
5+
// Test common.mustNotMutateObjectDeep()
6+
7+
const original = {
8+
foo: { bar: 'baz' },
9+
qux: null,
10+
quux: [
11+
'quuz',
12+
{ corge: 'grault' },
13+
],
14+
};
15+
16+
// Make a copy to make sure original doesn't get altered by the function itself.
17+
const backup = structuredClone(original);
18+
19+
// Wrapper for convenience:
20+
const obj = () => mustNotMutateObjectDeep(original);
21+
22+
function testOriginal(root) {
23+
assert.deepStrictEqual(root, backup);
24+
return root.foo.bar === 'baz' && root.quux[1].corge.length === 6;
25+
}
26+
27+
function definePropertyOnRoot(root) {
28+
Object.defineProperty(root, 'xyzzy', {});
29+
}
30+
31+
function definePropertyOnFoo(root) {
32+
Object.defineProperty(root.foo, 'xyzzy', {});
33+
}
34+
35+
function deletePropertyOnRoot(root) {
36+
delete root.foo;
37+
}
38+
39+
function deletePropertyOnFoo(root) {
40+
delete root.foo.bar;
41+
}
42+
43+
function preventExtensionsOnRoot(root) {
44+
Object.preventExtensions(root);
45+
}
46+
47+
function preventExtensionsOnFoo(root) {
48+
Object.preventExtensions(root.foo);
49+
}
50+
51+
function preventExtensionsOnRootViaSeal(root) {
52+
Object.seal(root);
53+
}
54+
55+
function preventExtensionsOnFooViaSeal(root) {
56+
Object.seal(root.foo);
57+
}
58+
59+
function preventExtensionsOnRootViaFreeze(root) {
60+
Object.freeze(root);
61+
}
62+
63+
function preventExtensionsOnFooViaFreeze(root) {
64+
Object.freeze(root.foo);
65+
}
66+
67+
function setOnRoot(root) {
68+
root.xyzzy = 'gwak';
69+
}
70+
71+
function setOnFoo(root) {
72+
root.foo.xyzzy = 'gwak';
73+
}
74+
75+
function setQux(root) {
76+
root.qux = 'gwak';
77+
}
78+
79+
function setQuux(root) {
80+
root.quux.push('gwak');
81+
}
82+
83+
function setQuuxItem(root) {
84+
root.quux[0] = 'gwak';
85+
}
86+
87+
function setQuuxProperty(root) {
88+
root.quux[1].corge = 'gwak';
89+
}
90+
91+
function setPrototypeOfRoot(root) {
92+
Object.setPrototypeOf(root, Array);
93+
}
94+
95+
function setPrototypeOfFoo(root) {
96+
Object.setPrototypeOf(root.foo, Array);
97+
}
98+
99+
function setPrototypeOfQuux(root) {
100+
Object.setPrototypeOf(root.quux, Array);
101+
}
102+
103+
104+
{
105+
assert.ok(testOriginal(obj()));
106+
107+
assert.throws(
108+
() => definePropertyOnRoot(obj()),
109+
{ code: 'ERR_ASSERTION' }
110+
);
111+
assert.throws(
112+
() => definePropertyOnFoo(obj()),
113+
{ code: 'ERR_ASSERTION' }
114+
);
115+
assert.throws(
116+
() => deletePropertyOnRoot(obj()),
117+
{ code: 'ERR_ASSERTION' }
118+
);
119+
assert.throws(
120+
() => deletePropertyOnFoo(obj()),
121+
{ code: 'ERR_ASSERTION' }
122+
);
123+
assert.throws(
124+
() => preventExtensionsOnRoot(obj()),
125+
{ code: 'ERR_ASSERTION' }
126+
);
127+
assert.throws(
128+
() => preventExtensionsOnFoo(obj()),
129+
{ code: 'ERR_ASSERTION' }
130+
);
131+
assert.throws(
132+
() => preventExtensionsOnRootViaSeal(obj()),
133+
{ code: 'ERR_ASSERTION' }
134+
);
135+
assert.throws(
136+
() => preventExtensionsOnFooViaSeal(obj()),
137+
{ code: 'ERR_ASSERTION' }
138+
);
139+
assert.throws(
140+
() => preventExtensionsOnRootViaFreeze(obj()),
141+
{ code: 'ERR_ASSERTION' }
142+
);
143+
assert.throws(
144+
() => preventExtensionsOnFooViaFreeze(obj()),
145+
{ code: 'ERR_ASSERTION' }
146+
);
147+
assert.throws(
148+
() => setOnRoot(obj()),
149+
{ code: 'ERR_ASSERTION' }
150+
);
151+
assert.throws(
152+
() => setOnFoo(obj()),
153+
{ code: 'ERR_ASSERTION' }
154+
);
155+
assert.throws(
156+
() => setQux(obj()),
157+
{ code: 'ERR_ASSERTION' }
158+
);
159+
assert.throws(
160+
() => setQuux(obj()),
161+
{ code: 'ERR_ASSERTION' }
162+
);
163+
assert.throws(
164+
() => setQuux(obj()),
165+
{ code: 'ERR_ASSERTION' }
166+
);
167+
assert.throws(
168+
() => setQuuxItem(obj()),
169+
{ code: 'ERR_ASSERTION' }
170+
);
171+
assert.throws(
172+
() => setQuuxProperty(obj()),
173+
{ code: 'ERR_ASSERTION' }
174+
);
175+
assert.throws(
176+
() => setPrototypeOfRoot(obj()),
177+
{ code: 'ERR_ASSERTION' }
178+
);
179+
assert.throws(
180+
() => setPrototypeOfFoo(obj()),
181+
{ code: 'ERR_ASSERTION' }
182+
);
183+
assert.throws(
184+
() => setPrototypeOfQuux(obj()),
185+
{ code: 'ERR_ASSERTION' }
186+
);
187+
188+
// Test that no mutation happened:
189+
assert.ok(testOriginal(obj()));
190+
}
191+
192+
// Test various supported types, directly and nested:
193+
[
194+
undefined, null, false, true, 42, 42n, Symbol('42'), NaN, Infinity, {}, [],
195+
() => {}, async () => {}, Promise.resolve(), Math, Object.create(null),
196+
].forEach((target) => {
197+
assert.deepStrictEqual(mustNotMutateObjectDeep(target), target);
198+
assert.deepStrictEqual(mustNotMutateObjectDeep({ target }), { target });
199+
assert.deepStrictEqual(mustNotMutateObjectDeep([ target ]), [ target ]);
200+
});
201+
202+
// Test that passed functions keep working correctly:
203+
{
204+
const fn = () => 'blep';
205+
fn.foo = {};
206+
const fnImmutableView = mustNotMutateObjectDeep(fn);
207+
assert.deepStrictEqual(fnImmutableView, fn);
208+
209+
// Test that the function still works:
210+
assert.strictEqual(fn(), 'blep');
211+
assert.strictEqual(fnImmutableView(), 'blep');
212+
213+
// Test that the original function is not deeply frozen:
214+
fn.foo.bar = 'baz';
215+
assert.strictEqual(fn.foo.bar, 'baz');
216+
assert.strictEqual(fnImmutableView.foo.bar, 'baz');
217+
218+
// Test the original function is not frozen:
219+
fn.qux = 'quux';
220+
assert.strictEqual(fn.qux, 'quux');
221+
assert.strictEqual(fnImmutableView.qux, 'quux');
222+
223+
// Redefining util.promisify.custom also works:
224+
promisify(mustNotMutateObjectDeep(promisify(fn)));
225+
}

0 commit comments

Comments
 (0)