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

@@ -229,7 +229,11 @@ class ExpressionBuilder {
}
public build() {
return new Expression(this.position, this.evaluator, this.parameters);
return new Expression({
position: this.position,
evaluator: this.evaluator,
parameters: this.parameters,
});
}
private evaluator: ExpressionEvaluator = () => `[${ExpressionBuilder.name}] evaluated-expression`;

View File

@@ -1,22 +1,21 @@
import { describe, it, expect } from 'vitest';
import { createPositionFromRegexFullMatch } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { itIsTransientFactory } from '@tests/unit/shared/TestCases/TransientFactoryTests';
describe('ExpressionPositionFactory', () => {
describe('createPositionFromRegexFullMatch', () => {
it(`creates ${ExpressionPosition.name} instance`, () => {
describe('it is a transient factory', () => {
// arrange
const expectedType = ExpressionPosition;
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: 5,
});
const fakeMatch = createRegexMatch();
// act
const position = createPositionFromRegexFullMatch(fakeMatch);
const create = () => createPositionFromRegexFullMatch(fakeMatch);
// assert
expect(position).to.be.instanceOf(expectedType);
itIsTransientFactory({
getter: create,
expectedType: ExpressionPosition,
});
});
it('creates a position with the correct start position', () => {
// arrange
const expectedStartPosition = 5;
@@ -63,10 +62,8 @@ describe('ExpressionPositionFactory', () => {
describe('invalid values', () => {
it('throws an error if match.index is undefined', () => {
// arrange
const fakeMatch = createRegexMatch({
fullMatch: 'matched string',
matchIndex: undefined,
});
const fakeMatch = createRegexMatch();
fakeMatch.index = undefined;
const expectedError = `Regex match did not yield any results: ${JSON.stringify(fakeMatch)}`;
// act
const act = () => createPositionFromRegexFullMatch(fakeMatch);
@@ -94,9 +91,9 @@ function createRegexMatch(options?: {
readonly capturingGroups?: readonly string[],
readonly matchIndex?: number,
}): RegExpMatchArray {
const fullMatch = options?.fullMatch ?? 'fake match';
const fullMatch = options?.fullMatch ?? 'default fake match';
const capturingGroups = options?.capturingGroups ?? [];
const fakeMatch: RegExpMatchArray = [fullMatch, ...capturingGroups];
fakeMatch.index = options?.matchIndex;
fakeMatch.index = options?.matchIndex ?? 0;
return fakeMatch;
}

View File

@@ -1,168 +1,438 @@
import { describe, it, expect } from 'vitest';
import type { ExpressionEvaluator } from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
import { type IPrimitiveExpression, RegexParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
import type {
ExpressionEvaluator, ExpressionInitParameters,
} from '@/application/Parser/Script/Compiler/Expressions/Expression/Expression';
import {
type PrimitiveExpression, RegexParser, type ExpressionFactory, type RegexParserUtilities,
} from '@/application/Parser/Script/Compiler/Expressions/Parser/Regex/RegexParser';
import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { collectExceptionMessage } from '@tests/unit/shared/ExceptionCollector';
import { itThrowsContextualError } from '@tests/unit/application/Parser/ContextualErrorTester';
import { ExpressionStub } from '@tests/unit/shared/Stubs/ExpressionStub';
import { FunctionParameterCollectionStub } from '@tests/unit/shared/Stubs/FunctionParameterCollectionStub';
import type { IExpression } from '@/application/Parser/Script/Compiler/Expressions/Expression/IExpression';
import { FunctionParameterStub } from '@tests/unit/shared/Stubs/FunctionParameterStub';
import type { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection';
import type { ExpressionPositionFactory } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPositionFactory';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { indentText } from '@tests/shared/Text';
import type { FunctionParameterCollectionFactory } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollectionFactory';
describe('RegexParser', () => {
describe('findExpressions', () => {
describe('throws when code is absent', () => {
itEachAbsentStringValue((absentValue) => {
describe('error handling', () => {
describe('throws when code is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expectedError = 'missing code';
const sut = new RegexParserConcrete({
regex: /unimportant/,
});
// act
const act = () => sut.findExpressions(absentValue);
// assert
const errorMessage = collectExceptionMessage(act);
expect(errorMessage).to.include(expectedError);
}, { excludeNull: true, excludeUndefined: true });
});
describe('rethrows regex match errors', () => {
// arrange
const expectedError = 'missing code';
const sut = new RegexParserConcrete(/unimportant/);
// act
const act = () => sut.findExpressions(absentValue);
// assert
expect(act).to.throw(expectedError);
}, { excludeNull: true, excludeUndefined: true });
const expectedMatchError = new TypeError('String.prototype.matchAll called with a non-global RegExp argument');
const expectedMessage = 'Failed to match regex.';
const expectedCodeInMessage = 'unimportant code content';
const expectedRegexInMessage = /failing-regex-because-it-is-non-global/;
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedContextMessage: expectedErrorMessage,
expectedWrappedError: expectedMatchError,
});
});
describe('rethrows expression building errors', () => {
// arrange
const expectedMessage = 'Failed to build expression.';
const expectedInnerError = new Error('Expected error from building expression');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionBuilder = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
builder: throwingExpressionBuilder,
utilities: {
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows position creation errors', () => {
// arrange
const expectedMessage = 'Failed to create position.';
const expectedInnerError = new Error('Expected error from position factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingPositionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createPosition: throwingPositionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows parameter creation errors', () => {
// arrange
const expectedMessage = 'Failed to create parameters.';
const expectedInnerError = new Error('Expected error from parameter collection factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingParameterCollectionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createParameterCollection: throwingParameterCollectionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
describe('rethrows expression creation errors', () => {
// arrange
const expectedMessage = 'Failed to create expression.';
const expectedInnerError = new Error('Expected error from expression factory');
const {
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
} = createCodeAndRegexMatchingOnce();
const throwingExpressionFactory = () => {
throw expectedInnerError;
};
const expectedErrorMessage = buildRethrowErrorMessage({
message: expectedMessage,
code: expectedCodeInMessage,
regex: expectedRegexInMessage,
});
itThrowsContextualError({
// act
throwingAction: (wrapError) => {
const sut = new RegexParserConcrete(
{
regex: expectedRegexInMessage,
utilities: {
createExpression: throwingExpressionFactory,
wrapError,
},
},
);
sut.findExpressions(expectedCodeInMessage);
},
// assert
expectedWrappedError: expectedInnerError,
expectedContextMessage: expectedErrorMessage,
});
});
});
it('throws when position is invalid', () => {
describe('handles matched regex correctly', () => {
// arrange
const regexMatchingEmpty = /^/gm; /* expressions cannot be empty */
const code = 'unimportant';
const expectedErrorParts = [
`[${RegexParserConcrete.constructor.name}]`,
'invalid script position',
`Regex: ${regexMatchingEmpty}`,
`Code: ${code}`,
];
const sut = new RegexParserConcrete(regexMatchingEmpty);
// act
let errorMessage: string | undefined;
try {
sut.findExpressions(code);
} catch (err) {
errorMessage = err.message;
}
// assert
expectExists(errorMessage);
const error = errorMessage; // workaround for ts(18048): possibly 'undefined'
expect(
expectedErrorParts.every((part) => error.includes(part)),
`Expected parts: ${expectedErrorParts.join(', ')}`
+ `Actual error: ${errorMessage}`,
);
});
describe('matches regex as expected', () => {
// arrange
const testCases = [
const testScenarios: readonly {
readonly description: string;
readonly regex: RegExp;
readonly code: string;
}[] = [
{
name: 'returns no result when regex does not match',
description: 'non-matching regex',
regex: /hello/g,
code: 'world',
},
{
name: 'returns expected when regex matches single',
description: 'single regex match',
regex: /hello/g,
code: 'hello world',
},
{
name: 'returns expected when regex matches multiple',
description: 'multiple regex matches',
regex: /l/g,
code: 'hello world',
},
];
for (const testCase of testCases) {
it(testCase.name, () => {
const expected = Array.from(testCase.code.matchAll(testCase.regex));
const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): IPrimitiveExpression => {
matches.push(m);
return mockPrimitiveExpression();
};
const sut = new RegexParserConcrete(testCase.regex, builder);
// act
const expressions = sut.findExpressions(testCase.code);
// assert
expect(expressions).to.have.lengthOf(matches.length);
expect(matches).to.deep.equal(expected);
testScenarios.forEach(({
description, code, regex,
}) => {
describe(description, () => {
it('generates expressions for all matches', () => {
// arrange
const expectedTotalExpressions = Array.from(code.matchAll(regex)).length;
const sut = new RegexParserConcrete({
regex,
});
// act
const expressions = sut.findExpressions(code);
// assert
const actualTotalExpressions = expressions.length;
expect(actualTotalExpressions).to.equal(
expectedTotalExpressions,
formatAssertionMessage([
`Expected ${actualTotalExpressions} expressions due to ${expectedTotalExpressions} matches`,
`Expressions:\n${indentText(JSON.stringify(expressions, undefined, 2))}`,
]),
);
});
it('builds primitive expressions for each match', () => {
const expected = Array.from(code.matchAll(regex));
const matches = new Array<RegExpMatchArray>();
const builder = (m: RegExpMatchArray): PrimitiveExpression => {
matches.push(m);
return createPrimitiveExpressionStub();
};
const sut = new RegexParserConcrete({
regex,
builder,
});
// act
sut.findExpressions(code);
// assert
expect(matches).to.deep.equal(expected);
});
it('sets positions correctly from matches', () => {
// arrange
const expectedMatches = [...code.matchAll(regex)];
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const serializeRegexMatch = (match: RegExpMatchArray) => `[startPos:${match?.index ?? 'none'},length:${match?.[0]?.length ?? 'none'}]`;
const positionsForMatches = new Map<string, ExpressionPosition>(expectedMatches.map(
(expectedMatch) => [serializeRegexMatch(expectedMatch), new ExpressionPosition(1, 4)],
));
const createPositionMock: ExpressionPositionFactory = (match) => {
const position = positionsForMatches.get(serializeRegexMatch(match));
return position ?? new ExpressionPosition(66, 666);
};
const sut = new RegexParserConcrete({
regex,
utilities: {
createExpression,
createPosition: createPositionMock,
},
});
// act
const expressions = sut.findExpressions(code);
// assert
const expectedPositions = [...positionsForMatches.values()];
const actualPositions = expressions.map((e) => getInitParameters(e)?.position);
expect(actualPositions).to.deep.equal(expectedPositions, formatAssertionMessage([
'Actual positions do not match the expected positions.',
`Expected total positions: ${expectedPositions.length} (due to ${expectedMatches.length} regex matches)`,
`Actual total positions: ${actualPositions.length}`,
`Expected positions:\n${indentText(JSON.stringify(expectedPositions, undefined, 2))}`,
`Actual positions:\n${indentText(JSON.stringify(actualPositions, undefined, 2))}`,
]));
});
});
}
});
it('sets evaluator as expected', () => {
// arrange
const expected = getEvaluatorStub();
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: expected,
});
const sut = new RegexParserConcrete(regex, builder);
});
it('sets evaluator correctly from expression', () => {
// arrange
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const expectedEvaluate = createEvaluatorStub();
const { code, regex } = createCodeAndRegexMatchingOnce();
const builder = (): PrimitiveExpression => ({
evaluator: expectedEvaluate,
});
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
},
});
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].evaluate === expected);
const actualEvaluate = getInitParameters(expressions[0])?.evaluator;
expect(actualEvaluate).to.equal(expectedEvaluate);
});
it('sets parameters as expected', () => {
it('sets parameters correctly from expression', () => {
// arrange
const expected = [
new FunctionParameterStub().withName('parameter1').withOptionality(true),
new FunctionParameterStub().withName('parameter2').withOptionality(false),
const expectedParameters: IReadOnlyFunctionParameterCollection['all'] = [
new FunctionParameterStub().withName('parameter1').withOptional(true),
new FunctionParameterStub().withName('parameter2').withOptional(false),
];
const regex = /hello/g;
const code = 'hello';
const builder = (): IPrimitiveExpression => ({
evaluator: getEvaluatorStub(),
parameters: expected,
const builder = (): PrimitiveExpression => ({
evaluator: createEvaluatorStub(),
parameters: expectedParameters,
});
const parameterCollection = new FunctionParameterCollectionStub();
const parameterCollectionFactoryStub
: FunctionParameterCollectionFactory = () => parameterCollection;
const { createExpression, getInitParameters } = createExpressionFactorySpy();
const sut = new RegexParserConcrete({
regex,
builder,
utilities: {
createExpression,
createParameterCollection: parameterCollectionFactoryStub,
},
});
const sut = new RegexParserConcrete(regex, builder);
// act
const expressions = sut.findExpressions(code);
// assert
expect(expressions).to.have.lengthOf(1);
expect(expressions[0].parameters.all).to.deep.equal(expected);
});
it('sets expected position', () => {
// arrange
const code = 'mate date in state is fate';
const regex = /ate/g;
const expected = [
new ExpressionPosition(1, 4),
new ExpressionPosition(6, 9),
new ExpressionPosition(15, 18),
new ExpressionPosition(23, 26),
];
const sut = new RegexParserConcrete(regex);
// act
const expressions = sut.findExpressions(code);
// assert
const actual = expressions.map((e) => e.position);
expect(actual).to.deep.equal(expected);
const actualParameters = getInitParameters(expressions[0])?.parameters;
expect(actualParameters).to.equal(parameterCollection);
expect(actualParameters?.all).to.deep.equal(expectedParameters);
});
});
});
function mockBuilder(): (match: RegExpMatchArray) => IPrimitiveExpression {
return () => ({
evaluator: getEvaluatorStub(),
});
}
function getEvaluatorStub(): ExpressionEvaluator {
return () => `[${getEvaluatorStub.name}] evaluated code`;
function buildRethrowErrorMessage(
expectedContext: {
readonly message: string;
readonly regex: RegExp;
readonly code: string;
},
): string {
return [
expectedContext.message,
`Class name: ${RegexParserConcrete.name}`,
`Regex pattern used: ${expectedContext.regex}`,
`Code: ${expectedContext.code}`,
].join('\n');
}
function mockPrimitiveExpression(): IPrimitiveExpression {
function createExpressionFactorySpy() {
const createdExpressions = new Map<IExpression, ExpressionInitParameters>();
const createExpression: ExpressionFactory = (parameters) => {
const expression = new ExpressionStub();
createdExpressions.set(expression, parameters);
return expression;
};
return {
evaluator: getEvaluatorStub(),
createExpression,
getInitParameters: (expression) => createdExpressions.get(expression),
};
}
function createBuilderStub(): (match: RegExpMatchArray) => PrimitiveExpression {
return () => ({
evaluator: createEvaluatorStub(),
});
}
function createEvaluatorStub(): ExpressionEvaluator {
return () => `[${createEvaluatorStub.name}] evaluated code`;
}
function createPrimitiveExpressionStub(): PrimitiveExpression {
return {
evaluator: createEvaluatorStub(),
};
}
function createCodeAndRegexMatchingOnce() {
const code = 'expected code in context';
const regex = /code/g;
return { code, regex };
}
class RegexParserConcrete extends RegexParser {
private readonly builder: RegexParser['buildExpression'];
protected regex: RegExp;
public constructor(
regex: RegExp,
private readonly builder = mockBuilder(),
) {
super();
this.regex = regex;
public constructor(parameters?: {
regex?: RegExp,
builder?: RegexParser['buildExpression'],
utilities?: Partial<RegexParserUtilities>,
}) {
super({
wrapError: parameters?.utilities?.wrapError
?? (() => new Error(`[${RegexParserConcrete}] wrapped error`)),
createPosition: parameters?.utilities?.createPosition
?? (() => new ExpressionPosition(0, 5)),
createExpression: parameters?.utilities?.createExpression
?? (() => new ExpressionStub()),
createParameterCollection: parameters?.utilities?.createParameterCollection
?? (() => new FunctionParameterCollectionStub()),
});
this.builder = parameters?.builder ?? createBuilderStub();
this.regex = parameters?.regex ?? /unimportant/g;
}
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
protected buildExpression(match: RegExpMatchArray): PrimitiveExpression {
return this.builder(match);
}
}