Last active
March 20, 2023 20:51
-
-
Save isaacs/1cd15895118d68154e934608d2e13e55 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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