Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion core/test-utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions core/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
160 changes: 160 additions & 0 deletions core/test-utils/src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
actual: ReadonlyArray<T>,
expected: ReadonlyArray<T>,
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);
}
}
Comment on lines +102 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The current implementation of assertArraySubclassStrictEqual uses a shallow copy ([...actual]) to normalize the array. However, if the array contains nested array subclasses (for example, an array of Spanner RowImpl instances like [row1, row2]), the shallow copy will not normalize the nested elements. On runtimes that require strict prototype equality (such as Node.js < 20 and Bun), assert.deepStrictEqual will still fail when comparing these nested subclass instances against plain arrays.

To support nested array subclasses and arrays of rows, we should recursively normalize any array subclasses to plain arrays.

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<T>(
  actual: ReadonlyArray<T>,
  expected: ReadonlyArray<T>,
  message?: string | Error,
  versions?: RuntimeVersions,
): void {
  if (requiresStrictArrayPrototypeEquality(versions)) {
    assert.deepStrictEqual(toPlainArray(actual), toPlainArray(expected), message);
  } else {
    assert.deepStrictEqual(actual, expected, message);
  }
}
References
  1. Prefer using Node version-based branching for temporary test workarounds (such as handling prototype equality differences in assert.deepStrictEqual) instead of permanently simplifying or weakening the assertion (e.g., by spreading an array). This keeps strict assertions active for newer Node versions and makes the workaround easy to identify and remove when older Node versions are deprecated.

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.

Updated in c453644 to recursively normalize nested Array subclasses via toPlainArray on runtimes requiring strict prototype equality, and guarded message !== undefined before passing the 3rd argument to assert.deepStrictEqual for Node.js 26 compatibility.


/**
* 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;
}
158 changes: 158 additions & 0 deletions core/test-utils/test/runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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();
});
Comment on lines +86 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add a test case to verify that assertArraySubclassStrictEqual correctly handles nested array subclasses (such as an array of rows) on runtimes requiring strict prototype equality.

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

      // 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();
    });

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.

Added test coverage for nested Array subclasses ([new CustomRow('a', 'b')]) and custom assertion messages in c453644.

});

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 '#<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);
});
});
});
2 changes: 1 addition & 1 deletion handwritten/spanner/cloudbuild-regular-sessions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion handwritten/spanner/cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
1 change: 1 addition & 0 deletions handwritten/spanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading