From d23ff5cd6a221f482b982af8d9620f93590d72be Mon Sep 17 00:00:00 2001 From: Loparev Date: Fri, 17 Apr 2026 19:02:50 +0300 Subject: [PATCH 1/3] feat: CON-2075 replace merge-deep with deepmerge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the unmaintained merge-deep dependency (along with 15 transitive CommonJS micro-packages) in favor of deepmerge — a zero-dependency, ESM-friendly library with built-in TypeScript types. A custom arrayMerge option preserves the ordered-unique-concat (union) semantics that merge-deep exposed via arr-union, so callers that rely on the old array handling see no behavior change. New characterization tests in test/base-api-merge.spec.ts pin the deep-merge contract (library-level + SmartlingBaseApi integration) and all 234 tests pass on both the old and new dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/base/base-api.ts | 17 ++- package-lock.json | 181 ++--------------------------- package.json | 12 +- test/base-api-merge.spec.ts | 222 ++++++++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 177 deletions(-) create mode 100644 test/base-api-merge.spec.ts diff --git a/api/base/base-api.ts b/api/base/base-api.ts index a25b42c..253bc56 100644 --- a/api/base/base-api.ts +++ b/api/base/base-api.ts @@ -1,4 +1,4 @@ -import merge from "merge-deep"; +import deepmerge from "deepmerge"; import ua from "default-user-agent"; import FormData from "form-data"; import fetch from "cross-fetch"; @@ -11,6 +11,21 @@ import { ResponseBodyType } from "./enum/response-body-type"; /* eslint-disable-next-line @typescript-eslint/no-var-requires */ const packageJson = require("../../package.json"); +// Preserves the ordered-unique-concat (union) array semantics that the +// previous merge-deep dependency provided, so deep-merging options/headers +// that contain arrays remains backwards-compatible for existing callers. +const mergeOptions: deepmerge.Options = { + arrayMerge: (target, source) => source.reduce( + (acc, item) => (acc.includes(item) ? acc : [...acc, item]), + [...target] + ) +}; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export function merge(target: Record, source: Record): any { + return deepmerge(target, source, mergeOptions); +} + export class SmartlingBaseApi { protected authApi: AccessTokenProvider = undefined; protected entrypoint: string; diff --git a/package-lock.json b/package-lock.json index e37241c..158d307 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,9 @@ "@types/mocha": "^9.0.0", "@types/node": "^18.19.115", "cross-fetch": "^3.1.4", + "deepmerge": "^4.3.1", "default-user-agent": "^1.0.0", "form-data": "^4.0.4", - "merge-deep": "^3.0.3", "querystring": "^0.2.1", "semver": "^5.7.2", "string-to-file-stream": "^2.0.0", @@ -1064,15 +1064,6 @@ "node": ">= 0.4" } }, - "node_modules/arr-union": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", - "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -1636,22 +1627,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/clone-deep": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", - "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", - "license": "MIT", - "dependencies": { - "for-own": "^0.1.3", - "is-plain-object": "^2.0.1", - "kind-of": "^3.0.2", - "lazy-cache": "^1.0.3", - "shallow-clone": "^0.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1855,6 +1830,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -2985,27 +2969,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/for-in": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", - "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/for-own": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", - "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", - "license": "MIT", - "dependencies": { - "for-in": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3671,6 +3634,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, "license": "MIT" }, "node_modules/is-callable": { @@ -3737,15 +3701,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3878,18 +3833,6 @@ "node": ">=8" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4092,15 +4035,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -4364,18 +4298,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "license": "MIT", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -4396,15 +4318,6 @@ "node": ">=0.10" } }, - "node_modules/lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4572,20 +4485,6 @@ "is-buffer": "~1.1.6" } }, - "node_modules/merge-deep": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", - "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", - "license": "MIT", - "dependencies": { - "arr-union": "^3.1.0", - "clone-deep": "^0.2.4", - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4653,28 +4552,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/mixin-object": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", - "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", - "license": "MIT", - "dependencies": { - "for-in": "^0.1.3", - "is-extendable": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/mixin-object/node_modules/for-in": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", - "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", @@ -6018,42 +5895,6 @@ "node": ">= 0.4" } }, - "node_modules/shallow-clone": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", - "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.1", - "kind-of": "^2.0.1", - "lazy-cache": "^0.2.3", - "mixin-object": "^2.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/kind-of": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", - "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", - "license": "MIT", - "dependencies": { - "is-buffer": "^1.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/shallow-clone/node_modules/lazy-cache": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", - "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 984d7e4..cad720c 100644 --- a/package.json +++ b/package.json @@ -31,19 +31,20 @@ "@types/mocha": "^9.0.0", "@types/node": "^18.19.115", "cross-fetch": "^3.1.4", + "deepmerge": "^4.3.1", "default-user-agent": "^1.0.0", "form-data": "^4.0.4", - "merge-deep": "^3.0.3", "querystring": "^0.2.1", + "semver": "^5.7.2", "string-to-file-stream": "^2.0.0", - "typescript": "^4.9.5", - "semver": "^5.7.2" + "typescript": "^4.9.5" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/parser": "^4.29.0", "ansi-regex": "^5.0.1", "babel-eslint": "^10.1.0", + "braces": "^3.0.3", "eslint": "^7.31.0", "eslint-config-airbnb": "^19.0.4", "eslint-plugin-flowtype": "^2.41.0", @@ -52,13 +53,12 @@ "eslint-plugin-jsx-a11y": "^6.0.3", "eslint-plugin-promise": "^3.6.0", "eslint-plugin-react": "^7.10.0", + "json5": "^1.0.2", "mocha": "^10.0.0", "mocha-junit-reporter": "^2.0.2", "nyc": "^17.1.0", "sinon": "^11.1.2", - "ts-mocha": "^10.0.0", - "braces": "^3.0.3", - "json5": "^1.0.2" + "ts-mocha": "^10.0.0" }, "nyc": { "reporter": [ diff --git a/test/base-api-merge.spec.ts b/test/base-api-merge.spec.ts new file mode 100644 index 0000000..59924f1 --- /dev/null +++ b/test/base-api-merge.spec.ts @@ -0,0 +1,222 @@ +import assert from "assert"; +import sinon from "sinon"; +import { SmartlingBaseApi } from "../api/base/index"; +import { merge } from "../api/base/base-api"; +import { loggerMock, responseMock, authMock } from "./mock"; + +// CON-2075: pins the deep-merge semantics SmartlingBaseApi relies on across the +// swap from merge-deep to deepmerge. Library-level cases exercise the exported +// `merge` helper directly so the tests pin behavior at the same entry point +// the production code uses. + +describe("Deep merge behavior (CON-2075).", () => { + describe("Library-level behavior (direct merge calls)", () => { + it("overrides a target primitive with the source primitive at the same key", () => { + assert.deepEqual(merge({ a: 1 }, { a: 2 }), { a: 2 }); + }); + + it("combines disjoint keys from target and source", () => { + assert.deepEqual(merge({ a: 1 }, { b: 2 }), { a: 1, b: 2 }); + }); + + it("recursively merges one level of nested objects; source wins on conflicts", () => { + assert.deepEqual( + merge( + { headers: { Authorization: "a", "Content-Type": "json" } }, + { headers: { "Content-Type": "xml", "X-Custom": "x" } } + ), + { + headers: { + Authorization: "a", + "Content-Type": "xml", + "X-Custom": "x" + } + } + ); + }); + + it("recursively merges multi-level nested objects", () => { + assert.deepEqual( + merge({ a: { b: { c: 1, d: 2 } } }, { a: { b: { d: 20, e: 3 } } }), + { a: { b: { c: 1, d: 20, e: 3 } } } + ); + }); + + it("replaces a target primitive with a source object at the same key", () => { + assert.deepEqual(merge({ a: 1 }, { a: { b: 2 } }), { a: { b: 2 } }); + }); + + it("does not mutate the target object", () => { + const target = { a: 1, b: { c: 2 } }; + const snapshot = JSON.parse(JSON.stringify(target)); + merge(target, { b: { d: 3 } }); + assert.deepEqual(target, snapshot); + }); + + it("does not mutate the source object", () => { + const source = { b: { d: 3 } }; + const snapshot = JSON.parse(JSON.stringify(source)); + merge({ a: 1 }, source); + assert.deepEqual(source, snapshot); + }); + + it("returns a new object rather than a reference to either input", () => { + const target = { a: { b: 1 } }; + const source = { c: 2 }; + const result = merge(target, source); + assert.notStrictEqual(result, target); + assert.notStrictEqual(result.a, target.a); + }); + + it("returns the source content when target is empty", () => { + assert.deepEqual(merge({}, { a: 1, b: { c: 2 } }), { a: 1, b: { c: 2 } }); + }); + + it("returns the target content when source is empty", () => { + assert.deepEqual(merge({ a: 1, b: { c: 2 } }, {}), { a: 1, b: { c: 2 } }); + }); + + it("concatenates disjoint arrays at the same key in source order", () => { + assert.deepEqual( + merge({ items: ["a"] }, { items: ["b", "c"] }), + { items: ["a", "b", "c"] } + ); + }); + + it("de-duplicates overlapping array values while preserving order (merge-deep array-union semantics)", () => { + // merge-deep delegates array handling to arr-union, so repeated + // values are collapsed. deepmerge does plain concat by default, so + // the swap must supply a custom arrayMerge to preserve this. + assert.deepEqual( + merge({ items: [1, 2, 3] }, { items: [2, 3, 4] }), + { items: [1, 2, 3, 4] } + ); + }); + + it("concatenates arrays at nested paths with the same union semantics", () => { + assert.deepEqual( + merge({ group: { items: [1, 2] } }, { group: { items: [2, 3] } }), + { group: { items: [1, 2, 3] } } + ); + }); + }); + + describe("SmartlingBaseApi integration (pins the call-site behavior)", () => { + let base; + let uaStub; + + beforeEach(() => { + base = new SmartlingBaseApi(loggerMock); + base.authApi = authMock; + uaStub = sinon.stub(base, "ua"); + uaStub.returns("test_user_agent"); + }); + + afterEach(() => { + uaStub.restore(); + }); + + it("getDefaultHeaders: caller-supplied Content-Type overrides the default", async () => { + const headers = await base.getDefaultHeaders({ + "Content-Type": "application/xml" + }); + + assert.deepEqual(headers, { + Authorization: "test_token_type test_access_token", + "Content-Type": "application/xml", + "User-Agent": "test_user_agent" + }); + }); + + it("getDefaultHeaders: caller-supplied Authorization overrides the default", async () => { + const headers = await base.getDefaultHeaders({ Authorization: "Bearer custom" }); + + assert.equal(headers.Authorization, "Bearer custom"); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers["User-Agent"], "test_user_agent"); + }); + + it("getDefaultHeaders: adds new caller-supplied keys while preserving defaults", async () => { + const headers = await base.getDefaultHeaders({ + "X-SL-ServiceOrigin": "foo", + "X-Request-Id": "abc-123" + }); + + assert.deepEqual(headers, { + Authorization: "test_token_type test_access_token", + "Content-Type": "application/json", + "User-Agent": "test_user_agent", + "X-SL-ServiceOrigin": "foo", + "X-Request-Id": "abc-123" + }); + }); + + it("makeRequest: nested non-header option fields survive the options merge", async () => { + const fetchStub = sinon.stub(base, "fetch").returns(responseMock); + const textStub = sinon.stub(responseMock, "text").returns("{\"response\": {\"data\": {}}}"); + + try { + base.setOptions({ + timeout: 10000, + agent: { keepAlive: true, maxSockets: 50 } + }); + + await base.makeRequest("POST", "https://test.com", { foo: "bar" }); + + const passedOpts = fetchStub.firstCall.args[1]; + assert.equal(passedOpts.timeout, 10000); + assert.deepEqual(passedOpts.agent, { keepAlive: true, maxSockets: 50 }); + assert.equal(passedOpts.method, "POST"); + } finally { + textStub.restore(); + fetchStub.restore(); + } + }); + + it("makeRequest: options.headers deep-merge with the default headers (source wins on conflicts)", async () => { + const fetchStub = sinon.stub(base, "fetch").returns(responseMock); + const textStub = sinon.stub(responseMock, "text").returns("{\"response\": {\"data\": {}}}"); + + try { + base.setOptions({ + headers: { + "X-SL-ServiceOrigin": "foo-bar", + "Content-Type": "application/xml" + } + }); + + await base.makeRequest("POST", "https://test.com", { foo: "bar" }); + + const passedOpts = fetchStub.firstCall.args[1]; + assert.deepEqual(passedOpts.headers, { + Authorization: "test_token_type test_access_token", + "Content-Type": "application/xml", + "User-Agent": "test_user_agent", + "X-SL-ServiceOrigin": "foo-bar" + }); + } finally { + textStub.restore(); + fetchStub.restore(); + } + }); + + it("makeRequest: calling setOptions twice does not accumulate stale state on the caller-provided object", async () => { + const fetchStub = sinon.stub(base, "fetch").returns(responseMock); + const textStub = sinon.stub(responseMock, "text").returns("{\"response\": {\"data\": {}}}"); + + try { + const userOptions = { headers: { "X-Custom": "first" } }; + base.setOptions(userOptions); + + await base.makeRequest("POST", "https://test.com", { foo: "bar" }); + + // The object the user passed to setOptions must be unchanged + // by the merge the SDK does internally. + assert.deepEqual(userOptions, { headers: { "X-Custom": "first" } }); + } finally { + textStub.restore(); + fetchStub.restore(); + } + }); + }); +}); From a1546c0aa5b0f0b472b21fb65da4e1a562589b32 Mon Sep 17 00:00:00 2001 From: Loparev Date: Fri, 17 Apr 2026 19:25:33 +0300 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20extract=20merge=20helper=20and=20extend=20test=20co?= =?UTF-8?q?verage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the deep-merge helper out of base-api.ts into api/base/merge.ts so it no longer looks like a public symbol on the main API surface. Tighten its signature from any to a generic T & U to drop the eslint-disable. Strip ticket-id and migration framing from comments so they stay useful after the merge-deep swap is ancient history. Extend test coverage with null/undefined source values, array vs object type mismatches, reference-based array-of-objects dedup, two prototype pollution cases, and an integration test that flows array-valued headers through setOptions to prove the arrayMerge option is reached in production. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/base/base-api.ts | 17 +------ api/base/merge.ts | 15 ++++++ test/base-api-merge.spec.ts | 98 +++++++++++++++++++++++++++++++------ 3 files changed, 99 insertions(+), 31 deletions(-) create mode 100644 api/base/merge.ts diff --git a/api/base/base-api.ts b/api/base/base-api.ts index 253bc56..87c0c15 100644 --- a/api/base/base-api.ts +++ b/api/base/base-api.ts @@ -1,4 +1,3 @@ -import deepmerge from "deepmerge"; import ua from "default-user-agent"; import FormData from "form-data"; import fetch from "cross-fetch"; @@ -7,25 +6,11 @@ import { Logger } from "../logger"; import { SmartlingException } from "../exception/index"; import { AccessTokenProvider } from "../auth/access-token-provider"; import { ResponseBodyType } from "./enum/response-body-type"; +import { merge } from "./merge"; /* eslint-disable-next-line @typescript-eslint/no-var-requires */ const packageJson = require("../../package.json"); -// Preserves the ordered-unique-concat (union) array semantics that the -// previous merge-deep dependency provided, so deep-merging options/headers -// that contain arrays remains backwards-compatible for existing callers. -const mergeOptions: deepmerge.Options = { - arrayMerge: (target, source) => source.reduce( - (acc, item) => (acc.includes(item) ? acc : [...acc, item]), - [...target] - ) -}; - -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export function merge(target: Record, source: Record): any { - return deepmerge(target, source, mergeOptions); -} - export class SmartlingBaseApi { protected authApi: AccessTokenProvider = undefined; protected entrypoint: string; diff --git a/api/base/merge.ts b/api/base/merge.ts new file mode 100644 index 0000000..a4fc37d --- /dev/null +++ b/api/base/merge.ts @@ -0,0 +1,15 @@ +import deepmerge from "deepmerge"; + +// Deep-merge arrays as an ordered union: keep the target order, then append +// items from the source that aren't already present. Prevents header and +// option arrays from duplicating when a caller's values overlap the defaults. +const mergeOptions: deepmerge.Options = { + arrayMerge: (target, source) => source.reduce( + (acc, item) => (acc.includes(item) ? acc : [...acc, item]), + [...target] + ) +}; + +export function merge(target: T, source: U): T & U { + return deepmerge(target, source, mergeOptions); +} diff --git a/test/base-api-merge.spec.ts b/test/base-api-merge.spec.ts index 59924f1..1a73338 100644 --- a/test/base-api-merge.spec.ts +++ b/test/base-api-merge.spec.ts @@ -1,15 +1,10 @@ import assert from "assert"; import sinon from "sinon"; import { SmartlingBaseApi } from "../api/base/index"; -import { merge } from "../api/base/base-api"; +import { merge } from "../api/base/merge"; import { loggerMock, responseMock, authMock } from "./mock"; -// CON-2075: pins the deep-merge semantics SmartlingBaseApi relies on across the -// swap from merge-deep to deepmerge. Library-level cases exercise the exported -// `merge` helper directly so the tests pin behavior at the same entry point -// the production code uses. - -describe("Deep merge behavior (CON-2075).", () => { +describe("SmartlingBaseApi deep merge", () => { describe("Library-level behavior (direct merge calls)", () => { it("overrides a target primitive with the source primitive at the same key", () => { assert.deepEqual(merge({ a: 1 }, { a: 2 }), { a: 2 }); @@ -83,22 +78,69 @@ describe("Deep merge behavior (CON-2075).", () => { ); }); - it("de-duplicates overlapping array values while preserving order (merge-deep array-union semantics)", () => { - // merge-deep delegates array handling to arr-union, so repeated - // values are collapsed. deepmerge does plain concat by default, so - // the swap must supply a custom arrayMerge to preserve this. + it("de-duplicates overlapping array values while preserving order", () => { + // Arrays at the same key are unioned (first occurrence wins, new + // items appended), so repeated values don't accumulate when a + // caller's option array overlaps the defaults. assert.deepEqual( merge({ items: [1, 2, 3] }, { items: [2, 3, 4] }), { items: [1, 2, 3, 4] } ); }); - it("concatenates arrays at nested paths with the same union semantics", () => { + it("applies the union semantics to arrays at nested paths", () => { assert.deepEqual( merge({ group: { items: [1, 2] } }, { group: { items: [2, 3] } }), { group: { items: [1, 2, 3] } } ); }); + + it("does not dedupe object array elements by structural equality (reference-based)", () => { + // Array.prototype.includes uses reference equality, so two + // distinct objects with identical fields are both retained. + const result = merge({ x: [{ id: 1 }] }, { x: [{ id: 1 }] }); + assert.deepEqual(result, { x: [{ id: 1 }, { id: 1 }] }); + }); + + it("replaces a target array with a source object at the same key", () => { + assert.deepEqual(merge({ a: [1, 2] }, { a: { b: 3 } }), { a: { b: 3 } }); + }); + + it("replaces a target object with a source array at the same key", () => { + assert.deepEqual(merge({ a: { b: 3 } }, { a: [1, 2] }), { a: [1, 2] }); + }); + + it("keeps a null source value (null replaces target)", () => { + assert.deepEqual(merge({ a: 1 }, { a: null }), { a: null }); + }); + + it("keeps an undefined source value (undefined replaces target)", () => { + assert.deepEqual(merge({ a: 1 }, { a: undefined }), { a: undefined }); + }); + + it("replaces a nested target object with a null source", () => { + assert.deepEqual(merge({ a: { b: 1 } }, { a: null }), { a: null }); + }); + + it("does not pollute Object.prototype via a __proto__ key in source", () => { + const malicious = JSON.parse("{\"__proto__\":{\"polluted\":\"yes\"}}"); + const result = merge({}, malicious); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + assert.equal(({} as any).polluted, undefined); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + assert.equal((result as any).polluted, undefined); + }); + + it("does not pollute Object.prototype via a constructor.prototype key in source", () => { + const malicious = JSON.parse( + "{\"constructor\":{\"prototype\":{\"polluted\":\"yes\"}}}" + ); + merge({}, malicious); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + assert.equal(({} as any).polluted, undefined); + }); }); describe("SmartlingBaseApi integration (pins the call-site behavior)", () => { @@ -200,7 +242,35 @@ describe("Deep merge behavior (CON-2075).", () => { } }); - it("makeRequest: calling setOptions twice does not accumulate stale state on the caller-provided object", async () => { + it("makeRequest: array-valued header options are unioned, not duplicated", async () => { + const fetchStub = sinon.stub(base, "fetch").returns(responseMock); + const textStub = sinon.stub(responseMock, "text").returns("{\"response\": {\"data\": {}}}"); + + try { + base.setOptions({ + headers: { "Accept-Language": ["en", "fr"] } + }); + + await base.makeRequest( + "POST", + "https://test.com", + { foo: "bar" }, + false, + { "Accept-Language": ["en", "de"] } + ); + + const passedOpts = fetchStub.firstCall.args[1]; + assert.deepEqual( + passedOpts.headers["Accept-Language"], + ["en", "de", "fr"] + ); + } finally { + textStub.restore(); + fetchStub.restore(); + } + }); + + it("makeRequest: calling setOptions does not mutate the caller-provided object", async () => { const fetchStub = sinon.stub(base, "fetch").returns(responseMock); const textStub = sinon.stub(responseMock, "text").returns("{\"response\": {\"data\": {}}}"); @@ -210,8 +280,6 @@ describe("Deep merge behavior (CON-2075).", () => { await base.makeRequest("POST", "https://test.com", { foo: "bar" }); - // The object the user passed to setOptions must be unchanged - // by the merge the SDK does internally. assert.deepEqual(userOptions, { headers: { "X-Custom": "first" } }); } finally { textStub.restore(); From a78c36af45529e2fb65db28d5084444d904f8e69 Mon Sep 17 00:00:00 2001 From: Loparev Date: Fri, 17 Apr 2026 19:41:40 +0300 Subject: [PATCH 3/3] test: assert key presence and value explicitly for undefined source merge Co-Authored-By: Claude Opus 4.7 (1M context) --- test/base-api-merge.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/base-api-merge.spec.ts b/test/base-api-merge.spec.ts index 1a73338..fb47a56 100644 --- a/test/base-api-merge.spec.ts +++ b/test/base-api-merge.spec.ts @@ -115,7 +115,13 @@ describe("SmartlingBaseApi deep merge", () => { }); it("keeps an undefined source value (undefined replaces target)", () => { - assert.deepEqual(merge({ a: 1 }, { a: undefined }), { a: undefined }); + const result = merge, Record>( + { a: 1 }, + { a: undefined } + ); + + assert.ok("a" in result); + assert.strictEqual(result.a, undefined); }); it("replaces a nested target object with a null source", () => {