From eecb0fa23465d7d300b08992a124c22b89912979 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 1 Oct 2026 14:05:01 +0000 Subject: [PATCH 1/4] feat(test-utils): add runtime detection predicates and cross-engine assertion helpers Adds runtime environment predicates (isNode, isBun, isDeno, getRuntime, getNodeMajorVersion), Array subclass equality helpers (requiresStrictArrayPrototypeEquality, assertArraySubclassStrictEqual), and cross-engine TypeError predicates (isReadOnlyPropertyError, isNullOrUndefinedPropertyError) to google-test-utils. [Generated-by: AI] --- core/test-utils/README.md | 21 ++- core/test-utils/src/index.ts | 14 ++ core/test-utils/src/runtime.ts | 245 +++++++++++++++++++++++++++ core/test-utils/test/runtime.test.ts | 223 ++++++++++++++++++++++++ 4 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 core/test-utils/src/runtime.ts create mode 100644 core/test-utils/test/runtime.test.ts diff --git a/core/test-utils/README.md b/core/test-utils/README.md index 4f88b412e236..0940f2ec9809 100644 --- a/core/test-utils/README.md +++ b/core/test-utils/README.md @@ -34,12 +34,31 @@ 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, + isDeno, + isNode, + isNullOrUndefinedPropertyError, + 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 / Deno & 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..7fa0a6dc025b 100644 --- a/core/test-utils/src/index.ts +++ b/core/test-utils/src/index.ts @@ -18,3 +18,17 @@ export { TokenMaker, defaultMaker as defaultTokenMaker, } from './testResources'; +export { + READONLY_PROPERTY_ERROR_REGEX, + RuntimeEnvironment, + RuntimeVersions, + assertArraySubclassStrictEqual, + getNodeMajorVersion, + getRuntime, + isBun, + isDeno, + isNode, + isNullOrUndefinedPropertyError, + 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..3143a184bd49 --- /dev/null +++ b/core/test-utils/src/runtime.ts @@ -0,0 +1,245 @@ +// 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; + deno?: string; + [key: string]: string | undefined; +} + +/** + * Supported JavaScript runtime identifiers. + */ +export type RuntimeEnvironment = 'node' | 'bun' | 'deno' | 'unknown'; + +interface GlobalWithDeno { + Deno?: { + version?: { + deno?: string; + }; + }; +} + +/** + * Returns the active runtime versions dictionary, falling back to `process.versions` + * and `globalThis.Deno.version` when available. + */ +function resolveVersions(versions?: RuntimeVersions): RuntimeVersions { + if (versions) { + return versions; + } + const procVersions: RuntimeVersions = + typeof process !== 'undefined' && process?.versions + ? {...process.versions} + : {}; + const denoVersion = (globalThis as unknown as GlobalWithDeno).Deno?.version + ?.deno; + if (denoVersion && !procVersions.deno) { + procVersions.deno = denoVersion; + } + return procVersions; +} + +/** + * 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 Deno. + */ +export function isDeno(versions?: RuntimeVersions): boolean { + const resolved = resolveVersions(versions); + return typeof resolved.deno === 'string' && resolved.deno.length > 0; +} + +/** + * Returns true if the current (or provided) runtime environment is Node.js + * (and not Bun or Deno running under a Node compatibility layer). + */ +export function isNode(versions?: RuntimeVersions): boolean { + const resolved = resolveVersions(versions); + return ( + !isBun(resolved) && + !isDeno(resolved) && + typeof resolved.node === 'string' && + resolved.node.length > 0 + ); +} + +/** + * Identifies the active JavaScript runtime (`'node'`, `'bun'`, `'deno'`, or `'unknown'`). + */ +export function getRuntime(versions?: RuntimeVersions): RuntimeEnvironment { + const resolved = resolveVersions(versions); + if (isBun(resolved)) { + return 'bun'; + } + if (isDeno(resolved)) { + return 'deno'; + } + if (isNode(resolved)) { + return 'node'; + } + return 'unknown'; +} + +/** + * 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+ and Deno (with Node 20+ compatibility) allow 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; +} + +/** + * Asserts `deepStrictEqual` between an `Array` (or `Array` subclass instance such as + * Spanner's `RowImpl`) and an expected array, normalizing the `actual` array via + * spread (`[...actual]`) only on runtimes that enforce strict prototype equality + * for `Array` subclasses. + */ +export function assertArraySubclassStrictEqual( + actual: ReadonlyArray, + expected: ReadonlyArray, + message?: string | Error, + versions?: RuntimeVersions, +): void { + if (requiresStrictArrayPrototypeEquality(versions)) { + assert.deepStrictEqual([...actual], expected, message); + } else { + assert.deepStrictEqual(actual, expected, message); + } +} + +/** + * Regular expression matching read-only / frozen property assignment `TypeError` + * messages across V8 (Node.js, Deno) 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, Deno) 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; +} + +/** + * Predicate suitable for `assert.throws` or callback error assertions that checks + * whether an error is a `TypeError` caused by accessing a property on `null` or + * `undefined` across V8 (Node.js, Deno) and JavaScriptCore (Bun). + * + * Matches: + * - V8 (modern Node.js / Deno): `Cannot read properties of null (reading 'prop')` + * - V8 (legacy Node.js): `Cannot read property 'prop' of null` + * - JavaScriptCore (Bun): `null is not an object (evaluating 'obj.prop')` / + * `undefined is not an object (evaluating 'obj.prop')` + * + * @param err The thrown error or error message string to inspect. + * @param propertyName Optional property name expected to be accessed. + */ +export function isNullOrUndefinedPropertyError( + err: unknown, + propertyName?: string, +): boolean { + const message = + typeof err === 'string' + ? err + : err instanceof TypeError + ? err.message + : undefined; + if (message === undefined) { + return false; + } + + const isV8Modern = + message.includes('Cannot read properties of null') || + message.includes('Cannot read properties of undefined'); + const isV8Legacy = + message.includes('Cannot read property ') && + (message.includes(' of null') || message.includes(' of undefined')); + const isJsc = + message.includes('null is not an object') || + message.includes('undefined is not an object'); + + if (!isV8Modern && !isV8Legacy && !isJsc) { + return false; + } + + if (propertyName !== undefined) { + return 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..0e7aae350d0e --- /dev/null +++ b/core/test-utils/test/runtime.test.ts @@ -0,0 +1,223 @@ +// 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, + getRuntime, + isBun, + isDeno, + isNode, + isNullOrUndefinedPropertyError, + 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(isDeno(versions)).toBe(false); + expect(getRuntime(versions)).toBe('node'); + 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); + expect(isDeno(versions)).toBe(false); + expect(getRuntime(versions)).toBe('bun'); + }); + + it('detects Deno runtime (even when process.versions.node is present)', () => { + const versions = {node: '20.11.1', deno: '2.1.0'}; + expect(isDeno(versions)).toBe(true); + expect(isNode(versions)).toBe(false); + expect(isBun(versions)).toBe(false); + expect(getRuntime(versions)).toBe('deno'); + }); + + it('returns unknown when no runtime versions are present', () => { + const versions = {}; + expect(isNode(versions)).toBe(false); + expect(isBun(versions)).toBe(false); + expect(isDeno(versions)).toBe(false); + expect(getRuntime(versions)).toBe('unknown'); + 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', () => { + const runtime = getRuntime(); + expect(['node', 'bun', 'deno']).toContain(runtime); + expect(getNodeMajorVersion()).toBeGreaterThanOrEqual(18); + }); + + it('detects globalThis.Deno.version.deno when process.versions.deno is absent', () => { + const globalAny = globalThis as Record; + const prevDeno = globalAny.Deno; + const origDenoVer = process.versions.deno; + try { + delete (process.versions as Record).deno; + globalAny.Deno = {version: {deno: '2.2.0'}}; + expect(isDeno()).toBe(true); + expect(getRuntime()).toBe('deno'); + } finally { + if (prevDeno === undefined) { + delete globalAny.Deno; + } else { + globalAny.Deno = prevDeno; + } + if (origDenoVer !== undefined) { + (process.versions as Record).deno = + origDenoVer; + } + } + }); + }); + + 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, + ); + expect( + requiresStrictArrayPrototypeEquality({node: '20.11.0', deno: '2.1.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); + }); + }); + + 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); + }); + }); + + describe('isNullOrUndefinedPropertyError', () => { + it('matches V8 modern, V8 legacy, and JavaScriptCore null/undefined property access errors', () => { + const v8Modern = new TypeError( + "Cannot read properties of null (reading 'proto')", + ); + const v8Legacy = new TypeError("Cannot read property 'proto' of null"); + const jscNull = new TypeError( + "null is not an object (evaluating 'data.proto')", + ); + const jscUndefined = new TypeError( + "undefined is not an object (evaluating 'data.proto')", + ); + + expect(isNullOrUndefinedPropertyError(v8Modern, 'proto')).toBe(true); + expect(isNullOrUndefinedPropertyError(v8Legacy, 'proto')).toBe(true); + expect(isNullOrUndefinedPropertyError(jscNull, 'proto')).toBe(true); + expect(isNullOrUndefinedPropertyError(jscUndefined, 'proto')).toBe(true); + expect(isNullOrUndefinedPropertyError(jscNull)).toBe(true); + expect(isNullOrUndefinedPropertyError(jscNull.message, 'proto')).toBe( + true, + ); + expect(isNullOrUndefinedPropertyError(v8Modern, 'other')).toBe(false); + }); + + it('rejects non-matching errors and non-error values', () => { + expect(isNullOrUndefinedPropertyError(new Error('generic error'))).toBe( + false, + ); + expect( + isNullOrUndefinedPropertyError(new TypeError('something else')), + ).toBe(false); + expect(isNullOrUndefinedPropertyError(123)).toBe(false); + }); + }); +}); From 44e8c15154bc4edb30172d8df428079382bed370 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 1 Oct 2026 14:25:27 +0000 Subject: [PATCH 2/4] test(spanner): adopt google-test-utils runtime helpers in unit tests Use assertArraySubclassStrictEqual, isReadOnlyPropertyError, and isNullOrUndefinedPropertyError from google-test-utils in Spanner unit tests to address PR #9460 review feedback. [Generated-by: AI] --- handwritten/spanner/package.json | 1 + handwritten/spanner/test/database.ts | 11 ++------ handwritten/spanner/test/helper.ts | 10 +++++--- .../spanner/test/partial-result-stream.ts | 25 +++---------------- pnpm-lock.yaml | 13 ++++++---- 5 files changed, 21 insertions(+), 39 deletions(-) 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/database.ts b/handwritten/spanner/test/database.ts index a0fd4991b0da..150d61b62e10 100644 --- a/handwritten/spanner/test/database.ts +++ b/handwritten/spanner/test/database.ts @@ -27,6 +27,7 @@ import {Transform, Duplex} from 'stream'; import * as through from 'through2'; import * as pfy from '@google-cloud/promisify'; import {grpc} from 'google-gax'; +import {isNullOrUndefinedPropertyError} from 'google-test-utils'; import * as db from '../src/database'; import {Spanner, Instance, MutationGroup} from '../src'; import {MockError} from './mockserver/mockspanner'; @@ -971,15 +972,7 @@ describe('Database', () => { try { database.writeAtLeastOnce(null, () => {}); } catch (err) { - const errorMessage = (err as grpc.ServiceError).message; - assert.ok( - errorMessage.includes( - "Cannot read properties of null (reading 'proto')", - ) || - errorMessage.includes("Cannot read property 'proto' of null") || - (errorMessage.includes('null is not an object') && - errorMessage.includes('proto')), - ); + assert.ok(isNullOrUndefinedPropertyError(err, 'proto')); done(); } 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: From c453644a27b722e8c1def90c35e21a8f90410321 Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 1 Oct 2026 15:57:36 +0000 Subject: [PATCH 3/4] fix(test-utils,spanner): normalize nested array subclasses and include workspace deps in cloudbuild - Recursively normalize nested Array subclasses in assertArraySubclassStrictEqual via toPlainArray and avoid passing undefined message to assert.deepStrictEqual (fixes Node 26 TypeError). - Add unit tests for nested Array subclasses and custom assertion messages. - Update Spanner cloudbuild.yaml and cloudbuild-regular-sessions.yaml pnpm filter to ...{./handwritten/spanner}... so upstream workspace dependencies like google-test-utils are built. [Generated-by: AI] --- core/test-utils/src/runtime.ts | 22 ++++++++++++++----- core/test-utils/test/runtime.test.ts | 13 +++++++++++ .../spanner/cloudbuild-regular-sessions.yaml | 2 +- handwritten/spanner/cloudbuild.yaml | 2 +- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/core/test-utils/src/runtime.ts b/core/test-utils/src/runtime.ts index 3143a184bd49..f73ef8c32f3d 100644 --- a/core/test-utils/src/runtime.ts +++ b/core/test-utils/src/runtime.ts @@ -142,11 +142,14 @@ export function requiresStrictArrayPrototypeEquality( 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, normalizing the `actual` array via - * spread (`[...actual]`) only on runtimes that enforce strict prototype equality - * for `Array` subclasses. + * 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, @@ -154,10 +157,17 @@ export function assertArraySubclassStrictEqual( message?: string | Error, versions?: RuntimeVersions, ): void { - if (requiresStrictArrayPrototypeEquality(versions)) { - assert.deepStrictEqual([...actual], expected, message); + 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(actual, expected, message); + assert.deepStrictEqual(normalizedActual, normalizedExpected); } } diff --git a/core/test-utils/test/runtime.test.ts b/core/test-utils/test/runtime.test.ts index 0e7aae350d0e..87a1472027a6 100644 --- a/core/test-utils/test/runtime.test.ts +++ b/core/test-utils/test/runtime.test.ts @@ -144,6 +144,19 @@ describe('runtime utilities (unit)', () => { 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(); }); }); 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' From e42a9f1d8f0f678c9fbb9e238c0b5878788140cb Mon Sep 17 00:00:00 2001 From: Daniel Bruce Date: Thu, 1 Oct 2026 17:42:29 +0000 Subject: [PATCH 4/4] refactor(test-utils,spanner): scope helpers to PR #9460 review comments Remove unused runtime utilities (isDeno, getRuntime, RuntimeEnvironment, isNullOrUndefinedPropertyError) and revert handwritten/spanner/test/database.ts so the PR only includes the helpers and Spanner test locations referenced by @feywind on PR #9460. [Generated-by: AI] --- core/test-utils/README.md | 4 +- core/test-utils/src/index.ts | 4 - core/test-utils/src/runtime.ts | 113 +++------------------------ core/test-utils/test/runtime.test.ts | 82 +------------------ handwritten/spanner/test/database.ts | 11 ++- 5 files changed, 21 insertions(+), 193 deletions(-) diff --git a/core/test-utils/README.md b/core/test-utils/README.md index 0940f2ec9809..289dd7cecc1c 100644 --- a/core/test-utils/README.md +++ b/core/test-utils/README.md @@ -39,16 +39,14 @@ import { TestResources, assertArraySubclassStrictEqual, isBun, - isDeno, isNode, - isNullOrUndefinedPropertyError, 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 / Deno & JSC / Bun) +// 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'; diff --git a/core/test-utils/src/index.ts b/core/test-utils/src/index.ts index 7fa0a6dc025b..ffe9502f2413 100644 --- a/core/test-utils/src/index.ts +++ b/core/test-utils/src/index.ts @@ -20,15 +20,11 @@ export { } from './testResources'; export { READONLY_PROPERTY_ERROR_REGEX, - RuntimeEnvironment, RuntimeVersions, assertArraySubclassStrictEqual, getNodeMajorVersion, - getRuntime, isBun, - isDeno, isNode, - isNullOrUndefinedPropertyError, isReadOnlyPropertyError, requiresStrictArrayPrototypeEquality, } from './runtime'; diff --git a/core/test-utils/src/runtime.ts b/core/test-utils/src/runtime.ts index f73ef8c32f3d..2578c04b82ab 100644 --- a/core/test-utils/src/runtime.ts +++ b/core/test-utils/src/runtime.ts @@ -20,41 +20,20 @@ import * as assert from 'node:assert'; export interface RuntimeVersions { node?: string; bun?: string; - deno?: string; [key: string]: string | undefined; } -/** - * Supported JavaScript runtime identifiers. - */ -export type RuntimeEnvironment = 'node' | 'bun' | 'deno' | 'unknown'; - -interface GlobalWithDeno { - Deno?: { - version?: { - deno?: string; - }; - }; -} - /** * Returns the active runtime versions dictionary, falling back to `process.versions` - * and `globalThis.Deno.version` when available. + * when available. */ function resolveVersions(versions?: RuntimeVersions): RuntimeVersions { if (versions) { return versions; } - const procVersions: RuntimeVersions = - typeof process !== 'undefined' && process?.versions - ? {...process.versions} - : {}; - const denoVersion = (globalThis as unknown as GlobalWithDeno).Deno?.version - ?.deno; - if (denoVersion && !procVersions.deno) { - procVersions.deno = denoVersion; - } - return procVersions; + return typeof process !== 'undefined' && process?.versions + ? {...process.versions} + : {}; } /** @@ -65,45 +44,19 @@ export function isBun(versions?: RuntimeVersions): boolean { return typeof resolved.bun === 'string' && resolved.bun.length > 0; } -/** - * Returns true if the current (or provided) runtime environment is Deno. - */ -export function isDeno(versions?: RuntimeVersions): boolean { - const resolved = resolveVersions(versions); - return typeof resolved.deno === 'string' && resolved.deno.length > 0; -} - /** * Returns true if the current (or provided) runtime environment is Node.js - * (and not Bun or Deno running under a Node compatibility layer). + * (and not Bun running under a Node compatibility layer). */ export function isNode(versions?: RuntimeVersions): boolean { const resolved = resolveVersions(versions); return ( !isBun(resolved) && - !isDeno(resolved) && typeof resolved.node === 'string' && resolved.node.length > 0 ); } -/** - * Identifies the active JavaScript runtime (`'node'`, `'bun'`, `'deno'`, or `'unknown'`). - */ -export function getRuntime(versions?: RuntimeVersions): RuntimeEnvironment { - const resolved = resolveVersions(versions); - if (isBun(resolved)) { - return 'bun'; - } - if (isDeno(resolved)) { - return 'deno'; - } - if (isNode(resolved)) { - return 'node'; - } - return 'unknown'; -} - /** * Parses and returns the major Node.js version number (from `process.versions.node`), * or `undefined` if unavailable or invalid. @@ -125,8 +78,8 @@ export function getNodeMajorVersion( * `constructor === Array`) against a plain `Array` literal. * * Node.js 18 and Bun enforce strict prototype equality on `Array` subclasses, - * whereas Node.js 20+ and Deno (with Node 20+ compatibility) allow comparing - * `Array` subclasses whose constructor is `Array` against plain arrays. + * whereas Node.js 20+ allows comparing `Array` subclasses whose constructor is + * `Array` against plain arrays. */ export function requiresStrictArrayPrototypeEquality( versions?: RuntimeVersions, @@ -173,7 +126,7 @@ export function assertArraySubclassStrictEqual( /** * Regular expression matching read-only / frozen property assignment `TypeError` - * messages across V8 (Node.js, Deno) and JavaScriptCore (Bun). + * 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/; @@ -181,7 +134,7 @@ export const READONLY_PROPERTY_ERROR_REGEX = /** * 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, Deno) and JavaScriptCore (Bun). + * 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 @@ -205,51 +158,3 @@ export function isReadOnlyPropertyError( } return true; } - -/** - * Predicate suitable for `assert.throws` or callback error assertions that checks - * whether an error is a `TypeError` caused by accessing a property on `null` or - * `undefined` across V8 (Node.js, Deno) and JavaScriptCore (Bun). - * - * Matches: - * - V8 (modern Node.js / Deno): `Cannot read properties of null (reading 'prop')` - * - V8 (legacy Node.js): `Cannot read property 'prop' of null` - * - JavaScriptCore (Bun): `null is not an object (evaluating 'obj.prop')` / - * `undefined is not an object (evaluating 'obj.prop')` - * - * @param err The thrown error or error message string to inspect. - * @param propertyName Optional property name expected to be accessed. - */ -export function isNullOrUndefinedPropertyError( - err: unknown, - propertyName?: string, -): boolean { - const message = - typeof err === 'string' - ? err - : err instanceof TypeError - ? err.message - : undefined; - if (message === undefined) { - return false; - } - - const isV8Modern = - message.includes('Cannot read properties of null') || - message.includes('Cannot read properties of undefined'); - const isV8Legacy = - message.includes('Cannot read property ') && - (message.includes(' of null') || message.includes(' of undefined')); - const isJsc = - message.includes('null is not an object') || - message.includes('undefined is not an object'); - - if (!isV8Modern && !isV8Legacy && !isJsc) { - return false; - } - - if (propertyName !== undefined) { - return message.includes(propertyName); - } - return true; -} diff --git a/core/test-utils/test/runtime.test.ts b/core/test-utils/test/runtime.test.ts index 87a1472027a6..16f087ca275b 100644 --- a/core/test-utils/test/runtime.test.ts +++ b/core/test-utils/test/runtime.test.ts @@ -17,11 +17,8 @@ import { READONLY_PROPERTY_ERROR_REGEX, assertArraySubclassStrictEqual, getNodeMajorVersion, - getRuntime, isBun, - isDeno, isNode, - isNullOrUndefinedPropertyError, isReadOnlyPropertyError, requiresStrictArrayPrototypeEquality, } from '../src'; @@ -32,8 +29,6 @@ describe('runtime utilities (unit)', () => { const versions = {node: '22.14.0'}; expect(isNode(versions)).toBe(true); expect(isBun(versions)).toBe(false); - expect(isDeno(versions)).toBe(false); - expect(getRuntime(versions)).toBe('node'); expect(getNodeMajorVersion(versions)).toBe(22); }); @@ -41,24 +36,12 @@ describe('runtime utilities (unit)', () => { const versions = {node: '22.6.0', bun: '1.2.4'}; expect(isBun(versions)).toBe(true); expect(isNode(versions)).toBe(false); - expect(isDeno(versions)).toBe(false); - expect(getRuntime(versions)).toBe('bun'); }); - it('detects Deno runtime (even when process.versions.node is present)', () => { - const versions = {node: '20.11.1', deno: '2.1.0'}; - expect(isDeno(versions)).toBe(true); - expect(isNode(versions)).toBe(false); - expect(isBun(versions)).toBe(false); - expect(getRuntime(versions)).toBe('deno'); - }); - - it('returns unknown when no runtime versions are present', () => { + it('returns false / undefined when no runtime versions are present', () => { const versions = {}; expect(isNode(versions)).toBe(false); expect(isBun(versions)).toBe(false); - expect(isDeno(versions)).toBe(false); - expect(getRuntime(versions)).toBe('unknown'); expect(getNodeMajorVersion(versions)).toBeUndefined(); }); @@ -67,32 +50,9 @@ describe('runtime utilities (unit)', () => { }); it('resolves default process.versions when called without arguments', () => { - const runtime = getRuntime(); - expect(['node', 'bun', 'deno']).toContain(runtime); + expect(isNode() || isBun()).toBe(true); expect(getNodeMajorVersion()).toBeGreaterThanOrEqual(18); }); - - it('detects globalThis.Deno.version.deno when process.versions.deno is absent', () => { - const globalAny = globalThis as Record; - const prevDeno = globalAny.Deno; - const origDenoVer = process.versions.deno; - try { - delete (process.versions as Record).deno; - globalAny.Deno = {version: {deno: '2.2.0'}}; - expect(isDeno()).toBe(true); - expect(getRuntime()).toBe('deno'); - } finally { - if (prevDeno === undefined) { - delete globalAny.Deno; - } else { - globalAny.Deno = prevDeno; - } - if (origDenoVer !== undefined) { - (process.versions as Record).deno = - origDenoVer; - } - } - }); }); describe('requiresStrictArrayPrototypeEquality & assertArraySubclassStrictEqual', () => { @@ -121,9 +81,6 @@ describe('runtime utilities (unit)', () => { expect(requiresStrictArrayPrototypeEquality({node: '22.14.0'})).toBe( false, ); - expect( - requiresStrictArrayPrototypeEquality({node: '20.11.0', deno: '2.1.0'}), - ).toBe(false); }); it('compares Array subclass instances across runtimes using assertArraySubclassStrictEqual', () => { @@ -198,39 +155,4 @@ describe('runtime utilities (unit)', () => { ).toBe(false); }); }); - - describe('isNullOrUndefinedPropertyError', () => { - it('matches V8 modern, V8 legacy, and JavaScriptCore null/undefined property access errors', () => { - const v8Modern = new TypeError( - "Cannot read properties of null (reading 'proto')", - ); - const v8Legacy = new TypeError("Cannot read property 'proto' of null"); - const jscNull = new TypeError( - "null is not an object (evaluating 'data.proto')", - ); - const jscUndefined = new TypeError( - "undefined is not an object (evaluating 'data.proto')", - ); - - expect(isNullOrUndefinedPropertyError(v8Modern, 'proto')).toBe(true); - expect(isNullOrUndefinedPropertyError(v8Legacy, 'proto')).toBe(true); - expect(isNullOrUndefinedPropertyError(jscNull, 'proto')).toBe(true); - expect(isNullOrUndefinedPropertyError(jscUndefined, 'proto')).toBe(true); - expect(isNullOrUndefinedPropertyError(jscNull)).toBe(true); - expect(isNullOrUndefinedPropertyError(jscNull.message, 'proto')).toBe( - true, - ); - expect(isNullOrUndefinedPropertyError(v8Modern, 'other')).toBe(false); - }); - - it('rejects non-matching errors and non-error values', () => { - expect(isNullOrUndefinedPropertyError(new Error('generic error'))).toBe( - false, - ); - expect( - isNullOrUndefinedPropertyError(new TypeError('something else')), - ).toBe(false); - expect(isNullOrUndefinedPropertyError(123)).toBe(false); - }); - }); }); diff --git a/handwritten/spanner/test/database.ts b/handwritten/spanner/test/database.ts index 150d61b62e10..a0fd4991b0da 100644 --- a/handwritten/spanner/test/database.ts +++ b/handwritten/spanner/test/database.ts @@ -27,7 +27,6 @@ import {Transform, Duplex} from 'stream'; import * as through from 'through2'; import * as pfy from '@google-cloud/promisify'; import {grpc} from 'google-gax'; -import {isNullOrUndefinedPropertyError} from 'google-test-utils'; import * as db from '../src/database'; import {Spanner, Instance, MutationGroup} from '../src'; import {MockError} from './mockserver/mockspanner'; @@ -972,7 +971,15 @@ describe('Database', () => { try { database.writeAtLeastOnce(null, () => {}); } catch (err) { - assert.ok(isNullOrUndefinedPropertyError(err, 'proto')); + const errorMessage = (err as grpc.ServiceError).message; + assert.ok( + errorMessage.includes( + "Cannot read properties of null (reading 'proto')", + ) || + errorMessage.includes("Cannot read property 'proto' of null") || + (errorMessage.includes('null is not an object') && + errorMessage.includes('proto')), + ); done(); }