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:
undergroundwires
2024-05-25 13:55:30 +02:00
parent 7794846185
commit 4212c7b9e0
78 changed files with 3346 additions and 1268 deletions

View File

@@ -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;
}

View 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),
};
}

View File

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

View 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}`);

View 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');
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -10,7 +10,7 @@ export class FunctionParameterStub implements IFunctionParameter {
return this;
}
public withOptionality(isOptional: boolean) {
public withOptional(isOptional: boolean) {
this.isOptional = isOptional;
return this;
}

View File

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

View 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}`;
}
}

View 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;
}

View File

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

View 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),
};
}

View 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;
}
}

View File

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

View File

@@ -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

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