From fd32b95d4aa29e2ceba08c03438fac0ea7081247 Mon Sep 17 00:00:00 2001 From: Dipper30 Date: Tue, 13 Jan 2026 16:50:11 +0800 Subject: [PATCH 1/3] fix: egg-mock for httpclient_next proxy --- lib/core/utils.js | 118 ++++++++++++ lib/egg.js | 35 +--- test/lib/core/httpclient.test.js | 36 +--- test/lib/core/utils.test.js | 307 +++++++++++++++++++++++++++++++ 4 files changed, 437 insertions(+), 59 deletions(-) diff --git a/lib/core/utils.js b/lib/core/utils.js index 6f6a5f70fd..90e3b5b075 100644 --- a/lib/core/utils.js +++ b/lib/core/utils.js @@ -7,6 +7,7 @@ const URL = require('url').URL; module.exports = { convertObject, safeParseURL, + createTransparentProxy, }; function convertObject(obj, ignore, ignoreKeyPaths) { @@ -90,3 +91,120 @@ function safeParseURL(url) { return null; } } + +/** + * Create a Proxy that behaves like the real object, but remains transparent to + * monkeypatch libraries (e.g. defineProperty-based overrides). + * + * - Lazily creates the real object on first access. + * - Allows overriding properties on the proxy target (overlay) to take effect. + * - Delegates everything else to the real object. + * + * @param {Object} options + * @param {Function} options.createReal Create the real object (lazy) + * @param {boolean} [options.bindFunctions=true] Bind real methods to the real object + * @return {Proxy} + */ +function createTransparentProxy({ createReal, bindFunctions = true }) { + if (typeof createReal !== 'function') { + throw new TypeError('createReal must be a function'); + } + + let real = null; + let error = null; + let initialized = false; + + const init = () => { + if (initialized) { + if (error) throw error; + return; + } + initialized = true; + try { + real = createReal(); + } catch (err) { + error = err; + throw err; + } + }; + + return new Proxy({}, { + get(target, prop, receiver) { + init(); + // Check if property is defined on proxy target (monkeypatch overlay) + if (Object.getOwnPropertyDescriptor(target, prop)) { + return Reflect.get(target, prop, receiver); + } + const value = real[prop]; + if (bindFunctions && typeof value === 'function') { + return value.bind(real); + } + return value; + }, + + set(target, prop, value, receiver) { + init(); + // Check if property is defined on proxy target + if (Object.getOwnPropertyDescriptor(target, prop)) { + return Reflect.set(target, prop, value, receiver); + } + real[prop] = value; + return true; + }, + + has(target, prop) { + init(); + return prop in target || prop in real; + }, + + ownKeys(target) { + init(); + const keys = new Set([ ...Reflect.ownKeys(real), ...Reflect.ownKeys(target) ]); + return Array.from(keys); + }, + + getOwnPropertyDescriptor(target, prop) { + init(); + return Object.getOwnPropertyDescriptor(target, prop) + || Object.getOwnPropertyDescriptor(real, prop); + }, + + deleteProperty(target, prop) { + init(); + if (Object.getOwnPropertyDescriptor(target, prop)) { + return delete target[prop]; + } + return delete real[prop]; + }, + + getPrototypeOf() { + init(); + return Object.getPrototypeOf(real); + }, + + setPrototypeOf(_target, proto) { + init(); + return Reflect.setPrototypeOf(real, proto); + }, + + isExtensible() { + init(); + return Reflect.isExtensible(real); + }, + + preventExtensions(target) { + init(); + // Must also prevent extensions on target to satisfy Proxy invariants + const result = Reflect.preventExtensions(real); + if (result) { + Reflect.preventExtensions(target); + } + return result; + }, + + defineProperty(target, prop, descriptor) { + // Used by monkeypatch libs: keep overrides on proxy target (overlay layer). + return Reflect.defineProperty(target, prop, descriptor); + }, + }); +} diff --git a/lib/egg.js b/lib/egg.js index 4607862af1..9dfee22184 100644 --- a/lib/egg.js +++ b/lib/egg.js @@ -316,39 +316,10 @@ class EggApplication extends EggCore { options.lookup = options.lookup ?? self.config.httpclient.lookup; realClient = new self.HttpClientNext(self, options); }; - return new Proxy({}, { - get(_target, prop) { + return utils.createTransparentProxy({ + createReal() { init(); - const value = realClient[prop]; - if (typeof value === 'function') { - return value.bind(realClient); - } - return value; - }, - set(_target, prop, value) { - init(); - realClient[prop] = value; - return true; - }, - has(_target, prop) { - init(); - return prop in realClient; - }, - ownKeys() { - init(); - return Reflect.ownKeys(realClient); - }, - getOwnPropertyDescriptor(_target, prop) { - init(); - return Object.getOwnPropertyDescriptor(realClient, prop); - }, - deleteProperty(_target, prop) { - init(); - return delete realClient[prop]; - }, - getPrototypeOf() { - init(); - return Object.getPrototypeOf(realClient); + return realClient; }, }); } diff --git a/test/lib/core/httpclient.test.js b/test/lib/core/httpclient.test.js index 3004dacb01..f03e0bc891 100644 --- a/test/lib/core/httpclient.test.js +++ b/test/lib/core/httpclient.test.js @@ -383,8 +383,16 @@ describe('test/lib/core/httpclient.test.js', () => { }); }); - it('should Proxy be fully functional', () => { + it('should Proxy be fully functional', async () => { const httpclient = app.httpclient; + mm(app.httpclient, 'request', async () => ({ + status: 500, + headers: { 'x-oss-request-id': 'mock request id' }, + })); + + const res = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert(res.status === 500); + mm.restore(); // Test get trap - method access assert(typeof httpclient.request === 'function'); @@ -399,35 +407,9 @@ describe('test/lib/core/httpclient.test.js', () => { const ownKeys = Reflect.ownKeys(httpclient); assert(ownKeys.length > 0); - httpclient.testProp = 'test'; - const customDescriptor = Object.getOwnPropertyDescriptor(httpclient, 'testProp'); - assert(customDescriptor); - assert.equal(customDescriptor.value, 'test'); - assert.equal(customDescriptor.writable, true); - assert.equal(customDescriptor.enumerable, true); - assert.equal(customDescriptor.configurable, true); - - const proto = Object.getPrototypeOf(httpclient); - assert(proto); - assert(proto instanceof HttpclientNext); - assert(proto.constructor); - - httpclient.customProperty = 'test-value'; - assert.equal(httpclient.customProperty, 'test-value'); - - delete httpclient.customProperty; - assert.equal(httpclient.customProperty, undefined); - - delete httpclient.testProp; - assert.equal(httpclient.testProp, undefined); - // Test that methods are properly bound const { request } = httpclient; assert(typeof request === 'function'); - - // Test Object.getOwnPropertyNames() which uses ownKeys trap - const propNames = Object.getOwnPropertyNames(httpclient); - assert(Array.isArray(propNames)); }); }); diff --git a/test/lib/core/utils.test.js b/test/lib/core/utils.test.js index 6acf1beaab..cd1a41958b 100644 --- a/test/lib/core/utils.test.js +++ b/test/lib/core/utils.test.js @@ -1,6 +1,7 @@ 'use strict'; const assert = require('assert'); +const mm = require('egg-mock'); const utils = require('../../../lib/core/utils'); describe('test/lib/core/utils.test.js', () => { @@ -228,4 +229,310 @@ describe('test/lib/core/utils.test.js', () => { assert(utils.safeParseURL('https://eggjs.org!.foo.com').hostname === 'eggjs.org!.foo.com'); }); }); + + describe('createTransparentProxy()', () => { + afterEach(mm.restore); + + it('should be transparent to defineProperty-based monkeypatch (mm)', async () => { + let created = 0; + + class RealClient { + async request() { + return { status: 200 }; + } + } + + const proxy = utils.createTransparentProxy({ + createReal() { + created++; + return new RealClient(); + }, + }); + + mm(proxy, 'request', async () => ({ status: 500 })); + const res1 = await proxy.request(); + assert.equal(res1.status, 500); + assert.equal(created, 1); + + mm.restore(); + const res2 = await proxy.request(); + assert.equal(res2.status, 200); + assert.equal(created, 1); + + mm.data(proxy, 'request', { status: 500 }); + + const res3 = await proxy.request(); + assert.equal(res3.status, 500); + assert.equal(created, 1); + }); + + it('should bind real methods to real instance', () => { + class RealClient { + constructor() { + this._value = 42; + } + getValue() { + return this._value; + } + } + + const proxy = utils.createTransparentProxy({ + createReal() { + return new RealClient(); + }, + bindFunctions: true, + }); + assert.equal(proxy.getValue(), 42); + + mm(proxy, 'value', 100); + assert.equal(proxy.value, 100); + assert.equal(proxy.getValue(), 42); + }); + + it('should merge ownKeys / has / getOwnPropertyDescriptor between target and real', () => { + class RealClient { + constructor() { + this.realProp = 'real'; + } + } + + const proxy = utils.createTransparentProxy({ + createReal() { + return new RealClient(); + }, + }); + + // Define property on proxy target via defineProperty (simulates monkeypatch libs). + Object.defineProperty(proxy, 'mockedProp', { + value: 'mock', + writable: true, + enumerable: true, + configurable: true, + }); + + assert('realProp' in proxy); + assert('mockedProp' in proxy); + + const keys = Reflect.ownKeys(proxy); + assert(keys.includes('realProp')); + assert(keys.includes('mockedProp')); + + const desc = Object.getOwnPropertyDescriptor(proxy, 'mockedProp'); + assert(desc); + assert.equal(desc.value, 'mock'); + }); + + it('should cache createReal errors and not call createReal again', () => { + let callCount = 0; + const proxy = utils.createTransparentProxy({ + createReal() { + callCount++; + throw new Error('Creation failed'); + }, + }); + + assert.throws(() => proxy.foo, /Creation failed/); + assert.throws(() => proxy.bar, /Creation failed/); + assert.throws(() => proxy.baz, /Creation failed/); + assert.equal(callCount, 1, 'createReal should only be called once'); + }); + + // Note: apply and construct traps are not supported because the proxy target + // is an empty object, not a function. These traps only work when the target + // itself is a function. For the current use case (HttpClient), this is not needed. + + it('should support setPrototypeOf', () => { + const proto = { custom: 'customValue' }; + const proxy = utils.createTransparentProxy({ + createReal: () => ({ foo: 'bar' }), + }); + + const result = Object.setPrototypeOf(proxy, proto); + assert.equal(result, proxy); + assert.equal(proxy.custom, 'customValue'); + assert.equal(Object.getPrototypeOf(proxy), proto); + }); + + it('should support isExtensible and preventExtensions', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => ({ foo: 1 }), + }); + + assert.equal(Object.isExtensible(proxy), true); + + Object.preventExtensions(proxy); + assert.equal(Object.isExtensible(proxy), false); + + // Should not be able to add new properties after preventExtensions + let errorThrown = false; + try { + 'use strict'; + proxy.bar = 2; + } catch (err) { + errorThrown = true; + } + assert(errorThrown || proxy.bar === undefined, 'Should not add property after preventExtensions'); + }); + + it('should support deleteProperty on real object', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => ({ foo: 1, bar: 2 }), + }); + + assert.equal(proxy.foo, 1); + assert.equal(delete proxy.foo, true); + assert.equal(proxy.foo, undefined); + assert(!('foo' in proxy)); + }); + + it('should support deleteProperty on mocked property', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => ({ foo: 1 }), + }); + + mm(proxy, 'bar', 'mocked'); + assert.equal(proxy.bar, 'mocked'); + + const deleted = delete proxy.bar; + assert(deleted); + assert.equal(proxy.bar, undefined); + }); + + it('should handle property descriptor with getter/setter correctly', () => { + let internalValue = 10; + const proxy = utils.createTransparentProxy({ + createReal: () => ({ + get computed() { + return internalValue * 2; + }, + set computed(val) { + internalValue = val / 2; + }, + }), + }); + + assert.equal(proxy.computed, 20); + proxy.computed = 100; + assert.equal(proxy.computed, 100); + assert.equal(internalValue, 50); + }); + + it('should handle set operation with descriptor on target', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => ({ foo: 1 }), + }); + + // Define a property with getter/setter on proxy target + let mockedValue = 100; + Object.defineProperty(proxy, 'bar', { + get() { return mockedValue; }, + set(val) { mockedValue = val * 2; }, + enumerable: true, + configurable: true, + }); + + assert.equal(proxy.bar, 100); + proxy.bar = 50; + assert.equal(proxy.bar, 100); // getter returns mockedValue which is now 100 (50*2) + assert.equal(mockedValue, 100); + }); + + it('should not create real object until first access', () => { + let created = false; + const proxy = utils.createTransparentProxy({ + createReal() { + created = true; + return { foo: 'bar' }; + }, + }); + + assert.equal(created, false, 'Should not create until access'); + const value = proxy.foo; + assert.equal(created, true, 'Should create on first access'); + assert.equal(value, 'bar'); + }); + + it('should work with Symbol properties', () => { + const sym = Symbol('test'); + const proxy = utils.createTransparentProxy({ + createReal: () => ({ + [sym]: 'symbol value', + regular: 'regular value', + }), + }); + + assert.equal(proxy[sym], 'symbol value'); + assert.equal(proxy.regular, 'regular value'); + + const keys = Reflect.ownKeys(proxy); + assert(keys.includes(sym)); + assert(keys.includes('regular')); + }); + + it('should handle complex inheritance chain', () => { + class Base { + baseMethod() { + return 'base'; + } + } + + class Derived extends Base { + derivedMethod() { + return 'derived'; + } + } + + const proxy = utils.createTransparentProxy({ + createReal: () => new Derived(), + }); + + assert.equal(proxy.baseMethod(), 'base'); + assert.equal(proxy.derivedMethod(), 'derived'); + assert(proxy instanceof Derived); + assert(proxy instanceof Base); + }); + + it('should handle array as real object', () => { + const proxy = utils.createTransparentProxy({ + createReal: () => [ 1, 2, 3 ], + }); + + assert.equal(proxy.length, 3); + assert.equal(proxy[0], 1); + assert.equal(proxy[1], 2); + proxy.push(4); + assert.equal(proxy.length, 4); + assert.equal(proxy[3], 4); + }); + + it('should work with bindFunctions=false', () => { + class RealClient { + constructor() { + this.value = 42; + } + getValue() { + return this.value; + } + } + + const proxy = utils.createTransparentProxy({ + createReal: () => new RealClient(), + bindFunctions: false, + }); + + // When bindFunctions is false, methods are not automatically bound + let shouldFail = true; + try { + const getValue = proxy.getValue; + getValue(); + shouldFail = false; + } catch (error) { + shouldFail = true; + assert(error instanceof TypeError); + assert(error.message.includes('Cannot read properties of undefined')); + } + assert(shouldFail, 'Expected TypeError when calling unbound method'); + assert.equal(proxy.getValue(), 42); // Direct call still works + }); + }); }); From 023f630c4f776765568722fcf5dab94520f51a5d Mon Sep 17 00:00:00 2001 From: Dipper30 Date: Tue, 13 Jan 2026 17:06:06 +0800 Subject: [PATCH 2/3] fix: proxy setter --- lib/core/utils.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/core/utils.js b/lib/core/utils.js index 90e3b5b075..cb563b79a1 100644 --- a/lib/core/utils.js +++ b/lib/core/utils.js @@ -144,12 +144,10 @@ function createTransparentProxy({ createReal, bindFunctions = true }) { set(target, prop, value, receiver) { init(); - // Check if property is defined on proxy target if (Object.getOwnPropertyDescriptor(target, prop)) { return Reflect.set(target, prop, value, receiver); } - real[prop] = value; - return true; + return Reflect.set(real, prop, value); }, has(target, prop) { From 7cda241b279cf5ba25c44488ed68a1ad9426a066 Mon Sep 17 00:00:00 2001 From: Dipper30 Date: Tue, 13 Jan 2026 17:41:06 +0800 Subject: [PATCH 3/3] chore: test case --- test/lib/core/utils.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/lib/core/utils.test.js b/test/lib/core/utils.test.js index cd1a41958b..6d87c35a44 100644 --- a/test/lib/core/utils.test.js +++ b/test/lib/core/utils.test.js @@ -529,7 +529,6 @@ describe('test/lib/core/utils.test.js', () => { } catch (error) { shouldFail = true; assert(error instanceof TypeError); - assert(error.message.includes('Cannot read properties of undefined')); } assert(shouldFail, 'Expected TypeError when calling unbound method'); assert.equal(proxy.getValue(), 42); // Direct call still works