diff --git a/core/test-utils/README.md b/core/test-utils/README.md index 4f88b412e236..289dd7cecc1c 100644 --- a/core/test-utils/README.md +++ b/core/test-utils/README.md @@ -34,12 +34,29 @@ It's unlikely you will need to install this package directly, as it is intended ### Using the library ```typescript -import {TestResources} from 'google-test-utils'; +import * as assert from 'node:assert'; +import { + TestResources, + assertArraySubclassStrictEqual, + isBun, + isNode, + isReadOnlyPropertyError, +} from 'google-test-utils'; const testResources = new TestResources('my-test-suite'); const resourceName = testResources.generateName('my-test'); + +// Runtime-agnostic frozen object mutation check (V8 / Node.js & JSC / Bun) +const frozen = Object.freeze({name: 'value'}); +assert.throws(() => { + (frozen as {name: string}).name = 'updated'; +}, err => isReadOnlyPropertyError(err, 'name')); + +// Runtime-agnostic Array subclass strict equality check +assertArraySubclassStrictEqual(rowInstance, expectedRow); ``` + ## Supported Node.js Versions This library is not publicly supported. diff --git a/core/test-utils/src/index.ts b/core/test-utils/src/index.ts index f2e68c1091cd..ffe9502f2413 100644 --- a/core/test-utils/src/index.ts +++ b/core/test-utils/src/index.ts @@ -18,3 +18,13 @@ export { TokenMaker, defaultMaker as defaultTokenMaker, } from './testResources'; +export { + READONLY_PROPERTY_ERROR_REGEX, + RuntimeVersions, + assertArraySubclassStrictEqual, + getNodeMajorVersion, + isBun, + isNode, + isReadOnlyPropertyError, + requiresStrictArrayPrototypeEquality, +} from './runtime'; diff --git a/core/test-utils/src/runtime.ts b/core/test-utils/src/runtime.ts new file mode 100644 index 000000000000..2578c04b82ab --- /dev/null +++ b/core/test-utils/src/runtime.ts @@ -0,0 +1,160 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'node:assert'; + +/** + * Subset of `process.versions` used for runtime detection. + */ +export interface RuntimeVersions { + node?: string; + bun?: string; + [key: string]: string | undefined; +} + +/** + * Returns the active runtime versions dictionary, falling back to `process.versions` + * when available. + */ +function resolveVersions(versions?: RuntimeVersions): RuntimeVersions { + if (versions) { + return versions; + } + return typeof process !== 'undefined' && process?.versions + ? {...process.versions} + : {}; +} + +/** + * Returns true if the current (or provided) runtime environment is Bun. + */ +export function isBun(versions?: RuntimeVersions): boolean { + const resolved = resolveVersions(versions); + return typeof resolved.bun === 'string' && resolved.bun.length > 0; +} + +/** + * Returns true if the current (or provided) runtime environment is Node.js + * (and not Bun running under a Node compatibility layer). + */ +export function isNode(versions?: RuntimeVersions): boolean { + const resolved = resolveVersions(versions); + return ( + !isBun(resolved) && + typeof resolved.node === 'string' && + resolved.node.length > 0 + ); +} + +/** + * Parses and returns the major Node.js version number (from `process.versions.node`), + * or `undefined` if unavailable or invalid. + */ +export function getNodeMajorVersion( + versions?: RuntimeVersions, +): number | undefined { + const resolved = resolveVersions(versions); + if (typeof resolved.node !== 'string' || resolved.node.length === 0) { + return undefined; + } + const major = Number.parseInt(resolved.node.split('.')[0], 10); + return Number.isNaN(major) ? undefined : major; +} + +/** + * Returns true if `assert.deepStrictEqual` in the current (or provided) runtime + * requires strict prototype identity when comparing an `Array` subclass (even with + * `constructor === Array`) against a plain `Array` literal. + * + * Node.js 18 and Bun enforce strict prototype equality on `Array` subclasses, + * whereas Node.js 20+ allows comparing `Array` subclasses whose constructor is + * `Array` against plain arrays. + */ +export function requiresStrictArrayPrototypeEquality( + versions?: RuntimeVersions, +): boolean { + const resolved = resolveVersions(versions); + if (isBun(resolved)) { + return true; + } + const nodeMajor = getNodeMajorVersion(resolved); + if (nodeMajor !== undefined && nodeMajor < 20) { + return true; + } + return false; +} + +function toPlainArray(val: unknown): unknown { + return Array.isArray(val) ? Array.from(val, toPlainArray) : val; +} + +/** + * Asserts `deepStrictEqual` between an `Array` (or `Array` subclass instance such as + * Spanner's `RowImpl`) and an expected array, recursively normalizing any `Array` + * subclasses to plain arrays only on runtimes that enforce strict prototype equality. + */ +export function assertArraySubclassStrictEqual( + actual: ReadonlyArray, + expected: ReadonlyArray, + message?: string | Error, + versions?: RuntimeVersions, +): void { + const normalizedActual = requiresStrictArrayPrototypeEquality(versions) + ? toPlainArray(actual) + : actual; + const normalizedExpected = requiresStrictArrayPrototypeEquality(versions) + ? toPlainArray(expected) + : expected; + + if (message !== undefined) { + assert.deepStrictEqual(normalizedActual, normalizedExpected, message); + } else { + assert.deepStrictEqual(normalizedActual, normalizedExpected); + } +} + +/** + * Regular expression matching read-only / frozen property assignment `TypeError` + * messages across V8 (Node.js) and JavaScriptCore (Bun). + */ +export const READONLY_PROPERTY_ERROR_REGEX = + /Cannot assign to read only property|Attempted to assign to readonly property/; + +/** + * Predicate suitable for `assert.throws(fn, isReadOnlyPropertyError)` that checks + * whether an error is a `TypeError` caused by mutating a read-only or frozen property + * across V8 (Node.js) and JavaScriptCore (Bun). + * + * @param err The thrown value to inspect. + * @param propertyName Optional property name expected to be mentioned in the error message + * when the engine includes property names in read-only errors (V8). + */ +export function isReadOnlyPropertyError( + err: unknown, + propertyName?: string, +): boolean { + if (!(err instanceof TypeError)) { + return false; + } + if (!READONLY_PROPERTY_ERROR_REGEX.test(err.message)) { + return false; + } + if ( + propertyName !== undefined && + err.message.includes('Cannot assign to read only property') + ) { + return err.message.includes(`'${propertyName}'`); + } + return true; +} diff --git a/core/test-utils/test/runtime.test.ts b/core/test-utils/test/runtime.test.ts new file mode 100644 index 000000000000..16f087ca275b --- /dev/null +++ b/core/test-utils/test/runtime.test.ts @@ -0,0 +1,158 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'node:assert'; +import { + READONLY_PROPERTY_ERROR_REGEX, + assertArraySubclassStrictEqual, + getNodeMajorVersion, + isBun, + isNode, + isReadOnlyPropertyError, + requiresStrictArrayPrototypeEquality, +} from '../src'; + +describe('runtime utilities (unit)', () => { + describe('runtime detection', () => { + it('detects Node.js runtime', () => { + const versions = {node: '22.14.0'}; + expect(isNode(versions)).toBe(true); + expect(isBun(versions)).toBe(false); + expect(getNodeMajorVersion(versions)).toBe(22); + }); + + it('detects Bun runtime (even when process.versions.node is present)', () => { + const versions = {node: '22.6.0', bun: '1.2.4'}; + expect(isBun(versions)).toBe(true); + expect(isNode(versions)).toBe(false); + }); + + it('returns false / undefined when no runtime versions are present', () => { + const versions = {}; + expect(isNode(versions)).toBe(false); + expect(isBun(versions)).toBe(false); + expect(getNodeMajorVersion(versions)).toBeUndefined(); + }); + + it('returns undefined for unparseable Node version', () => { + expect(getNodeMajorVersion({node: 'invalid'})).toBeUndefined(); + }); + + it('resolves default process.versions when called without arguments', () => { + expect(isNode() || isBun()).toBe(true); + expect(getNodeMajorVersion()).toBeGreaterThanOrEqual(18); + }); + }); + + describe('requiresStrictArrayPrototypeEquality & assertArraySubclassStrictEqual', () => { + class CustomRow extends Array { + toJSON() { + return [...this]; + } + } + Object.defineProperty(CustomRow.prototype, 'constructor', { + value: Array, + writable: true, + configurable: true, + enumerable: false, + }); + + it('identifies runtimes requiring strict array prototype equality', () => { + expect(requiresStrictArrayPrototypeEquality({node: '18.20.0'})).toBe( + true, + ); + expect( + requiresStrictArrayPrototypeEquality({node: '22.6.0', bun: '1.2.4'}), + ).toBe(true); + expect(requiresStrictArrayPrototypeEquality({node: '20.11.0'})).toBe( + false, + ); + expect(requiresStrictArrayPrototypeEquality({node: '22.14.0'})).toBe( + false, + ); + }); + + it('compares Array subclass instances across runtimes using assertArraySubclassStrictEqual', () => { + const row = new CustomRow('a', 'b'); + expect(() => + assertArraySubclassStrictEqual(row, ['a', 'b'], undefined, { + node: '18.20.0', + }), + ).not.toThrow(); + expect(() => + assertArraySubclassStrictEqual(row, ['a', 'b'], undefined, { + node: '22.14.0', + }), + ).not.toThrow(); + expect(() => + assertArraySubclassStrictEqual(row, ['a', 'c'], undefined, { + node: '22.6.0', + bun: '1.2.4', + }), + ).toThrow(assert.AssertionError); + expect(() => + assertArraySubclassStrictEqual(row, ['a', 'c'], 'custom mismatch', { + node: '22.14.0', + }), + ).toThrow('custom mismatch'); + + // Test nested array subclasses (e.g., array of rows) + const nestedRows = [new CustomRow('a', 'b')]; + expect(() => + assertArraySubclassStrictEqual(nestedRows, [['a', 'b']], undefined, { + node: '18.20.0', + }), + ).not.toThrow(); + }); + }); + + describe('isReadOnlyPropertyError & READONLY_PROPERTY_ERROR_REGEX', () => { + it('matches real frozen object mutation TypeError via assert.throws', () => { + const frozen = Object.freeze({name: 'projects/{{projectId}}'}); + assert.throws( + () => { + (frozen as {name: string}).name = 'projects/my-project'; + }, + err => isReadOnlyPropertyError(err, 'name'), + ); + assert.throws(() => { + (frozen as {name: string}).name = 'projects/my-project'; + }, READONLY_PROPERTY_ERROR_REGEX); + }); + + it('matches both V8 and JavaScriptCore read-only TypeError messages', () => { + const v8Err = new TypeError( + "Cannot assign to read only property 'name' of object '#'", + ); + const jscErr = new TypeError('Attempted to assign to readonly property.'); + + expect(isReadOnlyPropertyError(v8Err)).toBe(true); + expect(isReadOnlyPropertyError(v8Err, 'name')).toBe(true); + expect(isReadOnlyPropertyError(v8Err, 'other')).toBe(false); + + expect(isReadOnlyPropertyError(jscErr)).toBe(true); + expect(isReadOnlyPropertyError(jscErr, 'name')).toBe(true); + }); + + it('rejects non-TypeError or unrelated TypeError values', () => { + expect(isReadOnlyPropertyError(new Error('some error'))).toBe(false); + expect( + isReadOnlyPropertyError(new TypeError('unrelated type error')), + ).toBe(false); + expect( + isReadOnlyPropertyError('Cannot assign to read only property'), + ).toBe(false); + }); + }); +}); diff --git a/handwritten/spanner/cloudbuild-regular-sessions.yaml b/handwritten/spanner/cloudbuild-regular-sessions.yaml index 3ce9f47c1a66..3289490d9501 100644 --- a/handwritten/spanner/cloudbuild-regular-sessions.yaml +++ b/handwritten/spanner/cloudbuild-regular-sessions.yaml @@ -5,7 +5,7 @@ steps: args: - '-c' - | - cd ../.. && corepack enable pnpm && pnpm install --filter "...{./handwritten/spanner}" + cd ../.. && corepack enable pnpm && pnpm install --filter "...{./handwritten/spanner}..." dir: 'handwritten/spanner' id: 'install-dependencies' diff --git a/handwritten/spanner/cloudbuild.yaml b/handwritten/spanner/cloudbuild.yaml index 5ac1335ff111..9c9da32e0815 100644 --- a/handwritten/spanner/cloudbuild.yaml +++ b/handwritten/spanner/cloudbuild.yaml @@ -5,7 +5,7 @@ steps: args: - '-c' - | - cd ../.. && corepack enable pnpm && pnpm install --filter "...{./handwritten/spanner}" + cd ../.. && corepack enable pnpm && pnpm install --filter "...{./handwritten/spanner}..." dir: 'handwritten/spanner' id: 'install-dependencies' diff --git a/handwritten/spanner/package.json b/handwritten/spanner/package.json index 10116807548d..3f42ecfbee72 100644 --- a/handwritten/spanner/package.json +++ b/handwritten/spanner/package.json @@ -110,6 +110,7 @@ "dedent": "^1.5.3", "execa": "^5.0.0", "gapic-tools": "^2.0.0", + "google-test-utils": "workspace:^0.1.0", "grpc-health-check": "^2.1.0", "gts": "^6.0.2", "jsdoc": "^4.0.4", diff --git a/handwritten/spanner/test/helper.ts b/handwritten/spanner/test/helper.ts index 30d04e189f7b..78250b188dd1 100644 --- a/handwritten/spanner/test/helper.ts +++ b/handwritten/spanner/test/helper.ts @@ -16,6 +16,7 @@ import * as assert from 'assert'; import {describe, it} from 'mocha'; +import {isReadOnlyPropertyError} from 'google-test-utils'; import {hasProjectIdToken, replaceProjectIdToken} from '../src/helper'; import {Stream} from 'stream'; @@ -123,9 +124,12 @@ describe('helper', () => { name: 'projects/{{projectId}}', }); - assert.throws(() => { - replaceProjectIdToken(frozenObj, projectId); - }, /Cannot assign to read only property|Attempted to assign to readonly property/); + assert.throws( + () => { + replaceProjectIdToken(frozenObj, projectId); + }, + err => isReadOnlyPropertyError(err, 'name'), + ); }); it('should replace more than one {{projectId}}', () => { diff --git a/handwritten/spanner/test/partial-result-stream.ts b/handwritten/spanner/test/partial-result-stream.ts index bb0e115047b6..4d8560be7593 100644 --- a/handwritten/spanner/test/partial-result-stream.ts +++ b/handwritten/spanner/test/partial-result-stream.ts @@ -28,6 +28,7 @@ import {codec} from '../src/codec'; import {PreciseDate} from '@google-cloud/precise-date'; import * as prs from '../src/partial-result-stream'; import {grpc} from 'google-gax'; +import {assertArraySubclassStrictEqual} from 'google-test-utils'; import {Row} from '../src/partial-result-stream'; function toRawValue(value: any): any { @@ -193,17 +194,7 @@ describe('PartialResultStream', () => { it('should emit rows', done => { stream.on('error', done).on('data', row => { - // Node 18's assert.deepStrictEqual strictly requires prototype equality, - // which fails when comparing RowImpl (an Array subclass) with a plain Array literal. - // Node 20+ relaxed this for Array subclasses with constructor = Array. - if ( - parseInt(process.versions.node.split('.')[0], 10) < 20 || - process.versions.bun - ) { - assert.deepStrictEqual([...row], EXPECTED_ROW); - } else { - assert.deepStrictEqual(row, EXPECTED_ROW); - } + assertArraySubclassStrictEqual(row, EXPECTED_ROW); done(); }); @@ -260,17 +251,7 @@ describe('PartialResultStream', () => { assert.deepStrictEqual(json, fakeJson); const [row, options] = stub.lastCall.args; - // Node 18's assert.deepStrictEqual strictly requires prototype equality, - // which fails when comparing RowImpl (an Array subclass) with a plain Array literal. - // Node 20+ relaxed this for Array subclasses with constructor = Array. - if ( - parseInt(process.versions.node.split('.')[0], 10) < 20 || - process.versions.bun - ) { - assert.deepStrictEqual([...row], EXPECTED_ROW); - } else { - assert.deepStrictEqual(row, EXPECTED_ROW); - } + assertArraySubclassStrictEqual(row, EXPECTED_ROW); assert.strictEqual(options, jsonOptions); done(); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffcbb5630d21..426e2b25f4e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3199,6 +3199,9 @@ importers: gapic-tools: specifier: ^2.0.0 version: 2.0.1(protobufjs@7.5.9) + google-test-utils: + specifier: workspace:^0.1.0 + version: link:../../core/test-utils grpc-health-check: specifier: ^2.1.0 version: 2.1.0 @@ -28606,7 +28609,7 @@ snapshots: '@webpack-cli/configtest@3.0.1(webpack-cli@6.0.1)(webpack@5.109.0)': dependencies: - webpack: 5.109.0(webpack-cli@6.0.1) + webpack: 5.109.0(uglify-js@3.19.3)(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack@5.109.0) '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.109.0)': @@ -28616,7 +28619,7 @@ snapshots: '@webpack-cli/info@3.0.1(webpack-cli@6.0.1)(webpack@5.109.0)': dependencies: - webpack: 5.109.0(webpack-cli@6.0.1) + webpack: 5.109.0(uglify-js@3.19.3)(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack@5.109.0) '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.109.0)': @@ -28626,7 +28629,7 @@ snapshots: '@webpack-cli/serve@3.0.1(webpack-cli@6.0.1)(webpack@5.109.0)': dependencies: - webpack: 5.109.0(webpack-cli@6.0.1) + webpack: 5.109.0(uglify-js@3.19.3)(webpack-cli@6.0.1) webpack-cli: 6.0.1(webpack@5.109.0) '@xtuc/ieee754@1.2.0': {} @@ -34190,7 +34193,7 @@ snapshots: dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.109.0(webpack-cli@6.0.1) + webpack: 5.109.0(uglify-js@3.19.3)(webpack-cli@6.0.1) nunjucks@3.2.4(chokidar@3.6.0): dependencies: @@ -36857,7 +36860,7 @@ snapshots: import-local: 3.2.0 interpret: 3.1.1 rechoir: 0.8.0 - webpack: 5.109.0(webpack-cli@6.0.1) + webpack: 5.109.0(uglify-js@3.19.3)(webpack-cli@6.0.1) webpack-merge: 6.0.1 webpack-merge@4.2.2: