1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
|
// An initial implementation for a feature that will hopefully exist in tap
// https://github.com/tapjs/node-tap/issues/789
// This file is only used in tests but it is still tested itself.
// Hopefully it can be removed for a feature in tap in the future
const sep = '.'
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
const opd = (o, k) => Object.getOwnPropertyDescriptor(o, k)
const po = (o) => Object.getPrototypeOf(o)
const pojo = (o) => Object.prototype.toString.call(o) === '[object Object]'
const last = (arr) => arr[arr.length - 1]
const splitLast = (str) => str.split(new RegExp(`\\${sep}(?=[^${sep}]+$)`))
const dupes = (arr) => arr.filter((k, i) => arr.indexOf(k) !== i)
const dupesStartsWith = (arr) => arr.filter((k1) => arr.some((k2) => k2.startsWith(k1 + sep)))
// A weird getter that can look up keys on nested objects but also
// match keys with dots in their names, eg { 'process.env': { TERM: 'a' } }
// can be looked up with the key 'process.env.TERM'
const get = (obj, key, childKey = '') => {
if (has(obj, key)) {
return childKey ? get(obj[key], childKey) : obj[key]
} else if (key.includes(sep)) {
const [parentKey, prefix] = splitLast(key)
return get(
obj,
parentKey,
prefix + (childKey && sep + childKey)
)
}
}
// Map an object to an array of nested keys separated by dots
// { a: 1, b: { c: 2, d: [1] } } => ['a', 'b.c', 'b.d']
const getKeys = (values, p = '', acc = []) =>
Object.entries(values).reduce((memo, [k, value]) => {
const key = p ? [p, k].join(sep) : k
return pojo(value) ? getKeys(value, key, memo) : memo.concat(key)
}, acc)
// Walk prototype chain to get first available descriptor. This is necessary
// to get the current property descriptor for things like `process.on`.
// Since `opd(process, 'on') === undefined` but if you
// walk up the prototype chain you get the original descriptor
// `opd(po(po(process)), 'on') === { value, ... }`
const protoDescriptor = (obj, key) => {
let descriptor
// i always wanted to assign variables in a while loop's condition
// i thought it would feel better than this
while (!(descriptor = opd(obj, key))) {
if (!(obj = po(obj))) {
break
}
}
return descriptor
}
// Path can be different cases across platform so get the original case
// of the path before anything is changed
// XXX: other special cases to handle?
const specialCaseKeys = (() => {
const originalKeys = {
PATH: process.env.PATH ? 'PATH' : process.env.Path ? 'Path' : 'path',
}
return (key) => {
switch (key.toLowerCase()) {
case 'process.env.path':
return originalKeys.PATH
}
}
})()
const _setGlobal = Symbol('setGlobal')
const _nextDescriptor = Symbol('nextDescriptor')
class DescriptorStack {
#stack = []
#global = null
#valueKey = null
#defaultDescriptor = { configurable: true, writable: true, enumerable: true }
#delete = () => ({ DELETE: true })
#isDelete = (o) => o && o.DELETE === true
constructor (key) {
const keys = splitLast(key)
this.#global = keys.length === 1 ? global : get(global, keys[0])
this.#valueKey = specialCaseKeys(key) || last(keys)
// If the global object doesnt return a descriptor for the key
// then we mark it for deletion on teardown
this.#stack = [
protoDescriptor(this.#global, this.#valueKey) || this.#delete(),
]
}
add (value) {
// This must be a unique object so we can find it later via indexOf
// That's why delete/nextDescriptor create new objects
const nextDescriptor = this[_nextDescriptor](value)
this.#stack.push(this[_setGlobal](nextDescriptor))
return () => {
const index = this.#stack.indexOf(nextDescriptor)
// If the stack doesnt contain the descriptor anymore
// than do nothing. This keeps the reset function indempotent
if (index > -1) {
// Resetting removes a descriptor from the stack
this.#stack.splice(index, 1)
// But we always reset to what is now the most recent in case
// resets are being called manually out of order
this[_setGlobal](last(this.#stack))
}
}
}
reset () {
// Everything could be reset manually so only
// teardown if we have an initial descriptor left
// and then delete the rest of the stack
if (this.#stack.length) {
this[_setGlobal](this.#stack[0])
this.#stack.length = 0
}
}
[_setGlobal] (d) {
if (this.#isDelete(d)) {
delete this.#global[this.#valueKey]
} else {
Object.defineProperty(this.#global, this.#valueKey, d)
}
return d
}
[_nextDescriptor] (value) {
if (value === undefined) {
return this.#delete()
}
const d = last(this.#stack)
return {
// If the previous descriptor was one to delete the property
// then use the default descriptor as the base
...(this.#isDelete(d) ? this.#defaultDescriptor : d),
...(d && d.get ? { get: () => value } : { value }),
}
}
}
class MockGlobals {
#descriptors = {}
register (globals, { replace = false } = {}) {
// Replace means dont merge in object values but replace them instead
// so we only get top level keys instead of walking the obj
const keys = replace ? Object.keys(globals) : getKeys(globals)
// An error state where due to object mode there are multiple global
// values to be set with the same key
const duplicates = dupes(keys)
if (duplicates.length) {
throw new Error(`mockGlobals was called with duplicate keys: ${duplicates}`)
}
// Another error where when in replace mode overlapping keys are set like
// process and process.stdout which would cause unexpected behavior
const overlapping = dupesStartsWith(keys)
if (overlapping.length) {
const message = overlapping
.map((k) => `${k} -> ${keys.filter((kk) => kk.startsWith(k + sep))}`)
throw new Error(`mockGlobals was called with overlapping keys: ${message}`)
}
// Set each property passed in and return fns to reset them
// Return an object with each path as a key for manually resetting in each test
return keys.reduce((acc, key) => {
const desc = this.#descriptors[key] || (this.#descriptors[key] = new DescriptorStack(key))
acc[key] = desc.add(get(globals, key))
return acc
}, {})
}
teardown (key) {
if (!key) {
Object.values(this.#descriptors).forEach((d) => d.reset())
return
}
this.#descriptors[key].reset()
}
}
// Each test has one instance of MockGlobals so it can be called multiple times per test
// Its a weak map so that it can be garbage collected along with the tap tests without
// needing to explicitly call cache.delete
const cache = new WeakMap()
module.exports = (t, globals, options) => {
let instance = cache.get(t)
if (!instance) {
instance = cache.set(t, new MockGlobals()).get(t)
// Teardown only needs to be initialized once. The instance
// will keep track of its own state during the test
t.teardown(() => instance.teardown())
}
return {
// Reset contains only the functions to reset the globals
// set by this function call
reset: instance.register(globals, options),
// Teardown will reset across all calls tied to this test
teardown: () => instance.teardown(),
}
}
|