-
Notifications
You must be signed in to change notification settings - Fork 722
feat(test-utils): add runtime detection predicates and cross-engine assertion helpers #9492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
eecb0fa
44e8c15
c453644
e42a9f1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a test case to verify that 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();
});
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added test coverage for nested |
||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation of
assertArraySubclassStrictEqualuses a shallow copy ([...actual]) to normalize the array. However, if the array contains nested array subclasses (for example, an array of SpannerRowImplinstances 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.deepStrictEqualwill 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.
References
There was a problem hiding this comment.
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
Arraysubclasses viatoPlainArrayon runtimes requiring strict prototype equality, and guardedmessage !== undefinedbefore passing the 3rd argument toassert.deepStrictEqualfor Node.js 26 compatibility.