BREAKING CHANGE(taskbuffer): Change default Task error handling: trigger() now rejects when taskFunction throws; add catchErrors option (default false) to preserve previous swallow behavior; track errors (lastError, errorCount) and expose them in metadata; improve error propagation and logging across runners, chains, parallels and debounced tasks; add tests and documentation for new behavior.
This commit is contained in:
363
test/test.11.errorhandling.ts
Normal file
363
test/test.11.errorhandling.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as taskbuffer from '../ts/index.js';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
|
||||
// Test 1: Default task rejects on error (catchErrors: false)
|
||||
tap.test('should reject when taskFunction throws (default catchErrors: false)', async () => {
|
||||
const failingTask = new taskbuffer.Task({
|
||||
name: 'failing-task-default',
|
||||
taskFunction: async () => {
|
||||
throw new Error('intentional failure');
|
||||
},
|
||||
});
|
||||
|
||||
let didReject = false;
|
||||
try {
|
||||
await failingTask.trigger();
|
||||
} catch (err) {
|
||||
didReject = true;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toEqual('intentional failure');
|
||||
}
|
||||
expect(didReject).toBeTrue();
|
||||
});
|
||||
|
||||
// Test 2: Task with catchErrors: true resolves on error
|
||||
tap.test('should resolve with undefined when catchErrors is true', async () => {
|
||||
const failingTask = new taskbuffer.Task({
|
||||
name: 'failing-task-catch',
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
throw new Error('swallowed failure');
|
||||
},
|
||||
});
|
||||
|
||||
const result = await failingTask.trigger();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// Test 3: lastError and errorCount are set after failure
|
||||
tap.test('should set lastError and errorCount after failure', async () => {
|
||||
const failingTask = new taskbuffer.Task({
|
||||
name: 'failing-task-state',
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
throw new Error('tracked failure');
|
||||
},
|
||||
});
|
||||
|
||||
await failingTask.trigger();
|
||||
expect(failingTask.lastError).toBeInstanceOf(Error);
|
||||
expect(failingTask.lastError.message).toEqual('tracked failure');
|
||||
expect(failingTask.errorCount).toEqual(1);
|
||||
|
||||
// Run again to verify errorCount increments
|
||||
await failingTask.trigger();
|
||||
expect(failingTask.errorCount).toEqual(2);
|
||||
});
|
||||
|
||||
// Test 4: Error state resets on successful re-run
|
||||
tap.test('should reset lastError on successful re-run', async () => {
|
||||
let shouldFail = true;
|
||||
const task = new taskbuffer.Task({
|
||||
name: 'intermittent-task',
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
if (shouldFail) {
|
||||
throw new Error('first run fails');
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
});
|
||||
|
||||
// First run: fail
|
||||
await task.trigger();
|
||||
expect(task.lastError).toBeInstanceOf(Error);
|
||||
expect(task.errorCount).toEqual(1);
|
||||
|
||||
// Second run: succeed
|
||||
shouldFail = false;
|
||||
const result = await task.trigger();
|
||||
expect(result).toEqual('success');
|
||||
expect(task.lastError).toBeUndefined();
|
||||
// errorCount should still be 1 since the second run succeeded
|
||||
expect(task.errorCount).toEqual(1);
|
||||
});
|
||||
|
||||
// Test 5: Taskchain rejects when a child task throws
|
||||
tap.test('should reject Taskchain when a child task throws', async () => {
|
||||
const goodTask = new taskbuffer.Task({
|
||||
name: 'good-chain-task',
|
||||
taskFunction: async () => 'ok',
|
||||
});
|
||||
const badTask = new taskbuffer.Task({
|
||||
name: 'bad-chain-task',
|
||||
taskFunction: async () => {
|
||||
throw new Error('chain failure');
|
||||
},
|
||||
});
|
||||
|
||||
const chain = new taskbuffer.Taskchain({
|
||||
name: 'test-chain',
|
||||
taskArray: [goodTask, badTask],
|
||||
});
|
||||
|
||||
let didReject = false;
|
||||
try {
|
||||
await chain.trigger();
|
||||
} catch (err) {
|
||||
didReject = true;
|
||||
}
|
||||
expect(didReject).toBeTrue();
|
||||
});
|
||||
|
||||
// Test 6: Taskparallel rejects when a child task throws
|
||||
tap.test('should reject Taskparallel when a child task throws', async () => {
|
||||
const goodTask = new taskbuffer.Task({
|
||||
name: 'good-parallel-task',
|
||||
taskFunction: async () => 'ok',
|
||||
});
|
||||
const badTask = new taskbuffer.Task({
|
||||
name: 'bad-parallel-task',
|
||||
taskFunction: async () => {
|
||||
throw new Error('parallel failure');
|
||||
},
|
||||
});
|
||||
|
||||
const parallel = new taskbuffer.Taskparallel({
|
||||
taskArray: [goodTask, badTask],
|
||||
});
|
||||
|
||||
let didReject = false;
|
||||
try {
|
||||
await parallel.trigger();
|
||||
} catch (err) {
|
||||
didReject = true;
|
||||
}
|
||||
expect(didReject).toBeTrue();
|
||||
});
|
||||
|
||||
// Test 7: TaskRunner continues processing after a task error
|
||||
tap.test('should continue TaskRunner queue after a task error', async () => {
|
||||
const runner = new taskbuffer.TaskRunner();
|
||||
const executionOrder: string[] = [];
|
||||
|
||||
const badTask = new taskbuffer.Task({
|
||||
name: 'runner-bad-task',
|
||||
taskFunction: async () => {
|
||||
executionOrder.push('bad');
|
||||
throw new Error('runner task failure');
|
||||
},
|
||||
});
|
||||
const goodTask = new taskbuffer.Task({
|
||||
name: 'runner-good-task',
|
||||
taskFunction: async () => {
|
||||
executionOrder.push('good');
|
||||
},
|
||||
});
|
||||
|
||||
await runner.start();
|
||||
runner.addTask(badTask);
|
||||
runner.addTask(goodTask);
|
||||
|
||||
// Wait for both tasks to be processed
|
||||
await smartdelay.delayFor(500);
|
||||
await runner.stop();
|
||||
|
||||
expect(executionOrder).toContain('bad');
|
||||
expect(executionOrder).toContain('good');
|
||||
});
|
||||
|
||||
// Test 8: BufferRunner handles errors without hanging
|
||||
tap.test('should handle BufferRunner errors without hanging', async () => {
|
||||
let callCount = 0;
|
||||
const bufferedTask = new taskbuffer.Task({
|
||||
name: 'buffer-error-task',
|
||||
buffered: true,
|
||||
bufferMax: 3,
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
callCount++;
|
||||
throw new Error('buffer failure');
|
||||
},
|
||||
});
|
||||
|
||||
await bufferedTask.trigger();
|
||||
// The task should have executed and not hung
|
||||
expect(callCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Test 9: clearError() resets error state
|
||||
tap.test('should reset error state with clearError()', async () => {
|
||||
const task = new taskbuffer.Task({
|
||||
name: 'clearable-task',
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
throw new Error('to be cleared');
|
||||
},
|
||||
});
|
||||
|
||||
await task.trigger();
|
||||
expect(task.lastError).toBeInstanceOf(Error);
|
||||
|
||||
task.clearError();
|
||||
expect(task.lastError).toBeUndefined();
|
||||
// errorCount should remain unchanged
|
||||
expect(task.errorCount).toEqual(1);
|
||||
});
|
||||
|
||||
// Test 10: getMetadata() reflects error state
|
||||
tap.test('should reflect error state in getMetadata()', async () => {
|
||||
const task = new taskbuffer.Task({
|
||||
name: 'metadata-error-task',
|
||||
catchErrors: true,
|
||||
taskFunction: async () => {
|
||||
throw new Error('metadata failure');
|
||||
},
|
||||
});
|
||||
|
||||
// Before any run
|
||||
let metadata = task.getMetadata();
|
||||
expect(metadata.status).toEqual('idle');
|
||||
expect(metadata.errorCount).toEqual(0);
|
||||
|
||||
// After failing
|
||||
await task.trigger();
|
||||
metadata = task.getMetadata();
|
||||
expect(metadata.status).toEqual('failed');
|
||||
expect(metadata.lastError).toEqual('metadata failure');
|
||||
expect(metadata.errorCount).toEqual(1);
|
||||
});
|
||||
|
||||
// Test 11: TaskChain error includes task name and index context
|
||||
tap.test('should include task name and index in TaskChain error message', async () => {
|
||||
const goodTask = new taskbuffer.Task({
|
||||
name: 'chain-step-ok',
|
||||
taskFunction: async () => 'ok',
|
||||
});
|
||||
const badTask = new taskbuffer.Task({
|
||||
name: 'chain-step-fail',
|
||||
taskFunction: async () => {
|
||||
throw new Error('step exploded');
|
||||
},
|
||||
});
|
||||
|
||||
const chain = new taskbuffer.Taskchain({
|
||||
name: 'context-chain',
|
||||
taskArray: [goodTask, badTask],
|
||||
});
|
||||
|
||||
let caughtError: Error | null = null;
|
||||
try {
|
||||
await chain.trigger();
|
||||
} catch (err) {
|
||||
caughtError = err as Error;
|
||||
}
|
||||
expect(caughtError).toBeInstanceOf(Error);
|
||||
expect(caughtError.message).toInclude('context-chain');
|
||||
expect(caughtError.message).toInclude('chain-step-fail');
|
||||
expect(caughtError.message).toInclude('index 1');
|
||||
expect((caughtError as any).cause).toBeInstanceOf(Error);
|
||||
expect(((caughtError as any).cause as Error).message).toEqual('step exploded');
|
||||
});
|
||||
|
||||
// Test 12: BufferRunner error propagation with catchErrors: false
|
||||
tap.test('should reject buffered task when catchErrors is false', async () => {
|
||||
const bufferedTask = new taskbuffer.Task({
|
||||
name: 'buffer-reject-task',
|
||||
buffered: true,
|
||||
bufferMax: 3,
|
||||
catchErrors: false,
|
||||
taskFunction: async () => {
|
||||
throw new Error('buffer reject failure');
|
||||
},
|
||||
});
|
||||
|
||||
let didReject = false;
|
||||
try {
|
||||
await bufferedTask.trigger();
|
||||
} catch (err) {
|
||||
didReject = true;
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect((err as Error).message).toEqual('buffer reject failure');
|
||||
}
|
||||
expect(didReject).toBeTrue();
|
||||
});
|
||||
|
||||
// Test 13: Taskchain removeTask removes and returns true
|
||||
tap.test('should remove a task from Taskchain', async () => {
|
||||
const task1 = new taskbuffer.Task({
|
||||
name: 'removable-1',
|
||||
taskFunction: async () => 'a',
|
||||
});
|
||||
const task2 = new taskbuffer.Task({
|
||||
name: 'removable-2',
|
||||
taskFunction: async () => 'b',
|
||||
});
|
||||
|
||||
const chain = new taskbuffer.Taskchain({
|
||||
name: 'remove-chain',
|
||||
taskArray: [task1, task2],
|
||||
});
|
||||
|
||||
const removed = chain.removeTask(task1);
|
||||
expect(removed).toBeTrue();
|
||||
expect(chain.taskArray.length).toEqual(1);
|
||||
expect(chain.taskArray[0] === task2).toBeTrue();
|
||||
});
|
||||
|
||||
// Test 14: Taskchain removeTask returns false for unknown task
|
||||
tap.test('should return false when removing a task not in Taskchain', async () => {
|
||||
const task1 = new taskbuffer.Task({
|
||||
name: 'existing',
|
||||
taskFunction: async () => 'a',
|
||||
});
|
||||
const unknown = new taskbuffer.Task({
|
||||
name: 'unknown',
|
||||
taskFunction: async () => 'b',
|
||||
});
|
||||
|
||||
const chain = new taskbuffer.Taskchain({
|
||||
name: 'remove-false-chain',
|
||||
taskArray: [task1],
|
||||
});
|
||||
|
||||
const removed = chain.removeTask(unknown);
|
||||
expect(removed).toBeFalse();
|
||||
expect(chain.taskArray.length).toEqual(1);
|
||||
});
|
||||
|
||||
// Test 15: Taskchain shiftTask returns first task and shortens array
|
||||
tap.test('should shift the first task from Taskchain', async () => {
|
||||
const task1 = new taskbuffer.Task({
|
||||
name: 'shift-1',
|
||||
taskFunction: async () => 'a',
|
||||
});
|
||||
const task2 = new taskbuffer.Task({
|
||||
name: 'shift-2',
|
||||
taskFunction: async () => 'b',
|
||||
});
|
||||
|
||||
const chain = new taskbuffer.Taskchain({
|
||||
name: 'shift-chain',
|
||||
taskArray: [task1, task2],
|
||||
});
|
||||
|
||||
const shifted = chain.shiftTask();
|
||||
expect(shifted === task1).toBeTrue();
|
||||
expect(chain.taskArray.length).toEqual(1);
|
||||
expect(chain.taskArray[0] === task2).toBeTrue();
|
||||
});
|
||||
|
||||
// Test 16: Taskchain shiftTask returns undefined on empty array
|
||||
tap.test('should return undefined when shifting from empty Taskchain', async () => {
|
||||
const chain = new taskbuffer.Taskchain({
|
||||
name: 'empty-shift-chain',
|
||||
taskArray: [],
|
||||
});
|
||||
|
||||
const shifted = chain.shiftTask();
|
||||
expect(shifted).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -147,7 +147,7 @@ tap.test('Task should provide complete metadata', async () => {
|
||||
|
||||
// Get metadata after execution
|
||||
metadata = task.getMetadata();
|
||||
expect(metadata.status).toEqual('idle');
|
||||
expect(metadata.status).toEqual('completed');
|
||||
expect(metadata.runCount).toEqual(1);
|
||||
expect(metadata.currentProgress).toEqual(100);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user