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