diff --git a/packages/egg/src/index.ts b/packages/egg/src/index.ts index 60d0bc4396..5e5c0c1ae8 100644 --- a/packages/egg/src/index.ts +++ b/packages/egg/src/index.ts @@ -54,6 +54,9 @@ export type { LoggerLevel, EggLogger, EggLogger as Logger } from 'egg-logger'; export * from './lib/core/httpclient.ts'; export * from './lib/core/context_httpclient.ts'; +// export utils +export { createTransparentProxy, type CreateTransparentProxyOptions } from './lib/core/utils.ts'; + /** * Start egg application with cluster mode * @since 1.0.0 diff --git a/packages/egg/src/lib/core/httpclient.ts b/packages/egg/src/lib/core/httpclient.ts index be4111907b..f457ce75f3 100644 --- a/packages/egg/src/lib/core/httpclient.ts +++ b/packages/egg/src/lib/core/httpclient.ts @@ -36,6 +36,13 @@ export class HttpClient extends RawHttpClient { }; super(initOptions); this.#app = app; + + // Apply custom interceptors via Dispatcher.compose() if configured. + // This enables tracer injection, custom headers, retry logic, etc. + if (config.interceptors?.length) { + const originalDispatcher = this.getDispatcher(); + this.setDispatcher(originalDispatcher.compose(...config.interceptors)); + } } async request( diff --git a/packages/egg/src/lib/core/utils.ts b/packages/egg/src/lib/core/utils.ts index 53b93bc06f..63ce46435d 100644 --- a/packages/egg/src/lib/core/utils.ts +++ b/packages/egg/src/lib/core/utils.ts @@ -73,3 +73,109 @@ export function safeParseURL(url: string): URL | null { return null; } } + +export interface CreateTransparentProxyOptions { + /** + * Factory function to lazily create the real object. + * Called at most once, on first property access. + */ + createReal: () => T; + /** + * Whether to bind functions from the real object to the real instance. + * Defaults to true. + */ + bindFunctions?: boolean; +} + +/** + * Create a Proxy that behaves like the real object, but remains transparent to + * monkeypatch libraries (e.g. defineProperty-based overrides like egg-mock's `mm()`). + * + * - Lazily creates the real object on first access. + * - Allows overriding properties on the proxy target (overlay) — e.g. via `Object.defineProperty`. + * - Delegates everything else to the real object. + * + * This is used to defer HttpClient construction so plugins can modify + * `config.httpclient.lookup` after the first access to `app.httpClient` but + * before any actual HTTP request is made. + */ +export function createTransparentProxy(options: CreateTransparentProxyOptions): T { + const { createReal, bindFunctions = true } = options; + if (typeof createReal !== 'function') { + throw new TypeError('createReal must be a function'); + } + + let real: T | undefined; + let cachedError: unknown; + const boundFnCache = new WeakMap(); + + const getReal = (): T => { + if (real) return real; + if (cachedError) throw cachedError; + try { + return (real = createReal()); + } catch (err) { + cachedError = err; + throw err; + } + }; + + const hasOwn = (obj: object, prop: PropertyKey) => Reflect.getOwnPropertyDescriptor(obj, prop) !== undefined; + + // The overlay target stores defineProperty-based overrides (e.g. from egg-mock's mm()). + // Mocks live here so mm.restore() can delete them and reveal the real object underneath. + const overlay = {} as T; + + return new Proxy(overlay, { + get(target, prop, receiver) { + const r = getReal(); + // Overlay (defineProperty-based overrides) takes precedence + if (hasOwn(target, prop)) { + return Reflect.get(target, prop, receiver); + } + const value = Reflect.get(r, prop); + if (bindFunctions && typeof value === 'function') { + let bound = boundFnCache.get(value); + if (!bound) { + bound = value.bind(r) as Function; + boundFnCache.set(value, bound); + } + return bound; + } + return value; + }, + + set(target, prop, value) { + const r = getReal(); + if (hasOwn(target, prop)) { + return Reflect.set(target, prop, value); + } + return Reflect.set(r, prop, value); + }, + + has(target, prop) { + return Reflect.has(target, prop) || Reflect.has(getReal(), prop); + }, + + ownKeys(target) { + return [...new Set([...Reflect.ownKeys(getReal()), ...Reflect.ownKeys(target)])]; + }, + + getOwnPropertyDescriptor(target, prop) { + return Reflect.getOwnPropertyDescriptor(target, prop) ?? Reflect.getOwnPropertyDescriptor(getReal(), prop); + }, + + deleteProperty(target, prop) { + if (hasOwn(target, prop)) return Reflect.deleteProperty(target, prop); + return Reflect.deleteProperty(getReal(), prop); + }, + + getPrototypeOf() { + return Reflect.getPrototypeOf(getReal()); + }, + + defineProperty(target, prop, descriptor) { + return Reflect.defineProperty(target, prop, descriptor); + }, + }); +} diff --git a/packages/egg/src/lib/egg.ts b/packages/egg/src/lib/egg.ts index 907db0473b..3a158e7292 100644 --- a/packages/egg/src/lib/egg.ts +++ b/packages/egg/src/lib/egg.ts @@ -34,7 +34,7 @@ import { } from './core/httpclient.ts'; import { createLoggers } from './core/logger.ts'; import { create as createMessenger, type IMessenger } from './core/messenger/index.ts'; -import { convertObject } from './core/utils.ts'; +import { convertObject, createTransparentProxy } from './core/utils.ts'; import type { EggApplicationLoader } from './loader/index.ts'; import type { EggAppConfig } from './types.ts'; @@ -377,12 +377,21 @@ export class EggApplicationCore extends EggCore { /** * HttpClient instance + * + * Returns a transparent proxy that defers actual HttpClient construction + * until a method/property is first accessed. This allows plugins to modify + * `config.httpclient.lookup` or other options during lifecycle hooks + * (e.g. `configWillLoad`, `didLoad`) even after `app.httpClient` is + * first referenced. + * * @see https://github.com/node-modules/urllib * @member {HttpClient} */ get httpClient(): HttpClient { if (!this.#httpClient) { - this.#httpClient = this.createHttpClient(); + this.#httpClient = createTransparentProxy({ + createReal: () => this.createHttpClient(), + }); } return this.#httpClient; } diff --git a/packages/egg/src/lib/types.ts b/packages/egg/src/lib/types.ts index 33f92bb44f..2b62381c54 100644 --- a/packages/egg/src/lib/types.ts +++ b/packages/egg/src/lib/types.ts @@ -3,6 +3,7 @@ import type { Socket, LookupFunction } from 'node:net'; import type { FileLoaderOptions, EggAppConfig as EggCoreAppConfig, EggAppInfo } from '@eggjs/core'; import type { EggLoggerOptions, EggLoggersOptions } from 'egg-logger'; import type { PartialDeep } from 'type-fest'; +import type { Dispatcher } from 'urllib'; import type { RequestOptions as HttpClientRequestOptions } from 'urllib'; import type { MetaMiddlewareOptions } from '../app/middleware/meta.ts'; @@ -67,6 +68,24 @@ export interface HttpClientConfig { */ allowH2?: boolean; lookup?: LookupFunction; + /** + * Interceptors for request composition, applied via `Dispatcher.compose()`. + * Each interceptor receives a `dispatch` function and returns a new `dispatch` function. + * + * @example + * ```ts + * // config.default.ts + * config.httpclient = { + * interceptors: [ + * (dispatch) => (opts, handler) => { + * opts.headers = { ...opts.headers, 'x-trace-id': generateTraceId() }; + * return dispatch(opts, handler); + * }, + * ], + * }; + * ``` + */ + interceptors?: Dispatcher.DispatcherComposeInterceptor[]; } /** diff --git a/packages/egg/test/__snapshots__/index.test.ts.snap b/packages/egg/test/__snapshots__/index.test.ts.snap index a3bb8f5274..996d63b34f 100644 --- a/packages/egg/test/__snapshots__/index.test.ts.snap +++ b/packages/egg/test/__snapshots__/index.test.ts.snap @@ -89,6 +89,7 @@ exports[`should expose properties 1`] = ` "Singleton": [Function], "SingletonProto": [Function], "Subscription": [Function], + "createTransparentProxy": [Function], "defineConfig": [Function], "defineConfigFactory": [Function], "definePluginFactory": [Function], diff --git a/packages/egg/test/fixtures/apps/dnscache_httpclient/config/config.default.js b/packages/egg/test/fixtures/apps/dnscache_httpclient/config/config.default.js index 6c25d6c8a0..d932b45b02 100644 --- a/packages/egg/test/fixtures/apps/dnscache_httpclient/config/config.default.js +++ b/packages/egg/test/fixtures/apps/dnscache_httpclient/config/config.default.js @@ -1,5 +1,12 @@ 'use strict'; +const os = require('os'); +const path = require('path'); + +// Use unique log directory per vitest worker to avoid Windows file locking issues +const workerId = process.env.VITEST_WORKER_ID || '0'; +const tempBase = path.join(os.tmpdir(), `egg-httpclient-test-${workerId}`); + exports.httpclient = { lookup: function (hostname, options, callback) { const IP_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; @@ -21,4 +28,10 @@ exports.httpclient = { }, }; +exports.logger = { + dir: path.join(tempBase, 'logs', 'dnscache_httpclient'), +}; + +exports.rundir = path.join(tempBase, 'run'); + exports.keys = 'test key'; diff --git a/packages/egg/test/fixtures/apps/httpclient-interceptor/app/router.js b/packages/egg/test/fixtures/apps/httpclient-interceptor/app/router.js new file mode 100644 index 0000000000..2abb35fffe --- /dev/null +++ b/packages/egg/test/fixtures/apps/httpclient-interceptor/app/router.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = (app) => { + app.get('/', async (ctx) => { + ctx.body = 'ok'; + }); +}; diff --git a/packages/egg/test/fixtures/apps/httpclient-interceptor/config/config.default.js b/packages/egg/test/fixtures/apps/httpclient-interceptor/config/config.default.js new file mode 100644 index 0000000000..0774f8d982 --- /dev/null +++ b/packages/egg/test/fixtures/apps/httpclient-interceptor/config/config.default.js @@ -0,0 +1,33 @@ +'use strict'; + +const os = require('os'); +const path = require('path'); + +let rpcIdCounter = 0; + +// Use unique log directory per vitest worker to avoid Windows file locking issues +const workerId = process.env.VITEST_WORKER_ID || '0'; +const tempBase = path.join(os.tmpdir(), `egg-httpclient-test-${workerId}`); + +exports.httpclient = { + interceptors: [ + // Tracer interceptor: injects trace headers into every request + (dispatch) => { + return (opts, handler) => { + opts.headers = opts.headers || {}; + opts.headers['x-trace-id'] = 'trace-123'; + rpcIdCounter++; + opts.headers['x-rpc-id'] = `rpc-${rpcIdCounter}`; + return dispatch(opts, handler); + }; + }, + ], +}; + +exports.logger = { + dir: path.join(tempBase, 'logs', 'httpclient-interceptor'), +}; + +exports.rundir = path.join(tempBase, 'run'); + +exports.keys = 'test key'; diff --git a/packages/egg/test/fixtures/apps/httpclient-interceptor/package.json b/packages/egg/test/fixtures/apps/httpclient-interceptor/package.json new file mode 100644 index 0000000000..99e4ae0936 --- /dev/null +++ b/packages/egg/test/fixtures/apps/httpclient-interceptor/package.json @@ -0,0 +1,3 @@ +{ + "name": "httpclient-interceptor-app" +} diff --git a/packages/egg/test/lib/core/httpclient_interceptor.test.ts b/packages/egg/test/lib/core/httpclient_interceptor.test.ts new file mode 100644 index 0000000000..5a3fce969e --- /dev/null +++ b/packages/egg/test/lib/core/httpclient_interceptor.test.ts @@ -0,0 +1,80 @@ +import { strict as assert } from 'node:assert'; +import http from 'node:http'; + +import { describe, it, beforeAll, afterAll } from 'vitest'; + +import { createApp, type MockApplication, startNewLocalServer } from '../../utils.js'; + +describe('test/lib/core/httpclient_interceptor.test.ts', () => { + describe('with interceptors configured', () => { + let app: MockApplication; + let url: string; + let serverInfo: { url: string; server: http.Server }; + + beforeAll(async () => { + app = createApp('apps/httpclient-interceptor'); + await app.ready(); + serverInfo = await startNewLocalServer(); + url = serverInfo.url; + }); + + afterAll(async () => { + if (serverInfo?.server?.listening) { + serverInfo.server.close(); + } + await app.close(); + }); + + it('should inject trace headers via interceptor', async () => { + const res = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert.equal(res.status, 200); + assert.equal(res.data.headers['x-trace-id'], 'trace-123'); + // rpcId should be present + assert(res.data.headers['x-rpc-id']); + assert(res.data.headers['x-rpc-id'].startsWith('rpc-')); + }); + + it('should increment rpcId on each request', async () => { + const res1 = await app.curl(url + '/get_headers', { dataType: 'json' }); + const rpcId1 = parseInt(res1.data.headers['x-rpc-id'].replace('rpc-', ''), 10); + + const res2 = await app.curl(url + '/get_headers', { dataType: 'json' }); + const rpcId2 = parseInt(res2.data.headers['x-rpc-id'].replace('rpc-', ''), 10); + + assert(rpcId2 > rpcId1, `Expected ${rpcId2} > ${rpcId1}`); + }); + + it('should work with httpClient.request directly', async () => { + const res = await app.httpClient.request(url + '/get_headers', { dataType: 'json' }); + assert.equal(res.status, 200); + assert.equal(res.data.headers['x-trace-id'], 'trace-123'); + }); + }); + + describe('without interceptors configured', () => { + let app: MockApplication; + let url: string; + let serverInfo: { url: string; server: http.Server }; + + beforeAll(async () => { + app = createApp('apps/dnscache_httpclient'); + await app.ready(); + serverInfo = await startNewLocalServer(); + url = serverInfo.url; + }); + + afterAll(async () => { + if (serverInfo?.server?.listening) { + serverInfo.server.close(); + } + await app.close(); + }); + + it('should work normally without interceptors', async () => { + const res = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert.equal(res.status, 200); + // No trace headers injected + assert.equal(res.data.headers['x-trace-id'], undefined); + }); + }); +}); diff --git a/packages/egg/test/lib/core/httpclient_proxy.test.ts b/packages/egg/test/lib/core/httpclient_proxy.test.ts new file mode 100644 index 0000000000..f8aafd072b --- /dev/null +++ b/packages/egg/test/lib/core/httpclient_proxy.test.ts @@ -0,0 +1,126 @@ +import { strict as assert } from 'node:assert'; +import http from 'node:http'; + +import { mm } from '@eggjs/mock'; +import { describe, it, beforeAll, afterAll, afterEach } from 'vitest'; + +import { createApp, type MockApplication, startNewLocalServer } from '../../utils.js'; + +describe('test/lib/core/httpclient_proxy.test.ts', () => { + let app: MockApplication; + let url: string; + let serverInfo: { url: string; server: http.Server }; + + beforeAll(async () => { + app = createApp('apps/dnscache_httpclient'); + await app.ready(); + serverInfo = await startNewLocalServer(); + url = serverInfo.url; + }); + + afterAll(async () => { + if (serverInfo?.server?.listening) { + serverInfo.server.close(); + } + await app.close(); + }); + + afterEach(mm.restore); + + it('should app.httpClient return a proxy', () => { + // Accessing app.httpClient should not throw + const client = app.httpClient; + assert(client); + // The proxy should look like an HttpClient from the outside + assert(typeof client.request === 'function'); + assert(typeof client.curl === 'function'); + }); + + it('should app.httpclient be the same as app.httpClient', () => { + assert.equal(app.httpclient, app.httpClient); + }); + + it('should curl work through the proxy', async () => { + const res = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert.equal(res.status, 200); + }); + + it('should httpClient.request work through the proxy', async () => { + const res = await app.httpClient.request(url + '/get_headers', { dataType: 'json' }); + assert.equal(res.status, 200); + }); + + it('should proxy support get/set/has/delete/ownKeys/getPrototypeOf', () => { + const client = app.httpClient; + + // has + assert('request' in client); + assert('curl' in client); + + // get + assert.equal(typeof client.request, 'function'); + + // set (custom property) + (client as any)._customProp = 'test'; + assert.equal((client as any)._customProp, 'test'); + + // delete + delete (client as any)._customProp; + assert.equal((client as any)._customProp, undefined); + + // ownKeys + const keys = Object.keys(client); + assert(Array.isArray(keys)); + + // getPrototypeOf + const proto = Object.getPrototypeOf(client); + assert(proto); + }); + + it('should support mm() mock on httpClient (egg-mock compatibility)', async () => { + // Mock request via mm (uses Object.defineProperty internally) + mm(app.httpClient, 'request', async () => { + return { + status: 200, + headers: {}, + data: { mocked: true }, + }; + }); + + const res = await app.httpClient.request(url + '/get_headers'); + assert.equal(res.status, 200); + assert.deepEqual(res.data, { mocked: true }); + + // Restore + mm.restore(); + + // After restore, should use real HttpClient again + const realRes = await app.httpClient.request(url + '/get_headers', { dataType: 'json' }); + assert.equal(realRes.status, 200); + assert.notDeepEqual(realRes.data, { mocked: true }); + }); + + it('should support mm() mock on httpclient (deprecated alias)', async () => { + mm(app.httpclient, 'request', async () => { + return { + status: 418, + headers: {}, + data: 'teapot', + }; + }); + + const res = await app.httpclient.request(url + '/get_headers'); + assert.equal(res.status, 418); + assert.equal(res.data, 'teapot'); + + mm.restore(); + }); + + it('should custom lookup config take effect through proxy', async () => { + // The dnscache_httpclient fixture has a custom lookup that resolves + // all hostnames to 127.0.0.1, so we can test with a custom hostname + const customUrl = url.replace('127.0.0.1', 'custom-localhost'); + const res = await app.curl(customUrl + '/get_headers', { dataType: 'json' }); + assert.equal(res.status, 200); + }); +}); diff --git a/packages/egg/test/lib/core/utils.test.ts b/packages/egg/test/lib/core/utils.test.ts index 62ae0faf58..689f25bc42 100644 --- a/packages/egg/test/lib/core/utils.test.ts +++ b/packages/egg/test/lib/core/utils.test.ts @@ -171,4 +171,275 @@ describe('test/lib/core/utils.test.js', () => { assert.equal(utils.safeParseURL('https://eggjs.org!.foo.com')!.hostname, 'eggjs.org!.foo.com'); }); }); + + describe('createTransparentProxy()', () => { + it('should throw if createReal is not a function', () => { + assert.throws(() => { + utils.createTransparentProxy({ createReal: null as any }); + }, /createReal must be a function/); + }); + + it('should lazily create the real object', () => { + let created = false; + const proxy = utils.createTransparentProxy({ + createReal() { + created = true; + return { foo: 'bar' }; + }, + }); + assert.equal(created, false); + assert.equal((proxy as any).foo, 'bar'); + assert.equal(created, true); + }); + + it('should only call createReal once', () => { + let callCount = 0; + const proxy = utils.createTransparentProxy({ + createReal() { + callCount++; + return { value: 42 }; + }, + }); + (proxy as any).value; + (proxy as any).value; + (proxy as any).value; + assert.equal(callCount, 1); + }); + + it('should cache createReal errors', () => { + let callCount = 0; + const proxy = utils.createTransparentProxy({ + createReal() { + callCount++; + throw new Error('init failed'); + }, + }); + assert.throws(() => (proxy as any).foo, /init failed/); + assert.throws(() => (proxy as any).foo, /init failed/); + assert.equal(callCount, 1); + }); + + it('should support get/set/has/ownKeys/delete/getPrototypeOf', () => { + class MyClass { + name = 'test'; + count = 0; + greet() { + return `hello ${this.name}`; + } + } + const proxy = utils.createTransparentProxy({ + createReal: () => new MyClass(), + }); + + // get + assert.equal(proxy.name, 'test'); + assert.equal(proxy.greet(), 'hello test'); + + // set + proxy.count = 5; + assert.equal(proxy.count, 5); + + // has + assert('name' in proxy); + assert('greet' in proxy); + + // ownKeys + const keys = Object.keys(proxy); + assert(keys.includes('name')); + assert(keys.includes('count')); + + // getPrototypeOf / instanceof + assert(Object.getPrototypeOf(proxy) === MyClass.prototype); + // Note: instanceof won't work with Proxy by default since the target is {}, + // but getPrototypeOf returns the correct prototype + + // delete + assert.equal(delete (proxy as any).count, true); + assert.equal(proxy.count, undefined); + }); + + it('should be transparent to defineProperty-based monkeypatch (egg-mock mm)', () => { + const real = { + request(url: string) { + return `real:${url}`; + }, + }; + const proxy = utils.createTransparentProxy({ + createReal: () => real, + }); + + // Before mock + assert.equal((proxy as any).request('/api'), 'real:/api'); + + // Simulate mm() — uses Object.defineProperty to override + const originalDescriptor = Object.getOwnPropertyDescriptor(proxy, 'request'); + Object.defineProperty(proxy, 'request', { + value: (url: string) => `mock:${url}`, + configurable: true, + writable: true, + }); + assert.equal((proxy as any).request('/api'), 'mock:/api'); + + // Simulate mm.restore() — deletes the overlay property + if (originalDescriptor) { + Object.defineProperty(proxy, 'request', originalDescriptor); + } else { + delete (proxy as any).request; + } + assert.equal((proxy as any).request('/api'), 'real:/api'); + }); + + it('should bind real methods to real instance by default', () => { + class Counter { + #count = 0; + increment() { + this.#count++; + } + getCount() { + return this.#count; + } + } + const proxy = utils.createTransparentProxy({ + createReal: () => new Counter(), + }); + + // Methods should be bound to the real object, so private fields work + const { increment, getCount } = proxy; + increment(); + increment(); + assert.equal(getCount(), 2); + }); + + it('should not bind functions when bindFunctions=false', () => { + const obj = { + getValue() { + return this; + }, + }; + const proxy = utils.createTransparentProxy({ + createReal: () => obj, + bindFunctions: false, + }); + + // Without binding, `this` won't be the real object when destructured + const fn = (proxy as any).getValue; + assert.notEqual(fn(), obj); + }); + + it('should support Symbol properties', () => { + const sym = Symbol('test'); + const obj = { [sym]: 'symbol-value', normal: 'value' }; + const proxy = utils.createTransparentProxy({ + createReal: () => obj, + }); + + assert.equal((proxy as any)[sym], 'symbol-value'); + assert(sym in proxy); + }); + + it('should merge ownKeys from overlay and real object', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => ({ a: 1, b: 2 }), + }); + + // Add an overlay property + Object.defineProperty(proxy, 'c', { + value: 3, + configurable: true, + enumerable: true, + }); + + const keys = Object.keys(proxy); + assert(keys.includes('a')); + assert(keys.includes('b')); + assert(keys.includes('c')); + }); + + it('should return stable function references via boundFnCache', () => { + class Svc { + run() { + return 'ok'; + } + } + const proxy = utils.createTransparentProxy({ + createReal: () => new Svc(), + }); + + const ref1 = proxy.run; + const ref2 = proxy.run; + assert.equal(ref1, ref2, 'bound function reference should be stable'); + assert.equal(ref1(), 'ok'); + }); + + it('should support property descriptor with getter/setter', () => { + let _value = 10; + const proxy = utils.createTransparentProxy({ + createReal: () => ({ plain: 'hello' }), + }); + + // Define a property with getter/setter on overlay + Object.defineProperty(proxy, 'computed', { + get: () => _value * 2, + set: (v) => { + _value = v; + }, + configurable: true, + enumerable: true, + }); + + assert.equal((proxy as any).computed, 20); + (proxy as any).computed = 5; + assert.equal((proxy as any).computed, 10); + }); + + it('should work with array as real object', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => [1, 2, 3], + }); + + assert.equal(proxy.length, 3); + assert.equal(proxy[0], 1); + proxy.push(4); + assert.equal(proxy.length, 4); + assert.deepEqual(Array.from(proxy), [1, 2, 3, 4]); + }); + + it('should support complex inheritance chain', () => { + class Base { + baseMethod() { + return 'base'; + } + } + class Child extends Base { + childMethod() { + return 'child'; + } + } + const proxy = utils.createTransparentProxy({ + createReal: () => new Child(), + }); + + assert.equal(proxy.baseMethod(), 'base'); + assert.equal(proxy.childMethod(), 'child'); + assert.equal(Object.getPrototypeOf(proxy), Child.prototype); + }); + + it('should delete overlay property before real property', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => ({ key: 'real' }), + }); + + // Define overlay + Object.defineProperty(proxy, 'key', { + value: 'overlay', + configurable: true, + writable: true, + }); + assert.equal((proxy as any).key, 'overlay'); + + // Delete overlay — should reveal real + delete (proxy as any).key; + assert.equal((proxy as any).key, 'real'); + }); + }); });