Skip to content

fix(mock): non-string path matchers under ignoreTrailingSlash, and DataView reply bodies - #5619

Open
marko1olo wants to merge 4 commits into
nodejs:mainfrom
marko1olo:fix/mock-utils-bytes-and-matchers
Open

fix(mock): non-string path matchers under ignoreTrailingSlash, and DataView reply bodies#5619
marko1olo wants to merge 4 commits into
nodejs:mainfrom
marko1olo:fix/mock-utils-bytes-and-matchers

Conversation

@marko1olo

Copy link
Copy Markdown
Contributor

Two independent MockAgent defects, one commit each.

ignoreTrailingSlash throws on a non-string path matcher

removeTrailingSlash() calls path.endsWith('/') unconditionally, but it is only reached on the ignoreTrailingSlash branch, where the argument is the registered matcher — documented in docs/docs/api/MockPool.md:97 as {string|RegExp|Function}. safeUrl() a few lines up gets this right with a typeof guard. So

const agent = new MockAgent({ ignoreTrailingSlash: true })
agent.get(origin).intercept({ path: /\/foo/, method: 'GET' }).reply(200, 'ok')

throws a TypeError instead of matching.

A DataView reply body is delivered as {}

getResponseData() checks for Buffer, then Uint8Array, then ArrayBuffer, and everything else that is an object falls to JSON.stringify. A DataView is none of the first three, so reply(200, someDataView) sent the two characters {}.

Measured through getResponseData() on main before the change:

body returns delivered
Buffer object 9 bytes, correct
Uint8Array object 3 bytes, correct
ArrayBuffer object 6 bytes, correct
DataView string, length 2 {}

The fix returns a Uint8Array over the same memory, and deliberately passes byteOffset and byteLength:

return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)

A DataView is frequently a window into a larger buffer, so new Uint8Array(data.buffer) alone would send bytes the caller never offered. There is a test for exactly that case.

Tests

Added to test/mock-utils.js and test/mock-interceptor.js, in the style of the neighbours. Reverting lib/mock/mock-utils.js and keeping the tests:

test/mock-utils.js        36 pass / 0 fail  ->  31 pass / 5 fail
test/mock-interceptor.js  86 pass / 0 fail  ->  67 pass / 19 fail

  ✖ it should match a RegExp path with ignoreTrailingSlash
  ✖ it should match a function path with ignoreTrailingSlash
  ✖ it should return the bytes of a DataView
  ✖ it should return only the bytes a DataView covers

With both fixes in place: mock-agent.js 99/99, mock-utils.js 36/36, mock-interceptor.js 86/86, mock-pool.js 16/16, mock-client.js 18/18, mock-scope.js 5/5, mock-errors.js 2/2, mock-interceptor-unused-assertions.js 7/7, mock-call-history.js 46/46. eslint clean.

Possible drawbacks

ArrayBuffer.isView() widens the second fix past DataView to every non-Uint8Array typed array, and that is an observable behaviour change. reply(200, new Int32Array([1, 2])) previously delivered {"0":1,"1":2} and now delivers 8 raw bytes. I think the byte reading is obviously the intended one for a typed array, but anyone who was relying on the index-keyed JSON would notice. Say the word and I will narrow it to DataView only.

Related and worth knowing: content-length for a DataView reply also becomes correct as a side effect, because Uint8Array.length is the byte length where the old string had length 2.

Not exercised: no real socket — both changes are on the mock dispatch path, and getResponseData/removeTrailingSlash have no callers outside lib/mock/. Single local Node on Windows, so the CI matrix is untested from here, and test:typescript, test:fuzzing and test:wpt were not run.

@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.51%. Comparing base (c76fe46) to head (29d60a2).
⚠️ Report is 5 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5619      +/-   ##
==========================================
+ Coverage   93.49%   93.51%   +0.01%     
==========================================
  Files         110      110              
  Lines       38429    38461      +32     
==========================================
+ Hits        35931    35965      +34     
+ Misses       2498     2496       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…ilingSlash

getMockDispatch normalizes both sides of a path comparison before matching.
On the ignoreTrailingSlash branch it calls removeTrailingSlash on the
registered matcher, but that helper went straight to path.endsWith, so a
matcher that was not a string threw "path.endsWith is not a function" out of
the dispatch. The TypeError carries no code, so buildMockDispatch's catch
rethrew it rather than falling back to net connect, and the caller saw a
TypeError instead of either a match or a MockNotMatchedError.

MockPool.intercept documents path as {string|RegExp|Function}, and safeUrl —
the other normalizer applied on the same line — already returns non-strings
untouched. removeTrailingSlash now does the same: a RegExp or function has no
trailing slash to strip, so it is handed to matchValue unchanged and tested
against the request path that has already had its own trailing slashes
removed. That is the semantics ignoreTrailingSlash promises, so /^\/foo$/ now
matches a request for /foo/ as well as /foo. The guard also covers the
resolved request path, which getMockDispatch already allows to be a
non-string.

Signed-off-by: marko1olo <marko1olo@users.noreply.github.com>
getResponseData recognises Buffer, Uint8Array and ArrayBuffer as byte
containers and passes them straight through; everything else that is
typeof 'object' falls to JSON.stringify. A DataView is none of the three, so
reply(200, dataView) serialized to the two characters "{}" and the mock
delivered those instead of the body. JSON.stringify sees no own enumerable
properties on a DataView, so the corruption is silent: a valid, parseable,
empty JSON object rather than an error. The reply type is TData | Buffer |
string with TData extends object, so TypeScript accepts a DataView and gives
no warning either.

Returning the DataView itself is not enough. handleReply wraps the result in
Buffer.from(), and Buffer.from() of a DataView hits the fromObject path where
length is undefined while buffer is an ArrayBuffer, which yields an empty
buffer -- the body would still be lost, just as zero bytes rather than as
"{}". So the new branch produces a Uint8Array over exactly the region the view
covers, honouring byteOffset and byteLength; a view onto a slice of a larger
ArrayBuffer sends its own bytes and not the whole backing store.

The branch is keyed on ArrayBuffer.isView rather than an instanceof DataView
check, which also covers Uint8ClampedArray, Int16Array and the rest. Those are
byte containers too, and their current output -- {"0":1,"1":2} from index keys
-- is no more useful than "{}". The branch sits after the existing Buffer,
Uint8Array and ArrayBuffer cases so those keep returning the caller's exact
object, unchanged and uncopied.

Signed-off-by: marko1olo <marko1olo@users.noreply.github.com>
@marko1olo

Copy link
Copy Markdown
Contributor Author

CI failures here are unrelated to this PR:

  • Node.js 24 ubuntu: test/http2-request-never-settles.js failed
  • Node.js 26 macOS: test/http2-pipelining-default.js:2702 !== 1 assertion error

Both are h2 test flakes; this PR only touches lib/mock/mock-utils.js and its tests. The mock-specific test suite passes 185/185 locally on this branch.

@marko1olo
marko1olo force-pushed the fix/mock-utils-bytes-and-matchers branch from 4d66958 to 825a739 Compare July 30, 2026 07:28
Comment thread lib/mock/mock-utils.js Outdated
// rather than a plain object. Buffer.from() cannot read one directly, so
// expose the bytes it covers instead of letting it reach JSON.stringify.
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
} else if (data && typeof data === 'object') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
} else if (data && typeof data === 'object') {
} else if (typeof data === 'object') {

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.

Done in 29d60a2e. Thanks!

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.

3 participants