Skip to content

feat(test-utils): add createProxyquire and proxyquire helper - #9759

Merged
danieljbruce merged 4 commits into
googleapis:mainfrom
danieljbruce:feat/test-utils-create-proxyquire
Oct 28, 2026
Merged

danieljbruce merged 4 commits into
googleapis:mainfrom
danieljbruce:feat/test-utils-create-proxyquire

Conversation

@danieljbruce

Copy link
Copy Markdown
Contributor

Description

Adds runtime-agnostic createProxyquire and proxyquire helpers to google-test-utils.

Impact

Releasing createProxyquire in google-test-utils avoids extra YAML flags across stacked PRs.

Changes

Testing

  • Executed pnpm --dir core/test-utils test 5x on Node.js and JS_RUNTIME=bun MOCHA_PARALLEL=false bun --bun run --cwd core/test-utils test 5x on Bun ($\ge 95%$ confidence, 25/25 unit tests passing).
  • Verified strict lint and TypeScript checks pass via node ./bin/linter.mjs --strict and ci/run_conditional_tests.sh --strict.

Alternatives

  • Including createProxyquire directly inside test: remove --proxyquire-shim from common, bigquery, datastore, and dns #9643 alongside the common, bigquery, datastore, and google-cloud-dns migrations was considered, but because .npmrc sets link-workspace-packages=false and enable-pre-post-scripts=true, doing so before releasing google-test-utils requires extra --filter "./core/test-utils" --ignore-scripts and workspace-linking flags across the YAML files in the stack.
  • Not merging this PR would leave those temporary YAML workarounds in the stack PRs.

@danieljbruce

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +194 to +199
getOwnPropertyDescriptor(target, prop) {
const desc =
Reflect.getOwnPropertyDescriptor(target, prop) ||
Reflect.getOwnPropertyDescriptor(real, prop);
return desc ? {...desc, configurable: true} : undefined;
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
                    },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +194 to +199
getOwnPropertyDescriptor(target, prop) {
const desc =
Reflect.getOwnPropertyDescriptor(target, prop) ||
Reflect.getOwnPropertyDescriptor(real, prop);
return desc ? {...desc, configurable: true} : undefined;
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
                    },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment on lines +221 to +226
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
      }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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().

@danieljbruce

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread core/test-utils/src/proxyquire.ts Resolved
Comment on lines +66 to +89
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. Relative Path Resolution Failure: Relative paths for the SUT will be resolved relative to proxyquire.ts instead of the calling test file.
  2. Options Leakage: Since options (like noCallThru) are keyed by the caller's filename, returning proxyquire.ts for 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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Generated] Addressed in 2925587: normalized file via fileURLToPath(file) in getCallerFile() when file.startsWith('file://') before comparing against __filename.

Comment on lines +91 to +105
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

danieljbruce and others added 2 commits October 28, 2026 10:21
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
@danieljbruce
danieljbruce marked this pull request as ready for review October 28, 2026 14:44
@danieljbruce
danieljbruce requested a review from a team as a code owner October 28, 2026 14:44
@github-actions
github-actions Bot requested a review from shivanee-p October 28, 2026 14:44
@danieljbruce
danieljbruce requested a review from quirogas October 28, 2026 14:47
@danieljbruce
danieljbruce merged commit 763d1bd into googleapis:main Oct 28, 2026
54 checks passed
@release-please release-please Bot mentioned this pull request Oct 28, 2026
danieljbruce pushed a commit that referenced this pull request Oct 28, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants