Skip to content

Commit 3437a53

Browse files
committed
fixup! feat: streaming debug logfile
1 parent e1209ac commit 3437a53

File tree

2 files changed

+285
-242
lines changed

2 files changed

+285
-242
lines changed

test/fixtures/mock-globals.js

+156-148
Original file line numberDiff line numberDiff line change
@@ -3,200 +3,208 @@
33
// This file is only used in tests but it is still tested itself.
44
// Hopefully it can be removed for a feature in tap in the future
55

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]'
1011
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)))
1615

1716
// A weird getter that can look up keys on nested objects but also
1817
// match keys with dots in their names, eg { 'process.env': { TERM: 'a' } }
1918
// 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(
2625
obj,
27-
split[0],
28-
split[1] + (childKey ? `.${childKey}` : '')
29-
) : undefined
26+
parentKey,
27+
prefix + (childKey && sep + childKey)
28+
)
3029
}
3130
}
3231

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-
4032
// Map an object to an array of nested keys separated by dots
4133
// { a: 1, b: { c: 2, d: [1] } } => ['a', 'b.c', 'b.d']
4234
const getKeys = (values, p = '', acc = []) =>
4335
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)
4838
}, acc)
4939

5040
// Walk prototype chain to get first available descriptor. This is necessary
5141
// 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
5343
// 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
6452
}
65-
d = Object.getOwnPropertyDescriptor(obj, key)
6653
}
67-
return d
54+
return descriptor
6855
}
6956

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',
8663
}
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+
}
9569
}
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+
]
10692
}
10793

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+
}
118112
}
119113

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+
}
132122
}
133123

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)
137129
}
138-
this.#descriptors[fullKey].push(value)
130+
return d
139131
}
140132

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()
145136
}
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 }),
149143
}
150-
return descriptor
151144
}
145+
}
152146

153-
[_set] (fullKey, globals) {
154-
const obj = getGlobalParent(fullKey)
155-
const key = last(fullKey.split('.'))
147+
class MockGlobals {
148+
#descriptors = {}
156149

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)
159154

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+
}
168161

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}`)
177169
}
178-
}
179-
}
180170

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+
}
184179

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+
}
188188

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())
195201
}
196202

197203
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(),
199209
}
200210
}
201-
202-
module.exports = mockGlobals

0 commit comments

Comments
 (0)