Skip to content

Commit

Permalink
refactor: testing matcher and logging
Browse files Browse the repository at this point in the history
Refactors the error matcher to accommodate the fact that we know the shape of the things we are testing. This is only a change to our own matchers and is not publicly exposed. This was tested by intentionally breaking several tests and checking outcomes. The debugginginformation provided by the assertion is also improved
  • Loading branch information
benlesh committed Oct 27, 2023
1 parent 8b9d0ef commit 2913a4f
Showing 1 changed file with 162 additions and 36 deletions.
198 changes: 162 additions & 36 deletions packages/rxjs/spec/helpers/observableMatcher.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,175 @@
import * as _ from 'lodash';
import * as chai from 'chai';
import { ErrorNotification, NextNotification, ObservableNotification } from 'rxjs';
import { TestMessage } from 'rxjs/internal/testing/TestMessage';
import { SubscriptionLog } from 'rxjs/internal/testing/subscription-logging';

function stringify(x: any): string {
return JSON.stringify(x, function (key: string, value: any) {
if (Array.isArray(value)) {
return '[' + value
.map(function (i) {
return '\n\t' + stringify(i);
}) + '\n]';
}
return value;
})
.replace(/\\"/g, '"')
.replace(/\\t/g, '\t')
.replace(/\\n/g, '\n');
}

function deleteErrorNotificationStack(marble: any) {
const { notification } = marble;
if (notification) {
const { kind, error } = notification;
if (kind === 'E' && error instanceof Error) {
notification.error = { name: error.name, message: error.message };
}
function stringifyValue(obj: any): string {
// Handle null
if (obj === null) {
return 'null';
}

// Check if it's a plain object
if (typeof obj === 'object' && (Array.isArray(obj) || obj.constructor === Object)) {
return JSON.stringify(obj);
}

// If it's an instance of a class (or built-in like Date, RegExp, etc.)
if (typeof obj === 'object' && obj.constructor && obj.constructor.name) {
return `[instanceof ${obj.constructor.name}]`;
}

// Just in case there's some edge case not covered, return a generic string representation
return String(obj);
}

function testMessageToString(testMessage: TestMessage, indent: number, frameOffset: number) {
const indentation = ' '.repeat(indent);
const { notification, frame } = testMessage;
const currentFrame = frame + frameOffset;
let result = `\t${indentation}${currentFrame}: `;

switch (notification.kind) {
case 'N':
if (isTestMessageArray(notification.value)) {
result += `$ {\n${indentation}${testMessagesToString(notification.value, indent + 1, currentFrame)}\n\t${indentation}}`;
} else {
result += stringifyValue(notification.value);
}
break;
case 'E':
result += 'ERROR';
if (notification.error?.name) {
result += ` ${notification.error.name}`;
}
if (notification.error?.message) {
result += `: ${notification.error.message}`;
}
break;
case 'C':
result += 'COMPLETE';
break;
}
return marble;

return result;
}

function testMessagesToString(testMessages: TestMessage[], indent = 0, frameOffset = 0) {
return testMessages.map((testMessage) => testMessageToString(testMessage, indent, frameOffset)).join('\n');
}

export function observableMatcher(actual: any, expected: any) {
if (Array.isArray(actual) && Array.isArray(expected)) {
actual = actual.map(deleteErrorNotificationStack);
expected = expected.map(deleteErrorNotificationStack);
const passed = _.isEqual(actual, expected);
if (passed) {
return;
if (!testMessagesEqual(actual, expected)) {
if (isTestMessageArray(expected)) {
let message = '\n\tExpected \n';
message += testMessagesToString(actual, 1);
message += '\n\tto equal \n';
message += testMessagesToString(expected, 1);

chai.assert(false, message);
} else {
let message = '\n\tExpected \n';
message += '\t\t' + JSON.stringify(actual);
message += '\n\tto equal \n';
message += '\t\t' + JSON.stringify(expected);

chai.assert(false, message);
}
}
}

function testMessagesEqual(expected: SubscriptionLog[] | TestMessage[], actual: SubscriptionLog[] | TestMessage[]) {
if (expected.length !== actual.length) {
// If they're not the same length, we know they're not equal.
return false;
}

if (expected.length === 0) {
// Two empty arrays are always going to be equal.
return true;
}

if (isTestMessageArray(expected)) {
if (!isTestMessageArray(actual)) {
return false;
}

let message = '\nExpected \n';
actual.forEach((x: any) => message += `\t${stringify(x)}\n`);
// TestMessages
for (let i = 0; i < expected.length; i++) {
const aMsg = expected[i];
const bMsg = actual[i];
if (aMsg.frame !== bMsg.frame) {
return false;
}
const aNotification = aMsg.notification;
const bNotification = bMsg.notification;

if (aNotification.kind !== bNotification.kind) {
return false;
}
if (aNotification.kind === 'N') {
const aNotificationValue = aNotification.value;
const bNotificationValue = (bNotification as NextNotification<any>).value;

message += '\t\nto deep equal \n';
expected.forEach((x: any) => message += `\t${stringify(x)}\n`);
if (isTestMessageArray(aNotificationValue)) {
// We are testing inner observable values.
// That means we'll be matching test messages for that inner observable.
if (!isTestMessageArray(bNotificationValue)) {
return false;
}

if (!testMessagesEqual(aNotificationValue, bNotificationValue)) {
return false;
}
} else {
return _.isEqual(aNotificationValue, bNotificationValue);
}
} else if (aNotification.kind === 'E') {
return errorNotifcationsEqual(aNotification, bNotification as ErrorNotification);
}
}
return true;
}

if (isSubscriptionLogArray(expected)) {
if (!isSubscriptionLogArray(actual)) {
return false;
}

for (let i = 0; i < expected.length; i++) {
const aLog = expected[i];
const bLog = actual[i];

if (aLog.subscribedFrame !== bLog.subscribedFrame || aLog.unsubscribedFrame !== bLog.unsubscribedFrame) {
return false;
}
}

chai.assert(passed, message);
} else {
chai.assert.deepEqual(actual, expected);
return true;
}

return false;
}

function errorNotifcationsEqual(a: ErrorNotification, b: ErrorNotification) {
return a.error.name === b.error.name && a.error.message === b.error.message;
}

function isTestMessageArray(input: unknown): input is TestMessage[] {
return isArrayOf<TestMessage>(input, 'frame');
}

function isSubscriptionLogArray(input: unknown): input is SubscriptionLog[] {
return isArrayOf<SubscriptionLog>(input, 'subscribedFrame');
}

function isArrayOf<T>(input: unknown, propName: keyof T): input is T[] {
if (!Array.isArray(input)) return false;

// An empty array could match any type of array.
if (input.length === 0) return true;

const first = input[0];
return typeof first === 'object' && first && propName in first;
}

0 comments on commit 2913a4f

Please sign in to comment.