diff --git a/src/application/Parser/ApplicationParser.ts b/src/application/Parser/ApplicationParser.ts index 612c5b9d..27c4c08e 100644 --- a/src/application/Parser/ApplicationParser.ts +++ b/src/application/Parser/ApplicationParser.ts @@ -31,7 +31,7 @@ function validateCollectionsData( ) { validator.assertNonEmptyCollection({ value: collections, - valueName: 'collections', + valueName: 'Collections', }); } diff --git a/src/application/Parser/CategoryCollectionParser.ts b/src/application/Parser/CategoryCollectionParser.ts index 7c92c7d3..be10a9d0 100644 --- a/src/application/Parser/CategoryCollectionParser.ts +++ b/src/application/Parser/CategoryCollectionParser.ts @@ -45,14 +45,14 @@ function validateCollection( ): void { validator.assertObject({ value: content, - valueName: 'collection', + valueName: 'Collection', allowedProperties: [ 'os', 'scripting', 'actions', 'functions', ], }); validator.assertNonEmptyCollection({ value: content.actions, - valueName: '"actions" in collection', + valueName: '\'actions\' in collection', }); } diff --git a/src/application/Parser/Common/ContextualError.ts b/src/application/Parser/Common/ContextualError.ts index fd00b655..5d4cb9c6 100644 --- a/src/application/Parser/Common/ContextualError.ts +++ b/src/application/Parser/Common/ContextualError.ts @@ -1,42 +1,116 @@ import { CustomError } from '@/application/Common/CustomError'; +import { indentText } from '@/application/Common/Text/IndentText'; export interface ErrorWithContextWrapper { ( - error: Error, + innerError: Error, additionalContext: string, ): Error; } export const wrapErrorWithAdditionalContext: ErrorWithContextWrapper = ( - error: Error, - additionalContext: string, + innerError, + additionalContext, ) => { - return (error instanceof ContextualError ? error : new ContextualError(error)) - .withAdditionalContext(additionalContext); + if (!additionalContext) { + throw new Error('Missing additional context'); + } + return new ContextualError({ + innerError, + additionalContext, + }); }; -/* AggregateError is similar but isn't well-serialized or displayed by browsers */ +/** + * Class for building a detailed error trace. + * + * Alternatives considered: + * - `AggregateError`: + * Similar but not well-serialized or displayed by browsers such as Chromium (last tested v126). + * - `cause` property: + * Not displayed by all browsers (last tested v126). + * Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause + * + * This is immutable where the constructor sets the values because using getter functions such as + * `get cause()`, `get message()` does not work on Chromium (last tested v126), but works fine on + * Firefox (last tested v127). + */ class ContextualError extends CustomError { - private readonly additionalContext = new Array(); + constructor(public readonly context: ErrorContext) { + super( + generateDetailedErrorMessageWithContext(context), + { + cause: context.innerError, + }, + ); + } +} - constructor( - public readonly innerError: Error, - ) { - super(); +interface ErrorContext { + readonly innerError: Error; + readonly additionalContext: string; +} + +function generateDetailedErrorMessageWithContext( + context: ErrorContext, +): string { + return [ + '\n', + // Display the current error message first, then the root cause. + // This prevents repetitive main messages for errors with a `cause:` chain, + // aligning with browser error display conventions. + context.additionalContext, + '\n', + 'Error Trace (starting from root cause):', + indentText( + formatErrorTrace( + // Displaying contexts from the top frame (deepest, most recent) aligns with + // common debugger/compiler standard. + extractErrorTraceAscendingFromDeepest(context), + ), + ), + '\n', + ].join('\n'); +} + +function extractErrorTraceAscendingFromDeepest( + context: ErrorContext, +): string[] { + const originalError = findRootError(context.innerError); + const contextsDescendingFromMostRecent: string[] = [ + context.additionalContext, + ...gatherContextsFromErrorChain(context.innerError), + originalError.toString(), + ]; + const contextsAscendingFromDeepest = contextsDescendingFromMostRecent.reverse(); + return contextsAscendingFromDeepest; +} + +function findRootError(error: Error): Error { + if (error instanceof ContextualError) { + return findRootError(error.context.innerError); } + return error; +} - public withAdditionalContext(additionalContext: string): this { - this.additionalContext.push(additionalContext); - return this; +function gatherContextsFromErrorChain( + error: Error, + accumulatedContexts: string[] = [], +): string[] { + if (error instanceof ContextualError) { + accumulatedContexts.push(error.context.additionalContext); + return gatherContextsFromErrorChain(error.context.innerError, accumulatedContexts); } + return accumulatedContexts; +} - public get message(): string { // toString() is not used when Chromium logs it on console - return [ - '\n', - this.innerError.message, - '\n', - 'Additional context:', - ...this.additionalContext.map((context, index) => `${index + 1}: ${context}`), - ].join('\n'); +function formatErrorTrace( + errorMessages: readonly string[], +): string { + if (errorMessages.length === 1) { + return errorMessages[0]; } + return errorMessages + .map((context, index) => `${index + 1}.${indentText(context)}`) + .join('\n'); } diff --git a/src/application/Parser/Common/TypeValidator.ts b/src/application/Parser/Common/TypeValidator.ts index 78f79cf5..eaefab54 100644 --- a/src/application/Parser/Common/TypeValidator.ts +++ b/src/application/Parser/Common/TypeValidator.ts @@ -108,7 +108,7 @@ function assertArray( valueName: string, ): asserts value is Array { if (!isArray(value)) { - throw new Error(`'${valueName}' should be of type 'array', but is of type '${typeof value}'.`); + throw new Error(`${valueName} should be of type 'array', but is of type '${typeof value}'.`); } } @@ -117,7 +117,7 @@ function assertString( valueName: string, ): asserts value is string { if (!isString(value)) { - throw new Error(`'${valueName}' should be of type 'string', but is of type '${typeof value}'.`); + throw new Error(`${valueName} should be of type 'string', but is of type '${typeof value}'.`); } } diff --git a/src/application/Parser/Executable/CategoryParser.ts b/src/application/Parser/Executable/CategoryParser.ts index 39d77e51..88d77a1b 100644 --- a/src/application/Parser/Executable/CategoryParser.ts +++ b/src/application/Parser/Executable/CategoryParser.ts @@ -84,7 +84,7 @@ function ensureValidCategory( }); validator.assertType((v) => v.assertObject({ value: category, - valueName: category.category ?? 'category', + valueName: `Category '${category.category}'` ?? 'Category', allowedProperties: [ 'docs', 'children', 'category', ], diff --git a/src/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts b/src/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts index 4483f51c..b53f5494 100644 --- a/src/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts +++ b/src/application/Parser/Executable/Script/Compiler/Function/Call/Argument/FunctionCallArgument.ts @@ -22,7 +22,7 @@ export const createFunctionCallArgument: FunctionCallArgumentFactory = ( utilities.validateParameterName(parameterName); utilities.typeValidator.assertNonEmptyString({ value: argumentValue, - valueName: `Missing argument value for the parameter "${parameterName}".`, + valueName: `Function parameter '${parameterName}'`, }); return { parameterName, diff --git a/src/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.ts b/src/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.ts index d7dba7e3..94e56ee5 100644 --- a/src/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.ts +++ b/src/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.ts @@ -42,7 +42,7 @@ function getCallSequence(calls: FunctionCallsData, validator: TypeValidator): Fu if (isArray(calls)) { validator.assertNonEmptyCollection({ value: calls, - valueName: 'function call sequence', + valueName: 'Function call sequence', }); return calls as FunctionCallData[]; } @@ -56,7 +56,7 @@ function parseFunctionCall( ): FunctionCall { utilities.typeValidator.assertObject({ value: call, - valueName: 'function call', + valueName: 'Function call', allowedProperties: ['function', 'parameters'], }); const callArgs = parseArgs(call.parameters, utilities.createCallArgument); diff --git a/src/application/Parser/Executable/Script/Compiler/Function/Shared/ParameterNameValidator.ts b/src/application/Parser/Executable/Script/Compiler/Function/Shared/ParameterNameValidator.ts index 48a3a2f3..9577c7c6 100644 --- a/src/application/Parser/Executable/Script/Compiler/Function/Shared/ParameterNameValidator.ts +++ b/src/application/Parser/Executable/Script/Compiler/Function/Shared/ParameterNameValidator.ts @@ -13,7 +13,7 @@ export const validateParameterName = ( ) => { typeValidator.assertNonEmptyString({ value: parameterName, - valueName: 'parameter name', + valueName: 'Parameter name', rule: { expectedMatch: /^[0-9a-zA-Z]+$/, errorMessage: `parameter name must be alphanumeric but it was "${parameterName}".`, diff --git a/src/application/Parser/Executable/Script/ScriptParser.ts b/src/application/Parser/Executable/Script/ScriptParser.ts index 446227c5..d7be9d0c 100644 --- a/src/application/Parser/Executable/Script/ScriptParser.ts +++ b/src/application/Parser/Executable/Script/ScriptParser.ts @@ -102,7 +102,7 @@ function validateScript( ): asserts script is NonNullable { validator.assertType((v) => v.assertObject({ value: script, - valueName: script.name ?? 'script', + valueName: `Script '${script.name}'` ?? 'Script', allowedProperties: [ 'name', 'recommend', 'code', 'revertCode', 'call', 'docs', ], diff --git a/src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts b/src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts index baf065c1..83876990 100644 --- a/src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts +++ b/src/application/Parser/ScriptingDefinition/ScriptingDefinitionParser.ts @@ -37,7 +37,7 @@ function validateData( ): void { validator.assertObject({ value: data, - valueName: 'scripting definition', + valueName: 'Scripting definition', allowedProperties: ['language', 'fileExtension', 'startCode', 'endCode'], }); } diff --git a/tests/unit/application/Parser/ApplicationParser.spec.ts b/tests/unit/application/Parser/ApplicationParser.spec.ts index b5ad234e..b3cb15d3 100644 --- a/tests/unit/application/Parser/ApplicationParser.spec.ts +++ b/tests/unit/application/Parser/ApplicationParser.spec.ts @@ -134,7 +134,7 @@ describe('ApplicationParser', () => { const data = [new CollectionDataStub()]; const expectedAssertion: NonEmptyCollectionAssertion = { value: data, - valueName: 'collections', + valueName: 'Collections', }; const validator = new TypeValidatorStub(); const sut = new ApplicationParserBuilder() diff --git a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts index c58419e2..0b311704 100644 --- a/tests/unit/application/Parser/CategoryCollectionParser.spec.ts +++ b/tests/unit/application/Parser/CategoryCollectionParser.spec.ts @@ -28,7 +28,7 @@ describe('CategoryCollectionParser', () => { const data = new CollectionDataStub(); const expectedAssertion: ObjectAssertion = { value: data, - valueName: 'collection', + valueName: 'Collection', allowedProperties: [ 'os', 'scripting', 'actions', 'functions', ], @@ -48,7 +48,7 @@ describe('CategoryCollectionParser', () => { const actions = [getCategoryStub('test1'), getCategoryStub('test2')]; const expectedAssertion: NonEmptyCollectionAssertion = { value: actions, - valueName: '"actions" in collection', + valueName: '\'actions\' in collection', }; const validator = new TypeValidatorStub(); const context = new TestContext() diff --git a/tests/unit/application/Parser/Common/ContextualError.spec.ts b/tests/unit/application/Parser/Common/ContextualError.spec.ts index 3027b098..a4124f2a 100644 --- a/tests/unit/application/Parser/Common/ContextualError.spec.ts +++ b/tests/unit/application/Parser/Common/ContextualError.spec.ts @@ -1,101 +1,168 @@ import { describe, it, expect } from 'vitest'; import { CustomError } from '@/application/Common/CustomError'; import { wrapErrorWithAdditionalContext } from '@/application/Parser/Common/ContextualError'; -import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; +import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { indentText } from '@/application/Common/Text/IndentText'; +import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; describe('wrapErrorWithAdditionalContext', () => { - it('preserves the original error when wrapped', () => { - // arrange - const expectedError = new Error(); - const context = new TestContext() - .withError(expectedError); - // act - const error = context.wrap(); - // assert - const actualError = extractInnerErrorFromContextualError(error); - expect(actualError).to.equal(expectedError); - }); - it('maintains the original error when re-wrapped', () => { - // arrange - const expectedError = new Error(); - - // act - const firstError = new TestContext() - .withError(expectedError) - .withAdditionalContext('first error') - .wrap(); - const secondError = new TestContext() - .withError(firstError) - .withAdditionalContext('second error') - .wrap(); - - // assert - const actualError = extractInnerErrorFromContextualError(secondError); - expect(actualError).to.equal(expectedError); - }); - it(`the object extends ${CustomError.name}`, () => { + it(`extend ${CustomError.name}`, () => { // arrange const expected = CustomError; // act const error = new TestContext() - .wrap(); + .build(); // assert expect(error).to.be.an.instanceof(expected); }); + describe('inner error preservation', () => { + it('preserves the original error', () => { + // arrange + const expectedError = new Error(); + const context = new TestContext() + .withInnerError(expectedError); + // act + const error = context.build(); + // assert + const actualError = getInnerErrorFromContextualError(error); + expect(actualError).to.equal(expectedError); + }); + it('sets the original error as the cause', () => { + // arrange + const expectedError = new Error('error causing the issue'); + const context = new TestContext() + .withInnerError(expectedError); + // act + const error = context.build(); + // assert + const actualError = error.cause; + expect(actualError).to.equal(expectedError); + }); + }); describe('error message construction', () => { - it('includes the message from the original error', () => { + it('includes the original error message', () => { // arrange const expectedOriginalErrorMessage = 'Message from the inner error'; // act const error = new TestContext() - .withError(new Error(expectedOriginalErrorMessage)) - .wrap(); + .withInnerError(new Error(expectedOriginalErrorMessage)) + .build(); // assert expect(error.message).contains(expectedOriginalErrorMessage); }); - it('appends provided additional context to the error message', () => { + it('includes original error toString() if message is absent', () => { // arrange - const expectedAdditionalContext = 'Expected additional context message'; + const originalError = new Error(); + const expectedPartInMessage = originalError.toString(); // act const error = new TestContext() - .withAdditionalContext(expectedAdditionalContext) - .wrap(); + .withInnerError(originalError) + .build(); // assert - expect(error.message).contains(expectedAdditionalContext); + expect(error.message).contains(expectedPartInMessage); }); - it('appends multiple contexts to the error message in sequential order', () => { + it('appends additional context to the error message', () => { // arrange - const expectedFirstContext = 'First context'; - const expectedSecondContext = 'Second context'; + const expectedAdditionalContext = 'Expected additional context message'; // act - const firstError = new TestContext() - .withAdditionalContext(expectedFirstContext) - .wrap(); - const secondError = new TestContext() - .withError(firstError) - .withAdditionalContext(expectedSecondContext) - .wrap(); + const error = new TestContext() + .withAdditionalContext(expectedAdditionalContext) + .build(); // assert - const messageLines = splitTextIntoLines(secondError.message); - expect(messageLines).to.contain(`1: ${expectedFirstContext}`); - expect(messageLines).to.contain(`2: ${expectedSecondContext}`); + expect(error.message).contains(expectedAdditionalContext); + }); + describe('message order', () => { + it('displays the latest context before the original error message', () => { + // arrange + const originalErrorMessage = 'Original message from the inner error to be shown first'; + const additionalContext = 'Context to be displayed after'; + + // act + const error = new TestContext() + .withInnerError(new Error(originalErrorMessage)) + .withAdditionalContext(additionalContext) + .build(); + + // assert + expectMessageDisplayOrder(error.message, { + firstMessage: additionalContext, + secondMessage: originalErrorMessage, + }); + }); + it('appends multiple contexts from most specific to most general', () => { + // arrange + const deepErrorContext = 'first-context'; + const parentErrorContext = 'second-context'; + + // act + const deepError = new TestContext() + .withAdditionalContext(deepErrorContext) + .build(); + const parentError = new TestContext() + .withInnerError(deepError) + .withAdditionalContext(parentErrorContext) + .build(); + const grandParentError = new TestContext() + .withInnerError(parentError) + .withAdditionalContext('latest-error') + .build(); + + // assert + expectMessageDisplayOrder(grandParentError.message, { + firstMessage: deepErrorContext, + secondMessage: parentErrorContext, + }); + }); }); }); + describe('throws error when context is missing', () => { + itEachAbsentStringValue((absentValue) => { + // arrange + const expectedError = 'Missing additional context'; + const context = new TestContext() + .withAdditionalContext(absentValue); + // act + const act = () => context.build(); + // assert + expect(act).to.throw(expectedError); + }, { excludeNull: true, excludeUndefined: true }); + }); }); +function expectMessageDisplayOrder( + actualMessage: string, + expectation: { + readonly firstMessage: string; + readonly secondMessage: string; + }, +): void { + const firstMessageIndex = actualMessage.indexOf(expectation.firstMessage); + const secondMessageIndex = actualMessage.indexOf(expectation.secondMessage); + expect(firstMessageIndex).to.be.lessThan(secondMessageIndex, formatAssertionMessage([ + 'Error output order does not match the expected order.', + 'Expected the first message to be displayed before the second message.', + 'Expected first message:', + indentText(expectation.firstMessage), + 'Expected second message:', + indentText(expectation.secondMessage), + 'Received message:', + indentText(actualMessage), + ])); +} + class TestContext { - private error: Error = new Error(); + private innerError: Error = new Error(`[${TestContext.name}] original error`); private additionalContext = `[${TestContext.name}] additional context`; - public withError(error: Error) { - this.error = error; + public withInnerError(innerError: Error) { + this.innerError = innerError; return this; } @@ -104,19 +171,21 @@ class TestContext { return this; } - public wrap(): ReturnType { + public build(): ReturnType { return wrapErrorWithAdditionalContext( - this.error, + this.innerError, this.additionalContext, ); } } -function extractInnerErrorFromContextualError(error: Error): Error { - const innerErrorProperty = 'innerError'; - if (!(innerErrorProperty in error)) { - throw new Error(`${innerErrorProperty} property is missing`); +function getInnerErrorFromContextualError(error: Error & { + readonly context?: { + readonly innerError?: Error; + }, +}): Error { + if (error?.context?.innerError instanceof Error) { + return error.context.innerError; } - const actualError = error[innerErrorProperty]; - return actualError as Error; + throw new Error('Error must have a context with a valid innerError property.'); } diff --git a/tests/unit/application/Parser/Common/TypeValidator.spec.ts b/tests/unit/application/Parser/Common/TypeValidator.spec.ts index 5bac11a2..410d55c9 100644 --- a/tests/unit/application/Parser/Common/TypeValidator.spec.ts +++ b/tests/unit/application/Parser/Common/TypeValidator.spec.ts @@ -217,7 +217,7 @@ describe('createTypeValidator', () => { }) => { it(description, () => { const valueName = 'invalidValue'; - const expectedMessage = `'${valueName}' should be of type 'string', but is of type '${typeof invalidValue}'.`; + const expectedMessage = `${valueName} should be of type 'string', but is of type '${typeof invalidValue}'.`; const { assertNonEmptyString } = createTypeValidator(); // act const act = () => assertNonEmptyString({ value: invalidValue, valueName }); diff --git a/tests/unit/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.spec.ts b/tests/unit/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.spec.ts index 1a318ef4..720dc90e 100644 --- a/tests/unit/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.spec.ts +++ b/tests/unit/application/Parser/Executable/Script/Compiler/Function/Call/FunctionCallsParser.spec.ts @@ -57,7 +57,7 @@ describe('FunctionCallsParser', () => { const data = new FunctionCallDataStub(); const expectedAssertion: ObjectAssertion = { value: data, - valueName: 'function call', + valueName: 'Function call', allowedProperties: [ 'function', 'parameters', ], @@ -117,7 +117,7 @@ describe('FunctionCallsParser', () => { const data: FunctionCallsData = [new FunctionCallDataStub()]; const expectedAssertion: NonEmptyCollectionAssertion = { value: data, - valueName: 'function call sequence', + valueName: 'Function call sequence', }; const validator = new TypeValidatorStub(); const context = new TestContext() @@ -134,7 +134,7 @@ describe('FunctionCallsParser', () => { const data: FunctionCallsData = [expectedValidatedCallData]; const expectedAssertion: ObjectAssertion = { value: expectedValidatedCallData, - valueName: 'function call', + valueName: 'Function call', allowedProperties: [ 'function', 'parameters', ],