Improve context for errors thrown by compiler
This commit introduces a custom error object to provide additional context for errors throwing during parsing and compiling operations, improving troubleshooting. By integrating error context handling, the error messages become more informative and user-friendly, providing sequence of trace with context to aid in troubleshooting. Changes include: - Introduce custom error object that extends errors with contextual information. This replaces previous usages of `AggregateError` which is not displayed well by browsers when logged. - Improve parsing functions to encapsulate error context with more details. - Increase unit test coverage and refactor the related code to be more testable.
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
export function collectExceptionMessage(action: () => unknown): string {
|
||||
let message: string | undefined;
|
||||
return collectException(action).message;
|
||||
}
|
||||
|
||||
function collectException(
|
||||
action: () => unknown,
|
||||
): Error {
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
action();
|
||||
} catch (e) {
|
||||
message = e.message;
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
if (!message) {
|
||||
if (!error) {
|
||||
throw new Error('action did not throw');
|
||||
}
|
||||
return message;
|
||||
return error;
|
||||
}
|
||||
|
||||
19
tests/unit/shared/Stubs/CategoryFactoryStub.ts
Normal file
19
tests/unit/shared/Stubs/CategoryFactoryStub.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { CategoryFactory } from '@/application/Parser/CategoryParser';
|
||||
import type { CategoryInitParameters } from '@/domain/Category';
|
||||
import type { ICategory } from '@/domain/ICategory';
|
||||
import { CategoryStub } from './CategoryStub';
|
||||
|
||||
export function createCategoryFactorySpy(): {
|
||||
readonly categoryFactorySpy: CategoryFactory;
|
||||
getInitParameters: (category: ICategory) => CategoryInitParameters | undefined;
|
||||
} {
|
||||
const createdCategories = new Map<ICategory, CategoryInitParameters>();
|
||||
return {
|
||||
categoryFactorySpy: (parameters) => {
|
||||
const category = new CategoryStub(55);
|
||||
createdCategories.set(category, parameters);
|
||||
return category;
|
||||
},
|
||||
getInitParameters: (category) => createdCategories.get(category),
|
||||
};
|
||||
}
|
||||
@@ -19,16 +19,16 @@ export class CodeValidatorStub implements ICodeValidator {
|
||||
});
|
||||
}
|
||||
|
||||
public assertHistory(expected: {
|
||||
public assertHistory(expectation: {
|
||||
validatedCodes: readonly (string | undefined)[],
|
||||
rules: readonly Constructible<ICodeValidationRule>[],
|
||||
}) {
|
||||
expect(this.callHistory).to.have.lengthOf(expected.validatedCodes.length);
|
||||
expect(this.callHistory).to.have.lengthOf(expectation.validatedCodes.length);
|
||||
const actualValidatedCodes = this.callHistory.map((args) => args.code);
|
||||
expect(actualValidatedCodes.sort()).deep.equal([...expected.validatedCodes].sort());
|
||||
expect(actualValidatedCodes.sort()).deep.equal([...expectation.validatedCodes].sort());
|
||||
for (const call of this.callHistory) {
|
||||
const actualRules = call.rules.map((rule) => rule.constructor);
|
||||
expect(actualRules.sort()).to.deep.equal([...expected.rules].sort());
|
||||
expect(actualRules.sort()).to.deep.equal([...expectation.rules].sort());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
tests/unit/shared/Stubs/ErrorWithContextWrapperStub.ts
Normal file
4
tests/unit/shared/Stubs/ErrorWithContextWrapperStub.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
|
||||
export const errorWithContextWrapperStub
|
||||
: ErrorWithContextWrapper = (error, message) => new Error(`[stubbed error wrapper] ${error.message} + ${message}`);
|
||||
67
tests/unit/shared/Stubs/ErrorWrapperStub.ts
Normal file
67
tests/unit/shared/Stubs/ErrorWrapperStub.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ErrorWithContextWrapper } from '@/application/Parser/ContextualError';
|
||||
|
||||
export class ErrorWrapperStub {
|
||||
private errorToReturn: Error | undefined;
|
||||
|
||||
private parameters?: Parameters<ErrorWithContextWrapper>;
|
||||
|
||||
public get lastError(): Error | undefined {
|
||||
if (!this.parameters) {
|
||||
return undefined;
|
||||
}
|
||||
return getError(this.parameters);
|
||||
}
|
||||
|
||||
public get lastContext(): string | undefined {
|
||||
if (!this.parameters) {
|
||||
return undefined;
|
||||
}
|
||||
return getAdditionalContext(this.parameters);
|
||||
}
|
||||
|
||||
public withError(error: Error): this {
|
||||
this.errorToReturn = error;
|
||||
return this;
|
||||
}
|
||||
|
||||
public get(): ErrorWithContextWrapper {
|
||||
return (...args) => {
|
||||
this.parameters = args;
|
||||
if (this.errorToReturn) {
|
||||
return this.errorToReturn;
|
||||
}
|
||||
return new Error(
|
||||
`[${ErrorWrapperStub.name}] Error wrapped with additional context.`
|
||||
+ `\nAdditional context: ${getAdditionalContext(args)}`
|
||||
+ `\nWrapped error message: ${getError(args).message}`
|
||||
+ `\nWrapped error stack trace:\n${getLimitedStackTrace(getError(args), 5)}`,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getAdditionalContext(
|
||||
parameters: Parameters<ErrorWithContextWrapper>,
|
||||
): string {
|
||||
return parameters[1];
|
||||
}
|
||||
|
||||
function getError(
|
||||
parameters: Parameters<ErrorWithContextWrapper>,
|
||||
): Error {
|
||||
return parameters[0];
|
||||
}
|
||||
|
||||
function getLimitedStackTrace(
|
||||
error: Error,
|
||||
limit: number,
|
||||
): string {
|
||||
const { stack } = error;
|
||||
if (!stack) {
|
||||
return 'No stack trace available';
|
||||
}
|
||||
return stack
|
||||
.split('\n')
|
||||
.slice(0, limit + 1)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -2,22 +2,33 @@ import type { CompiledCode } from '@/application/Parser/Script/Compiler/Function
|
||||
import type { FunctionCallCompiler } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompiler';
|
||||
import type { ISharedFunctionCollection } from '@/application/Parser/Script/Compiler/Function/ISharedFunctionCollection';
|
||||
import type { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
|
||||
import { CompiledCodeStub } from './CompiledCodeStub';
|
||||
|
||||
interface IScenario {
|
||||
calls: FunctionCall[];
|
||||
functions: ISharedFunctionCollection;
|
||||
result: CompiledCode;
|
||||
interface FunctionCallCompilationTestScenario {
|
||||
readonly calls: FunctionCall[];
|
||||
readonly functions: ISharedFunctionCollection;
|
||||
readonly result: CompiledCode;
|
||||
}
|
||||
|
||||
export class FunctionCallCompilerStub implements FunctionCallCompiler {
|
||||
public scenarios = new Array<IScenario>();
|
||||
public scenarios = new Array<FunctionCallCompilationTestScenario>();
|
||||
|
||||
private defaultCompiledCode: CompiledCode = new CompiledCodeStub()
|
||||
.withCode(`[${FunctionCallCompilerStub.name}] function code`)
|
||||
.withRevertCode(`[${FunctionCallCompilerStub.name}] function revert code`);
|
||||
|
||||
public setup(
|
||||
calls: FunctionCall[],
|
||||
functions: ISharedFunctionCollection,
|
||||
result: CompiledCode,
|
||||
) {
|
||||
): this {
|
||||
this.scenarios.push({ calls, functions, result });
|
||||
return this;
|
||||
}
|
||||
|
||||
public withDefaultCompiledCode(defaultCompiledCode: CompiledCode): this {
|
||||
this.defaultCompiledCode = defaultCompiledCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
public compileFunctionCalls(
|
||||
@@ -29,10 +40,7 @@ export class FunctionCallCompilerStub implements FunctionCallCompiler {
|
||||
if (predefined) {
|
||||
return predefined.result;
|
||||
}
|
||||
return {
|
||||
code: 'function code [FunctionCallCompilerStub]',
|
||||
revertCode: 'function revert code [FunctionCallCompilerStub]',
|
||||
};
|
||||
return this.defaultCompiledCode;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export class FunctionParameterCollectionStub implements IFunctionParameterCollec
|
||||
public withParameterName(parameterName: string, isOptional = true) {
|
||||
const parameter = new FunctionParameterStub()
|
||||
.withName(parameterName)
|
||||
.withOptionality(isOptional);
|
||||
.withOptional(isOptional);
|
||||
this.addParameter(parameter);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export class FunctionParameterStub implements IFunctionParameter {
|
||||
return this;
|
||||
}
|
||||
|
||||
public withOptionality(isOptional: boolean) {
|
||||
public withOptional(isOptional: boolean) {
|
||||
this.isOptional = isOptional;
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
|
||||
import type { INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError';
|
||||
import { NodeType } from '@/application/Parser/NodeValidation/NodeType';
|
||||
import type { NodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataErrorContext';
|
||||
import { NodeDataType } from '@/application/Parser/NodeValidation/NodeDataType';
|
||||
import { CategoryDataStub } from './CategoryDataStub';
|
||||
|
||||
export class NodeDataErrorContextStub implements INodeDataErrorContext {
|
||||
public readonly type: NodeType = NodeType.Script;
|
||||
|
||||
public readonly selfNode: NodeData = new CategoryDataStub();
|
||||
|
||||
public readonly parentNode?: NodeData;
|
||||
export function createNodeDataErrorContextStub(): NodeDataErrorContext {
|
||||
return {
|
||||
type: NodeDataType.Category,
|
||||
selfNode: new CategoryDataStub(),
|
||||
parentNode: new CategoryDataStub(),
|
||||
};
|
||||
}
|
||||
|
||||
57
tests/unit/shared/Stubs/NodeDataValidatorStub.ts
Normal file
57
tests/unit/shared/Stubs/NodeDataValidatorStub.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { NodeData } from '@/application/Parser/NodeValidation/NodeData';
|
||||
import type { NodeDataValidator, NodeDataValidatorFactory } from '@/application/Parser/NodeValidation/NodeDataValidator';
|
||||
import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls';
|
||||
|
||||
export const createNodeDataValidatorFactoryStub
|
||||
: NodeDataValidatorFactory = () => new NodeDataValidatorStub();
|
||||
|
||||
export class NodeDataValidatorStub
|
||||
extends StubWithObservableMethodCalls<NodeDataValidator>
|
||||
implements NodeDataValidator {
|
||||
private assertThrowsOnFalseCondition = true;
|
||||
|
||||
public withAssertThrowsOnFalseCondition(enableAssertThrows: boolean): this {
|
||||
this.assertThrowsOnFalseCondition = enableAssertThrows;
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertValidName(nameValue: string): this {
|
||||
this.registerMethodCall({
|
||||
methodName: 'assertValidName',
|
||||
args: [nameValue],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public assertDefined(node: NodeData): this {
|
||||
this.registerMethodCall({
|
||||
methodName: 'assertDefined',
|
||||
args: [node],
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public assert(
|
||||
validationPredicate: () => boolean,
|
||||
errorMessage: string,
|
||||
): this {
|
||||
this.registerMethodCall({
|
||||
methodName: 'assert',
|
||||
args: [validationPredicate, errorMessage],
|
||||
});
|
||||
if (this.assertThrowsOnFalseCondition) {
|
||||
if (!validationPredicate()) {
|
||||
throw new Error(`[${NodeDataValidatorStub.name}] Assert validation failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public createContextualErrorMessage(errorMessage: string): string {
|
||||
this.registerMethodCall({
|
||||
methodName: 'createContextualErrorMessage',
|
||||
args: [errorMessage],
|
||||
});
|
||||
return `${NodeDataValidatorStub.name}: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
22
tests/unit/shared/Stubs/ScriptCodeFactoryStub.ts
Normal file
22
tests/unit/shared/Stubs/ScriptCodeFactoryStub.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ScriptCodeFactory } from '@/domain/ScriptCodeFactory';
|
||||
import type { IScriptCode } from '@/domain/IScriptCode';
|
||||
import { ScriptCodeStub } from './ScriptCodeStub';
|
||||
|
||||
export function createScriptCodeFactoryStub(
|
||||
options?: Partial<StubOptions>,
|
||||
): ScriptCodeFactory {
|
||||
let defaultCodePrefix = 'createScriptCodeFactoryStub';
|
||||
if (options?.defaultCodePrefix) {
|
||||
defaultCodePrefix += ` > ${options?.defaultCodePrefix}`;
|
||||
}
|
||||
return (
|
||||
() => options?.scriptCode ?? new ScriptCodeStub()
|
||||
.withExecute(`[${defaultCodePrefix}] default code`)
|
||||
.withRevert(`[${defaultCodePrefix}] revert code`)
|
||||
);
|
||||
}
|
||||
|
||||
interface StubOptions {
|
||||
readonly scriptCode?: IScriptCode;
|
||||
readonly defaultCodePrefix?: string;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { IScriptCode } from '@/domain/IScriptCode';
|
||||
|
||||
export class ScriptCodeStub implements IScriptCode {
|
||||
public execute = 'default execute code';
|
||||
public execute = `[${ScriptCodeStub.name}] default execute code`;
|
||||
|
||||
public revert = 'default revert code';
|
||||
public revert = `[${ScriptCodeStub.name}] default revert code`;
|
||||
|
||||
public withExecute(code: string) {
|
||||
this.execute = code;
|
||||
|
||||
19
tests/unit/shared/Stubs/ScriptFactoryStub.ts
Normal file
19
tests/unit/shared/Stubs/ScriptFactoryStub.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ScriptFactory } from '@/application/Parser/Script/ScriptParser';
|
||||
import type { IScript } from '@/domain/IScript';
|
||||
import type { ScriptInitParameters } from '@/domain/Script';
|
||||
import { ScriptStub } from './ScriptStub';
|
||||
|
||||
export function createScriptFactorySpy(): {
|
||||
readonly scriptFactorySpy: ScriptFactory;
|
||||
getInitParameters: (category: IScript) => ScriptInitParameters | undefined;
|
||||
} {
|
||||
const createdScripts = new Map<IScript, ScriptInitParameters>();
|
||||
return {
|
||||
scriptFactorySpy: (parameters) => {
|
||||
const script = new ScriptStub('script from factory stub');
|
||||
createdScripts.set(script, parameters);
|
||||
return script;
|
||||
},
|
||||
getInitParameters: (script) => createdScripts.get(script),
|
||||
};
|
||||
}
|
||||
37
tests/unit/shared/Stubs/ScriptParserStub.ts
Normal file
37
tests/unit/shared/Stubs/ScriptParserStub.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ScriptParser } from '@/application/Parser/Script/ScriptParser';
|
||||
import type { IScript } from '@/domain/IScript';
|
||||
import type { ScriptData } from '@/application/collections/';
|
||||
import { ScriptStub } from './ScriptStub';
|
||||
|
||||
export class ScriptParserStub {
|
||||
private readonly parsedScripts = new Map<IScript, Parameters<ScriptParser>>();
|
||||
|
||||
private readonly setupScripts = new Map<ScriptData, IScript>();
|
||||
|
||||
public get(): ScriptParser {
|
||||
return (...parameters) => {
|
||||
const [scriptData] = parameters;
|
||||
const script = this.setupScripts.get(scriptData)
|
||||
?? new ScriptStub(
|
||||
`[${ScriptParserStub.name}] parsed script stub number ${this.parsedScripts.size + 1}`,
|
||||
);
|
||||
this.parsedScripts.set(script, parameters);
|
||||
return script;
|
||||
};
|
||||
}
|
||||
|
||||
public getParseParameters(
|
||||
script: IScript,
|
||||
): Parameters<ScriptParser> {
|
||||
const parameters = this.parsedScripts.get(script);
|
||||
if (!parameters) {
|
||||
throw new Error('Script has never been parsed.');
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
public setupParsedResultForData(scriptData: ScriptData, parsedResult: IScript): this {
|
||||
this.setupScripts.set(scriptData, parsedResult);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import type { FunctionKeys } from '@/TypeHelpers';
|
||||
export abstract class StubWithObservableMethodCalls<T> {
|
||||
public readonly callHistory = new Array<MethodCall<T>>();
|
||||
|
||||
private readonly notifiableMethodCalls = new EventSource<MethodCall<T>>();
|
||||
|
||||
public get methodCalls(): IEventSource<MethodCall<T>> {
|
||||
return this.notifiableMethodCalls;
|
||||
}
|
||||
|
||||
private readonly notifiableMethodCalls = new EventSource<MethodCall<T>>();
|
||||
|
||||
protected registerMethodCall(name: MethodCall<T>) {
|
||||
this.callHistory.push(name);
|
||||
this.notifiableMethodCalls.notify(name);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { it, expect } from 'vitest';
|
||||
import type { Constructible } from '@/TypeHelpers';
|
||||
|
||||
interface ISingletonTestData<T> {
|
||||
interface SingletonTestData<T> {
|
||||
readonly getter: () => T;
|
||||
readonly expectedType?: Constructible<T>;
|
||||
}
|
||||
|
||||
export function itIsSingleton<T>(test: ISingletonTestData<T>): void {
|
||||
export function itIsSingletonFactory<T>(test: SingletonTestData<T>): void {
|
||||
if (test.expectedType !== undefined) {
|
||||
it('gets the expected type', () => {
|
||||
// act
|
||||
24
tests/unit/shared/TestCases/TransientFactoryTests.ts
Normal file
24
tests/unit/shared/TestCases/TransientFactoryTests.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Constructible } from '@/TypeHelpers';
|
||||
|
||||
interface TransientFactoryTestData<T> {
|
||||
readonly getter: () => T;
|
||||
readonly expectedType?: Constructible<T>;
|
||||
}
|
||||
|
||||
export function itIsTransientFactory<T>(test: TransientFactoryTestData<T>): void {
|
||||
if (test.expectedType !== undefined) {
|
||||
it('gets the expected type', () => {
|
||||
// act
|
||||
const instance = test.getter();
|
||||
// assert
|
||||
expect(instance).to.be.instanceOf(test.expectedType);
|
||||
});
|
||||
}
|
||||
it('multiple calls get different instances', () => {
|
||||
// act
|
||||
const instance1 = test.getter();
|
||||
const instance2 = test.getter();
|
||||
// assert
|
||||
expect(instance1).to.not.equal(instance2);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user