Skip to content

Instantly share code, notes, and snippets.

@isaacs
Last active March 20, 2023 20:51
Show Gist options
  • Save isaacs/1cd15895118d68154e934608d2e13e55 to your computer and use it in GitHub Desktop.
Save isaacs/1cd15895118d68154e934608d2e13e55 to your computer and use it in GitHub Desktop.
// The idea here is that a plugin is a module that exports a function which
// takes a test object as an argument, and returns any kind of object.
// utility types
type SecondParam<
T extends [any] | [any, any],
R extends unknown = unknown
> = T extends [any, any] ? T[1] : R
const copyToString = (v: Function) => ({
toString: Object.assign(() => v.toString(), {
toString: () => 'function toString() { [native code] }',
}),
})
// test base class
interface TestBaseOpts {
blah?: boolean
}
class TestBase {
#privateThing: string = 'private thing'
#testBaseConstructed: boolean = false
getPrivateThing() {
return this.#privateThing
}
publicThing: string = 'public thing'
opts: TestBaseOpts
constructor(opts: TestBaseOpts) {
this.opts = opts
if (this.#testBaseConstructed) return
this.#testBaseConstructed = true
}
test(fn: (t: Test) => any) {
fn(new Test(this.opts))
}
pass(msg: string) {
console.log('ok - ' + msg)
}
fail(msg: string, fn: Function = this.fail) {
const e = new Error('trace')
type ECS = typeof Error & {
captureStackTrace: (e: Error, fn: Function) => void
}
if (
typeof (Error as ECS).captureStackTrace === 'function'
)
(Error as ECS).captureStackTrace(e, fn)
const s = (e.stack || '')
.split('\n')
.filter(l => !/Proxy.<anonymous>/.test(l))
.join('\n')
console.log('not ok - ' + msg, s)
}
}
// end test base class
// plugin 1
const greaterThan = (
t: TestBase,
{ orEq }: { orEq?: {} }
) =>
new (class GreaterThan {
orEq = orEq
#privateGT: string
#privateInitialized: number = 123
constructor() {
this.#privateGT = 'is greater than'
}
getPrivateGTs() {
console.log(
'getting private GTs',
this instanceof GreaterThan
)
return [this.#privateGT, this.#privateInitialized]
}
greaterThan(a: number, b: number) {
return a <= b
? t.fail(
'expect to be greater than',
this.greaterThan
)
: t.pass('expect to be greater than')
}
})()
// plugin 2
interface LTOpts extends TestBaseOpts {
orEq?: boolean
}
const lessThan = (t: TestBase, opts: LTOpts) =>
new (class LessThan {
orEq: boolean
constructor(opts: LTOpts) {
this.orEq = !!opts.orEq
}
lessThan(a: number, b: number) {
return (this.orEq ? a > b : a >= b)
? t.fail('expect to be less than', this.lessThan)
: t.pass('expect to be less than')
}
})(opts)
// auto generate start
type GreaterThanOpts = SecondParam<
Parameters<typeof greaterThan>,
TestBaseOpts
>
type LessThanOpts = SecondParam<
Parameters<typeof lessThan>,
TestBaseOpts
>
type TestOpts = TestBaseOpts &
GreaterThanOpts &
LessThanOpts
// Interface extends a type that is &'ed together to avoid blowing up when
// plugins might have differing types. Usually not a problem, just means one
// will override the other in practice, based on the order in which plugins
// are added to the list. But referencing eg `this.blah` will always refer
// to the actual thing the plugin expects, because plugins are separate objects
// in reality, just referenced via the Proxy.
type TTest = TestBase &
ReturnType<typeof greaterThan> &
ReturnType<typeof lessThan>
interface Test extends TTest {}
type PI<O extends TestBaseOpts | any = any> =
| ((t: Test, opts: O) => Plug)
| ((t: Test) => Plug)
const plugins: PI[] = [greaterThan, lessThan]
type Plug =
| TestBase
| ReturnType<typeof greaterThan>
| ReturnType<typeof lessThan>
type PlugKeys =
| keyof TestBase
| keyof ReturnType<typeof greaterThan>
| keyof ReturnType<typeof lessThan>
// auto generate end
// Test base start
const applyPlugins = (base: Test): Test => {
const ext: Plug[] = [
base,
...plugins.map(p => p(base, base.opts)),
]
const getCache = new Map<any, any>()
const t = new Proxy(base, {
has(_, p) {
for (const t of ext) {
if (Reflect.has(t, p)) return true
}
return false
},
ownKeys() {
const k: PlugKeys[] = []
for (const t of ext) {
const keys = Reflect.ownKeys(t) as PlugKeys[]
k.push(...keys)
}
return [...new Set(k)]
},
getOwnPropertyDescriptor(_, p) {
for (const t of ext) {
const prop = Reflect.getOwnPropertyDescriptor(t, p)
if (prop) return prop
}
return undefined
},
set(_, p, v) {
// check to see if there's any setters, and if so, set it there
// otherwise, just set on the base
for (const t of ext) {
let o: Object | null = t
while (o) {
if (Reflect.getOwnPropertyDescriptor(o, p)?.set) {
//@ts-ignore
t[p] = v
return true
}
o = Reflect.getPrototypeOf(o)
}
}
base[p as keyof TestBase] = v
return true
},
get(_, p) {
// cache get results so t.blah === t.blah
// we only cache functions, so that getters aren't memoized
// Of course, a getter that returns a function will be broken,
// at least when accessed from outside the plugin, but that's
// a pretty narrow caveat, and easily documented.
if (getCache.has(p)) return getCache.get(p)
for (const plug of ext) {
if (p in plug) {
//@ts-ignore
const v = plug[p]
// Functions need special handling so that they report
// the correct toString and are called on the correct object
// Otherwise attempting to access #private props will fail.
if (typeof v === 'function') {
const f: (this: Plug, ...args: any) => any =
function (...args: any[]) {
const thisArg = this === t ? plug : this
return v.apply(thisArg, args)
}
const vv = Object.assign(f, copyToString(v))
const nameProp =
Reflect.getOwnPropertyDescriptor(v, 'name')
if (nameProp) {
Reflect.defineProperty(f, 'name', nameProp)
}
getCache.set(p, vv)
return vv
} else {
getCache.set(p, v)
return v
}
}
}
},
})
return t
}
class Test extends TestBase {
constructor(opts: TestOpts) {
super(opts)
return applyPlugins(this)
}
}
// test base end
// Usage: create some test objects
const t = new Test({})
console.log('ctor name', t.constructor.name)
console.log('ctor toString()', t.constructor.toString())
console.log('instanceof Test', t instanceof Test)
console.log('keys', Object.keys(t))
console.log(
'has isEq',
Object.prototype.hasOwnProperty.call(t, 'orEq')
)
console.log('isEq', t.orEq)
t.lessThan(0, 1)
console.log(t.lessThan.toString())
console.log(t.getPrivateThing())
t.test(t => {
t.lessThan(1, 2)
console.log(t.getPrivateGTs())
t.test(t => {
t.greaterThan(1, 2)
console.log(t.getPrivateThing.toString())
console.log(t.getPrivateThing())
})
})
export {}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment