Repository navigation
feat(test-utils): add createProxyquire and proxyquire helper - #9759
danieljbruce merged 4 commits into
Conversation
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a runtime-agnostic proxyquire utility in core/test-utils to support module stubbing in both Node.js and Bun environments, accompanied by comprehensive unit tests. A critical issue was identified in the ES6 Proxy's getOwnPropertyDescriptor trap, where unconditionally forcing configurable: true on all descriptors violates proxy invariants and can trigger a TypeError when proxying functions or objects with non-configurable properties. It is recommended to return the target's descriptor as-is if it exists, and only force configurability when falling back to the real object's descriptor.
| getOwnPropertyDescriptor(target, prop) { | ||
| const desc = | ||
| Reflect.getOwnPropertyDescriptor(target, prop) || | ||
| Reflect.getOwnPropertyDescriptor(real, prop); | ||
| return desc ? {...desc, configurable: true} : undefined; | ||
| }, |
There was a problem hiding this comment.
In ES6 Proxies, the getOwnPropertyDescriptor trap must respect strict invariants. Specifically, if a property exists on the target object (stub) and is non-configurable, the trap must not return it as configurable. Forcing configurable: true on all descriptors will cause a TypeError when proxying functions (which have non-configurable properties like prototype or length) or objects with other non-configurable properties.
Conversely, if a property is non-configurable on the real object but does not exist on the target object, it must be reported as configurable to avoid another invariant violation.
To satisfy both invariants, we should return the target's descriptor as-is if it exists, and only force configurable: true when falling back to the descriptor from the real object.
getOwnPropertyDescriptor(target, prop) {
const targetDesc = Reflect.getOwnPropertyDescriptor(target, prop);
if (targetDesc) {
return targetDesc;
}
const realDesc = Reflect.getOwnPropertyDescriptor(real, prop);
if (realDesc) {
return {...realDesc, configurable: true};
}
return undefined;
},There was a problem hiding this comment.
[Generated] Addressed in 214fe91: updated getOwnPropertyDescriptor to return targetDesc directly when the property exists on target (stub), and only force configurable: true when falling back to realDesc. Also added a unit test covering non-configurable stub properties (such as function prototype and configurable: false data descriptors).
There was a problem hiding this comment.
Code Review
This pull request introduces a custom, runtime-agnostic implementation of proxyquire to support module stubbing in both Node.js and Bun environments, along with corresponding unit tests. While the implementation is comprehensive, two critical issues were identified: a potential runtime TypeError in the Proxy's getOwnPropertyDescriptor trap when handling non-configurable properties, and a bug in the cache cleanup logic that incorrectly deletes the module under test from the cache even when cache preservation is enabled.
| getOwnPropertyDescriptor(target, prop) { | ||
| const desc = | ||
| Reflect.getOwnPropertyDescriptor(target, prop) || | ||
| Reflect.getOwnPropertyDescriptor(real, prop); | ||
| return desc ? {...desc, configurable: true} : undefined; | ||
| }, |
There was a problem hiding this comment.
There is a potential runtime TypeError when wrapping stubs in a Proxy if the stub has non-configurable properties, or if we try to redefine non-configurable properties from the real module on the proxy.
According to the ES6 Proxy invariants, the getOwnPropertyDescriptor trap must not report a property as configurable if it is non-configurable on the target object (the stub). Currently, the code unconditionally forces configurable: true on all descriptors, which will throw a TypeError if the property already exists on the stub as non-configurable.
To safely satisfy the Proxy invariants, we should only force configurable: true on descriptors retrieved from the real module when they do not exist on the stub (target) object.
getOwnPropertyDescriptor(target, prop) {
const targetDesc = Reflect.getOwnPropertyDescriptor(target, prop);
if (targetDesc) {
return targetDesc;
}
const realDesc = Reflect.getOwnPropertyDescriptor(real, prop);
return realDesc ? {...realDesc, configurable: true} : undefined;
},There was a problem hiding this comment.
[Generated] Addressed in 214fe91: getOwnPropertyDescriptor now returns Reflect.getOwnPropertyDescriptor(target, prop) as-is when defined on target and only forces configurable: true on descriptors read from real.
| for (const id of global ? Object.keys(cache) : [sut]) { | ||
| if (!id.endsWith('.node')) delete cache[id]; | ||
| } | ||
| for (const [id, m] of saved) { | ||
| if (m && (opts.keepCache || id !== sut)) cache[id] = m; | ||
| } |
There was a problem hiding this comment.
There is a bug in the cache cleanup logic when opts.keepCache is true (the default) and the module under test (sut) was not previously in the cache.
Currently, cache[sut] is deleted in the finally block regardless of whether it was originally in the cache or not. If sut was not in the cache before the proxyquire call, its saved value m is undefined. Therefore, the restore loop does not restore it, and the newly loaded sut module is permanently deleted from the cache. This violates the expected behavior of keepCache: true (where the loaded module should remain in the cache for subsequent requires).
To fix this, we should only delete cache[sut] if !opts.keepCache or if we have a saved module to restore for it.
const savedMap = new Map(saved);
for (const id of global ? Object.keys(cache) : [sut]) {
if (!id.endsWith('.node')) {
const original = savedMap.get(id);
if (!opts.keepCache || id !== sut || original !== undefined) {
delete cache[id];
}
}
}
for (const [id, m] of saved) {
if (m && (opts.keepCache || id !== sut)) cache[id] = m;
}There was a problem hiding this comment.
[Generated] Addressed in 214fe91: updated the finally cache cleanup block to only delete cache[sut] when !opts.keepCache or when a pre-existing original cached module is being restored from saved, and added unit test assertions verifying modCache[sutFile] under both noPreserveCache() and preserveCache().
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a runtime-agnostic proxyquire utility in core/test-utils to support stubbing dependencies in both Node.js and Bun environments, along with a comprehensive unit test suite. The review feedback points out critical issues when running in ESM or Bun environments where file:// URLs may be returned by stack traces or passed as caller arguments. To address this, the reviewer suggests importing and using fileURLToPath to normalize these URLs in both getCallerFile and resolveModule, preventing incorrect caller detection, options leakage, and path resolution failures.
| function getCallerFile(): string | undefined { | ||
| const origPrepare = Error.prepareStackTrace; | ||
| try { | ||
| const err = new Error(); | ||
| Error.prepareStackTrace = (_, stack) => stack; | ||
| const stack = err.stack as unknown as NodeJS.CallSite[] | undefined; | ||
| if (Array.isArray(stack)) { | ||
| for (const frame of stack) { | ||
| const file = frame.getFileName?.(); | ||
| if ( | ||
| file && | ||
| file !== __filename && | ||
| !file.startsWith('node:') && | ||
| !file.startsWith('internal/') | ||
| ) { | ||
| return file; | ||
| } | ||
| } | ||
| } | ||
| } finally { | ||
| Error.prepareStackTrace = origPrepare; | ||
| } | ||
| return undefined; | ||
| } |
There was a problem hiding this comment.
In environments like Bun or Node.js ESM, frame.getFileName() can return a file:// URL. If file is a file:// URL and __filename is an absolute path, the comparison file !== __filename will incorrectly evaluate to true for proxyquire.ts itself. This causes getCallerFile() to return proxyquire.ts instead of the actual caller.
This has two major consequences:
- Relative Path Resolution Failure: Relative paths for the SUT will be resolved relative to
proxyquire.tsinstead of the calling test file. - Options Leakage: Since options (like
noCallThru) are keyed by the caller's filename, returningproxyquire.tsfor all callers means all test files will share the same options bucket, leading to unexpected side effects across tests.
Normalizing the URL to an absolute path using fileURLToPath resolves this issue.
function getCallerFile(): string | undefined {
const origPrepare = Error.prepareStackTrace;
try {
const err = new Error();
Error.prepareStackTrace = (_, stack) => stack;
const stack = err.stack as unknown as NodeJS.CallSite[] | undefined;
if (Array.isArray(stack)) {
for (const frame of stack) {
let file = frame.getFileName?.();
if (file) {
if (file.startsWith('file://')) {
try {
file = fileURLToPath(file);
} catch {
// ignore
}
}
if (
file !== __filename &&
!file.startsWith('node:') &&
!file.startsWith('internal/')
) {
return file;
}
}
}
}
} finally {
Error.prepareStackTrace = origPrepare;
}
return undefined;
}There was a problem hiding this comment.
[Generated] Addressed in 2925587: normalized file via fileURLToPath(file) in getCallerFile() when file.startsWith('file://') before comparing against __filename.
| function resolveModule(target: NodeModule | string | undefined): NodeModule { | ||
| if (target && typeof target === 'object') { | ||
| return target; | ||
| } | ||
| const filename = | ||
| typeof target === 'string' ? target : (getCallerFile() ?? __filename); | ||
| const cached = require.cache[filename]; | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const fallback = new mod(filename); | ||
| fallback.filename = filename; | ||
| fallback.paths = mod._nodeModulePaths(path.dirname(filename)); | ||
| return fallback; | ||
| } |
There was a problem hiding this comment.
If a user passes import.meta.url (which is a file:// URL) as the caller argument to createProxyquire, resolveModule will fail to find it in require.cache and will pass the URL to path.dirname, leading to incorrect path resolution. Normalizing the filename string using fileURLToPath ensures robust compatibility with ESM callers.
function resolveModule(target: NodeModule | string | undefined): NodeModule {
if (target && typeof target === 'object') {
return target;
}
let filename =
typeof target === 'string' ? target : (getCallerFile() ?? __filename);
if (filename.startsWith('file://')) {
try {
filename = fileURLToPath(filename);
} catch {
// ignore
}
}
const cached = require.cache[filename];
if (cached) {
return cached;
}
const fallback = new mod(filename);
fallback.filename = filename;
fallback.paths = mod._nodeModulePaths(path.dirname(filename));
return fallback;
}There was a problem hiding this comment.
[Generated] Addressed in 2925587: normalized filename via fileURLToPath(filename) in resolveModule() when filename.startsWith('file://'), and added a unit test in test/proxyquire.test.ts verifying createProxyquire(pathToFileURL(sutFile).href).
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
🤖 I have created a release *beep* *boop* --- <details><summary>gapic-generator: 5.7.3</summary> ## [5.7.3](gapic-generator-v5.7.2...gapic-generator-v5.7.3) (2026-10-28) ### Bug Fixes * **core:** Log request/response payloads at DEBUG severity ([#9754](#9754)) ([bf46818](bf46818)) </details> <details><summary>google-gax: 6.14.0</summary> ## [6.14.0](google-gax-v6.13.0...google-gax-v6.14.0) (2026-10-28) ### Features * **gax:** Add observability logging flag ([#9656](#9656)) ([c0ab589](c0ab589)) ### Bug Fixes * **gax:** Suppress cancel destroy error in StreamArrayParser and add Bun certification matrix ([#9652](#9652)) ([a802909](a802909)) </details> <details><summary>gcp-metadata: 9.2.3</summary> ## [9.2.3](gcp-metadata-v9.2.2...gcp-metadata-v9.2.3) (2026-10-28) ### Bug Fixes * **core:** Log request/response payloads at DEBUG severity ([#9754](#9754)) ([bf46818](bf46818)) </details> <details><summary>google-auth-library: 11.3.2</summary> ## [11.3.2](google-auth-library-v11.3.1...google-auth-library-v11.3.2) (2026-10-28) ### Bug Fixes * **core:** Log request/response payloads at DEBUG severity ([#9754](#9754)) ([bf46818](bf46818)) * **google-auth-library:** Add credential deprecation and validation warnings ([#9720](#9720)) ([569678a](569678a)) </details> <details><summary>google-logging-utils: 2.1.1</summary> ## [2.1.1](google-logging-utils-v2.1.0...google-logging-utils-v2.1.1) (2026-10-28) ### Bug Fixes * **core:** Log request/response payloads at DEBUG severity ([#9754](#9754)) ([bf46818](bf46818)) * **logging-utils:** Export LogSeverity as value and treat 'true' as '*' alias ([#9718](#9718)) ([e8da4f0](e8da4f0)) </details> <details><summary>gapic-tools: 2.2.1</summary> ## [2.2.1](gapic-tools-v2.2.0...gapic-tools-v2.2.1) (2026-10-28) ### Bug Fixes * **gapic-tools:** Strip JS method bodies before pbts and parallelize minification ([#9737](#9737)) ([c7884b8](c7884b8)) </details> <details><summary>test-utils: 0.4.0</summary> ## [0.4.0](test-utils-v0.3.1...test-utils-v0.4.0) (2026-10-28) ### Features * **test-utils:** Add createProxyquire and proxyquire helper ([#9759](#9759)) ([763d1bd](763d1bd)) </details> <details><summary>bigtable: 7.5.1</summary> ## [7.5.1](bigtable-v7.5.0...bigtable-v7.5.1) (2026-10-28) ### Bug Fixes * **bigtable:** Use ReadonlyMap for EncodedKeyMap implements clause ([#9709](#9709)) ([f803bff](f803bff)) </details> <details><summary>pubsub: 6.3.2</summary> ## [6.3.2](pubsub-v6.3.1...pubsub-v6.3.2) (2026-10-28) ### Bug Fixes * **pubsub:** Reuse GoogleAuth instance and deprecate credential options ([#9721](#9721)) ([4c4ba34](4c4ba34)) </details> <details><summary>datamanager: 1.5.0</summary> ## [1.5.0](datamanager-v1.4.0...datamanager-v1.5.0) (2026-10-28) ### Features * **datamanager:** Update release_level to stable in .repo-metadata.json ([#9725](#9725)) ([6eb9340](6eb9340)), refs [#9685](#9685) </details> <details><summary>observability: 0.1.0</summary> ## 0.1.0 (2026-10-28) ### Features * **google/cloud/observability/v1:** Onboard a new library ([#9689](#9689)) ([5e53e2e](5e53e2e)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com>
Description
Adds runtime-agnostic
createProxyquireandproxyquirehelpers togoogle-test-utils.Impact
Releasing
createProxyquireingoogle-test-utilsavoids extra YAML flags across stacked PRs.Changes
createProxyquireandproxyquireimplementation incore/test-utils(google-test-utils) into a standalone PR.google-test-utils(core/test-utils) withcreateProxyquirefirst so that the rest of the stacked PRs (test: remove --proxyquire-shim from common, bigquery, datastore, and dns #9643, test: remove --proxyquire-shim from logging and pubsub packages #9644, test: remove --proxyquire-shim from bigtable, spanner, and storage #9645, chore: remove proxyquire-bun-shim.cjs and runner flags #9646) can consume the publishedgoogle-test-utilspackage and do not need the extra--filter "./core/test-utils" --ignore-scripts,pnpm --dir core/test-utils run compile,ensureWorkspacePackagesLinked(), andrun prepare --if-presentflags in the.yamlfiles (cloudbuild.yamland.github/actions/pnpm-lockfile-check/action.yaml).core/test-utils/src/proxyquire.ts: AddscreateProxyquire,proxyquire,Proxyquire, andProxyquireStubsbuilt onModule.prototype.requireandrequire.cachewith automatic caller path resolution (Error.prepareStackTrace) for Node.js and Bun (JS_RUNTIME=bun).core/test-utils/src/index.ts: ExportscreateProxyquire,proxyquire,Proxyquire, andProxyquireStubs.core/test-utils/test/proxyquire.test.ts: Adds unit tests covering direct/transitive@globalstubbing,@noCallThru/noCallThru()/callThru(),noPreserveCache()/preserveCache(),nullstubMODULE_NOT_FOUNDhandling, and bare module stubs.Testing
pnpm --dir core/test-utils test5x on Node.js andJS_RUNTIME=bun MOCHA_PARALLEL=false bun --bun run --cwd core/test-utils test5x on Bun (node ./bin/linter.mjs --strictandci/run_conditional_tests.sh --strict.Alternatives
createProxyquiredirectly inside test: remove --proxyquire-shim from common, bigquery, datastore, and dns #9643 alongside thecommon,bigquery,datastore, andgoogle-cloud-dnsmigrations was considered, but because.npmrcsetslink-workspace-packages=falseandenable-pre-post-scripts=true, doing so before releasinggoogle-test-utilsrequires extra--filter "./core/test-utils" --ignore-scriptsand workspace-linking flags across the YAML files in the stack.