Skip to content

Commit

Permalink
fixup! feat: streaming debug logfile
Browse files Browse the repository at this point in the history
  • Loading branch information
lukekarrys committed Dec 2, 2021
1 parent e1209ac commit 3437a53
Show file tree
Hide file tree
Showing 2 changed files with 285 additions and 242 deletions.
304 changes: 156 additions & 148 deletions test/fixtures/mock-globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,200 +3,208 @@
// 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

// Path can be different cases across platform so get the original case
// of the path before anything is changed
const originalPathKey = process.env.PATH ? 'PATH' : process.env.Path ? 'Path' : 'path'

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 has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key)
const splitOnLast = (str) => {
const index = str.lastIndexOf('.')
return index > -1 && [str.slice(0, index), str.slice(index + 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, fullKey, childKey) => {
if (has(obj, fullKey)) {
return childKey ? get(obj[fullKey], childKey) : obj[fullKey]
} else {
const split = splitOnLast(fullKey)
return split ? get(
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,
split[0],
split[1] + (childKey ? `.${childKey}` : '')
) : undefined
parentKey,
prefix + (childKey && sep + childKey)
)
}
}

// Get object reference for the parent of a full key path on `global`
// So `process.env.NODE_ENV` would return a reference to global.process.env
const getGlobalParent = (fullKey) => {
const split = splitOnLast(fullKey)
return split ? get(global, split[0]) : global
}

// 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}` : k
return value && typeof value === 'object' && !Array.isArray(value)
? getKeys(value, key, memo)
: memo.concat(key)
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 `getOPD(process, 'on') === undefined` but if you
// Since `opd(process, 'on') === undefined` but if you
// walk up the prototype chain you get the original descriptor
// `getOPD(getPO(getPO(process)), 'on') === { value: [Function], ... }`
const getPropertyDescriptor = (obj, key, fullKey) => {
if (fullKey.toUpperCase() === 'PROCESS.ENV.PATH') {
key = originalPathKey
}
let d = Object.getOwnPropertyDescriptor(obj, key)
while (!d) {
obj = Object.getPrototypeOf(obj)
if (!obj) {
return
// `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
}
d = Object.getOwnPropertyDescriptor(obj, key)
}
return d
return descriptor
}

const createDescriptor = (currentDescriptor = {
configurable: true,
writable: true,
enumerable: true,
}, value) => {
if (value === undefined) {
// Mocking a global to undefined is the same
// as deleting it so return early since no
// descriptor will be created
return value
}
// Either set the descriptor value or getter depending
// on what the current descriptor has
return {
...currentDescriptor,
...(currentDescriptor.get ? { get: () => value } : { value }),
// 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',
}
}

// Define a descriptor or delete a key on an object
const defineProperty = (obj, key, descriptor) => {
if (descriptor === undefined) {
delete obj[key]
} else {
Object.defineProperty(obj, key, descriptor)
return (key) => {
switch (key.toLowerCase()) {
case 'process.env.path':
return originalKeys.PATH
}
}
}

const _pushDescriptor = Symbol('pushDescriptor')
const _popDescriptor = Symbol('popDescriptor')
const _set = Symbol('set')

class MockGlobals {
#skipDescriptor = Symbol('skipDescriptor')
#descriptors = {
// [fullKey]: [descriptor, descriptor, ...]
})()

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(),
]
}

teardown () {
Object.entries(this.#descriptors)
.forEach(([fullKey, descriptors]) => {
defineProperty(
getGlobalParent(fullKey),
last(fullKey.split('.')),
// On teardown reset to the initial descriptor
descriptors[0]
)
})
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))
}
}
}

registerGlobals (globals, { replace = false } = {}) {
// Replace means dont merge in object values but replace them instead
const keys = replace ? Object.keys(globals) : getKeys(globals)
return keys
// Set each property passed in and return fns to reset them
.map(k => this[_set](k, globals))
// Return an object with each path as a key for manually
// resetting in each test
.reduce((acc, r) => {
acc[r.fullKey] = r.reset
return acc
}, {})
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
}
}

[_pushDescriptor] (fullKey, value) {
if (!this.#descriptors[fullKey]) {
this.#descriptors[fullKey] = []
[_setGlobal] (d) {
if (this.#isDelete(d)) {
delete this.#global[this.#valueKey]
} else {
Object.defineProperty(this.#global, this.#valueKey, d)
}
this.#descriptors[fullKey].push(value)
return d
}

[_popDescriptor] (fullKey) {
const descriptors = this.#descriptors[fullKey]
if (!descriptors) {
return this.#skipDescriptor
[_nextDescriptor] (value) {
if (value === undefined) {
return this.#delete()
}
const descriptor = descriptors.pop()
if (!descriptors.length) {
delete this.#descriptors[fullKey]
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 }),
}
return descriptor
}
}

[_set] (fullKey, globals) {
const obj = getGlobalParent(fullKey)
const key = last(fullKey.split('.'))
class MockGlobals {
#descriptors = {}

const currentDescriptor = getPropertyDescriptor(obj, key, fullKey)
this[_pushDescriptor](fullKey, currentDescriptor)
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)

defineProperty(
obj,
key,
createDescriptor(
currentDescriptor,
get(globals, fullKey)
)
)
// 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}`)
}

return {
fullKey,
reset: () => {
const lastDescriptor = this[_popDescriptor](fullKey)
if (lastDescriptor !== this.#skipDescriptor) {
defineProperty(obj, key, lastDescriptor)
}
},
// 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}`)
}
}
}

// Each test has one instance of MockGlobals so it can be called
// multiple times per test
const cache = new Map()
// 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
}, {})
}

const mockGlobals = (t, globals, options) => {
const hasInstance = cache.has(t)
const instance = hasInstance ? cache.get(t) : new MockGlobals()
teardown (key) {
if (!key) {
Object.values(this.#descriptors).forEach((d) => d.reset())
return
}
this.#descriptors[key].reset()
}
}

if (!hasInstance) {
cache.set(t, instance)
t.teardown(() => {
instance.teardown()
cache.delete(t)
})
// 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: instance.registerGlobals(globals, options),
// 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(),
}
}

module.exports = mockGlobals
Loading

0 comments on commit 3437a53

Please sign in to comment.