feat(error/error-hierarchy): implement typed error class hierarchy

- Add typed fields to all error subclasses (taskId, cycles, field, message, prerequisite, dependent)
- Set Object.setPrototypeOf(this, new.target.prototype) in all constructors
- Add InvalidInputError.fromTypeBoxError() static factory for TypeBox Value.Errors() output
- CircularDependencyError accepts string[][] for cycle paths
- 31 unit tests covering instanceof chain, field access, .name property, and error messages
This commit is contained in:
2026-04-27 10:00:40 +00:00
parent bd8a7b06d0
commit ce68271f4f
3 changed files with 316 additions and 17 deletions

253
test/error.test.ts Normal file
View File

@@ -0,0 +1,253 @@
import { describe, it, expect } from 'vitest';
import {
TaskgraphError,
TaskNotFoundError,
CircularDependencyError,
InvalidInputError,
DuplicateNodeError,
DuplicateEdgeError,
} from '../src/error/index.js';
describe('TaskgraphError', () => {
it('is an instance of Error', () => {
const err = new TaskgraphError('base error');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TaskgraphError);
});
it('sets name to TaskgraphError', () => {
const err = new TaskgraphError('base error');
expect(err.name).toBe('TaskgraphError');
});
it('preserves the message', () => {
const err = new TaskgraphError('something went wrong');
expect(err.message).toBe('something went wrong');
});
});
describe('TaskNotFoundError', () => {
it('is an instance of Error, TaskgraphError, and TaskNotFoundError', () => {
const err = new TaskNotFoundError('task-1');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TaskgraphError);
expect(err).toBeInstanceOf(TaskNotFoundError);
});
it('sets name to TaskNotFoundError', () => {
const err = new TaskNotFoundError('task-1');
expect(err.name).toBe('TaskNotFoundError');
});
it('exposes taskId field', () => {
const err = new TaskNotFoundError('task-42');
expect(err.taskId).toBe('task-42');
});
it('includes taskId in the message', () => {
const err = new TaskNotFoundError('task-42');
expect(err.message).toContain('task-42');
});
});
describe('CircularDependencyError', () => {
it('is an instance of Error, TaskgraphError, and CircularDependencyError', () => {
const err = new CircularDependencyError([['a', 'b', 'a']]);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TaskgraphError);
expect(err).toBeInstanceOf(CircularDependencyError);
});
it('sets name to CircularDependencyError', () => {
const err = new CircularDependencyError([['a', 'b', 'a']]);
expect(err.name).toBe('CircularDependencyError');
});
it('exposes cycles field as string[][]', () => {
const cycles = [
['a', 'b', 'c', 'a'],
['x', 'y', 'x'],
];
const err = new CircularDependencyError(cycles);
expect(err.cycles).toEqual(cycles);
expect(err.cycles).toHaveLength(2);
expect(err.cycles[0]).toEqual(['a', 'b', 'c', 'a']);
expect(err.cycles[1]).toEqual(['x', 'y', 'x']);
});
it('includes cycle path descriptions in the message', () => {
const err = new CircularDependencyError([['a', 'b', 'c']]);
expect(err.message).toContain('a → b → c');
});
it('handles multiple cycles in message', () => {
const err = new CircularDependencyError([
['a', 'b', 'a'],
['x', 'y', 'x'],
]);
expect(err.message).toContain('a → b → a');
expect(err.message).toContain('x → y → x');
});
});
describe('InvalidInputError', () => {
it('is an instance of Error, TaskgraphError, and InvalidInputError', () => {
const err = new InvalidInputError('name', 'is required');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TaskgraphError);
expect(err).toBeInstanceOf(InvalidInputError);
});
it('sets name to InvalidInputError', () => {
const err = new InvalidInputError('name', 'is required');
expect(err.name).toBe('InvalidInputError');
});
it('exposes field and message fields', () => {
const err = new InvalidInputError('dependsOn', 'must be an array');
expect(err.field).toBe('dependsOn');
expect(err.message).toBe('must be an array');
});
it('exposes field and message as separate properties', () => {
const err = new InvalidInputError('name', 'is required');
// message field on InvalidInputError shadows Error.message and holds the
// validation-specific message (per architecture spec: field + message)
expect(err.field).toBe('name');
expect(err.message).toBe('is required');
});
it('creates from TypeBox Value.Errors() output via static method', () => {
const typeboxError = {
path: '/dependsOn',
message: 'Expected array, received string',
};
const err = InvalidInputError.fromTypeBoxError(typeboxError);
expect(err).toBeInstanceOf(InvalidInputError);
expect(err.field).toBe('dependsOn');
expect(err.message).toBe('Expected array, received string');
});
it('strips leading slash from TypeBox path in fromTypeBoxError', () => {
const typeboxError = {
path: '/risk',
message: 'Invalid enum value',
};
const err = InvalidInputError.fromTypeBoxError(typeboxError);
expect(err.field).toBe('risk');
});
it('handles nested path in fromTypeBoxError', () => {
const typeboxError = {
path: '/attributes/risk',
message: 'Invalid enum value',
};
const err = InvalidInputError.fromTypeBoxError(typeboxError);
expect(err.field).toBe('attributes/risk');
});
it('handles path without leading slash in fromTypeBoxError', () => {
const typeboxError = {
path: 'name',
message: 'Required',
};
const err = InvalidInputError.fromTypeBoxError(typeboxError);
expect(err.field).toBe('name');
});
});
describe('DuplicateNodeError', () => {
it('is an instance of Error, TaskgraphError, and DuplicateNodeError', () => {
const err = new DuplicateNodeError('task-1');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TaskgraphError);
expect(err).toBeInstanceOf(DuplicateNodeError);
});
it('sets name to DuplicateNodeError', () => {
const err = new DuplicateNodeError('task-1');
expect(err.name).toBe('DuplicateNodeError');
});
it('exposes taskId field', () => {
const err = new DuplicateNodeError('task-99');
expect(err.taskId).toBe('task-99');
});
it('includes taskId in the message', () => {
const err = new DuplicateNodeError('task-99');
expect(err.message).toContain('task-99');
});
});
describe('DuplicateEdgeError', () => {
it('is an instance of Error, TaskgraphError, and DuplicateEdgeError', () => {
const err = new DuplicateEdgeError('prereq', 'dep');
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TaskgraphError);
expect(err).toBeInstanceOf(DuplicateEdgeError);
});
it('sets name to DuplicateEdgeError', () => {
const err = new DuplicateEdgeError('prereq', 'dep');
expect(err.name).toBe('DuplicateEdgeError');
});
it('exposes prerequisite and dependent fields', () => {
const err = new DuplicateEdgeError('prereq-task', 'dep-task');
expect(err.prerequisite).toBe('prereq-task');
expect(err.dependent).toBe('dep-task');
});
it('includes prerequisite and dependent in the message', () => {
const err = new DuplicateEdgeError('prereq-task', 'dep-task');
expect(err.message).toContain('prereq-task');
expect(err.message).toContain('dep-task');
});
});
describe('prototype chain', () => {
it('all subclasses are properly linked in the prototype chain', () => {
const errors = [
new TaskgraphError('base'),
new TaskNotFoundError('id'),
new CircularDependencyError([['a', 'b']]),
new InvalidInputError('field', 'msg'),
new DuplicateNodeError('id'),
new DuplicateEdgeError('src', 'tgt'),
];
for (const err of errors) {
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(TaskgraphError);
// Verify Object.setPrototypeOf worked correctly
expect(Object.getPrototypeOf(err)).not.toBe(Error.prototype);
}
});
it('catch clause works correctly for subclasses', () => {
const throwTaskNotFound = (): never => {
throw new TaskNotFoundError('missing');
};
expect(() => {
try {
throwTaskNotFound();
} catch (e) {
// Should be catchable as TaskgraphError
if (e instanceof TaskgraphError) {
throw e;
}
throw new Error('unexpected');
}
}).toThrow(TaskNotFoundError);
});
it('field access works after catching as TaskgraphError', () => {
const err: TaskgraphError = new TaskNotFoundError('task-1');
// After narrowing via instanceof, fields should be accessible
if (err instanceof TaskNotFoundError) {
expect(err.taskId).toBe('task-1');
}
});
});