import { expect, tap } from '@git.zone/tstest/tapbundle'; import * as taskbuffer from '../ts/index.js'; import * as smartdelay from '@push.rocks/smartdelay'; // Test 1: Task data property — typed data accessible tap.test('should have typed data property on task', async () => { const task = new taskbuffer.Task({ name: 'data-task', data: { domain: 'example.com', priority: 1 }, taskFunction: async () => {}, }); expect(task.data.domain).toEqual('example.com'); expect(task.data.priority).toEqual(1); }); // Test 2: Task data defaults to empty object tap.test('should default data to empty object when not provided', async () => { const task = new taskbuffer.Task({ name: 'no-data-task', taskFunction: async () => {}, }); expect(task.data).toBeTruthy(); expect(typeof task.data).toEqual('object'); }); // Test 3: No-constraint passthrough — behavior unchanged tap.test('should run tasks directly when no constraints are configured', async () => { const manager = new taskbuffer.TaskManager(); let executed = false; const task = new taskbuffer.Task({ name: 'passthrough-task', taskFunction: async () => { executed = true; return 'done'; }, }); manager.addTask(task); const result = await manager.triggerTaskByName('passthrough-task'); expect(executed).toBeTrue(); expect(result).toEqual('done'); await manager.stop(); }); // Test 4: Group concurrency — 3 tasks, max 2 concurrent, 3rd queues tap.test('should enforce group concurrency limit', async () => { const manager = new taskbuffer.TaskManager(); let running = 0; let maxRunning = 0; const constraint = new taskbuffer.TaskConstraintGroup<{ group: string }>({ name: 'concurrency-test', maxConcurrent: 2, constraintKeyForTask: (task) => task.data.group === 'workers' ? 'workers' : null, }); manager.addConstraintGroup(constraint); const makeTask = (id: number) => new taskbuffer.Task({ name: `worker-${id}`, data: { group: 'workers' }, taskFunction: async () => { running++; maxRunning = Math.max(maxRunning, running); await smartdelay.delayFor(200); running--; }, }); const t1 = makeTask(1); const t2 = makeTask(2); const t3 = makeTask(3); manager.addTask(t1); manager.addTask(t2); manager.addTask(t3); await Promise.all([ manager.triggerTaskConstrained(t1), manager.triggerTaskConstrained(t2), manager.triggerTaskConstrained(t3), ]); expect(maxRunning).toBeLessThanOrEqual(2); await manager.stop(); }); // Test 5: Key-based mutual exclusion — same key sequential, different keys parallel tap.test('should enforce key-based mutual exclusion', async () => { const manager = new taskbuffer.TaskManager(); const log: string[] = []; const constraint = new taskbuffer.TaskConstraintGroup<{ domain: string }>({ name: 'domain-mutex', maxConcurrent: 1, constraintKeyForTask: (task) => task.data.domain, }); manager.addConstraintGroup(constraint); const makeTask = (name: string, domain: string, delayMs: number) => new taskbuffer.Task({ name, data: { domain }, taskFunction: async () => { log.push(`${name}-start`); await smartdelay.delayFor(delayMs); log.push(`${name}-end`); }, }); const taskA1 = makeTask('a1', 'a.com', 100); const taskA2 = makeTask('a2', 'a.com', 100); const taskB1 = makeTask('b1', 'b.com', 100); manager.addTask(taskA1); manager.addTask(taskA2); manager.addTask(taskB1); await Promise.all([ manager.triggerTaskConstrained(taskA1), manager.triggerTaskConstrained(taskA2), manager.triggerTaskConstrained(taskB1), ]); // a1 and a2 should be sequential (same key) const a1EndIdx = log.indexOf('a1-end'); const a2StartIdx = log.indexOf('a2-start'); expect(a2StartIdx).toBeGreaterThanOrEqual(a1EndIdx); // b1 should start concurrently with a1 (different key) const a1StartIdx = log.indexOf('a1-start'); const b1StartIdx = log.indexOf('b1-start'); // Both should start before a1 ends expect(b1StartIdx).toBeLessThan(a1EndIdx); await manager.stop(); }); // Test 6: Cooldown enforcement tap.test('should enforce cooldown between task executions', async () => { const manager = new taskbuffer.TaskManager(); const timestamps: number[] = []; const constraint = new taskbuffer.TaskConstraintGroup<{ key: string }>({ name: 'cooldown-test', maxConcurrent: 1, cooldownMs: 300, constraintKeyForTask: (task) => task.data.key, }); manager.addConstraintGroup(constraint); const makeTask = (name: string) => new taskbuffer.Task({ name, data: { key: 'shared' }, taskFunction: async () => { timestamps.push(Date.now()); }, }); const t1 = makeTask('cool-1'); const t2 = makeTask('cool-2'); const t3 = makeTask('cool-3'); manager.addTask(t1); manager.addTask(t2); manager.addTask(t3); await Promise.all([ manager.triggerTaskConstrained(t1), manager.triggerTaskConstrained(t2), manager.triggerTaskConstrained(t3), ]); // Each execution should be at least ~300ms apart (with 200ms tolerance) for (let i = 1; i < timestamps.length; i++) { const gap = timestamps[i] - timestamps[i - 1]; expect(gap).toBeGreaterThanOrEqual(250); // 300ms cooldown minus 50ms tolerance } await manager.stop(); }); // Test 7: Multiple constraint groups on one task tap.test('should apply multiple constraint groups to one task', async () => { const manager = new taskbuffer.TaskManager(); let running = 0; let maxRunning = 0; const globalConstraint = new taskbuffer.TaskConstraintGroup({ name: 'global', maxConcurrent: 3, constraintKeyForTask: () => 'all', }); const groupConstraint = new taskbuffer.TaskConstraintGroup<{ group: string }>({ name: 'group', maxConcurrent: 1, constraintKeyForTask: (task) => task.data.group, }); manager.addConstraintGroup(globalConstraint); manager.addConstraintGroup(groupConstraint); const makeTask = (name: string, group: string) => new taskbuffer.Task({ name, data: { group }, taskFunction: async () => { running++; maxRunning = Math.max(maxRunning, running); await smartdelay.delayFor(100); running--; }, }); // Same group - should be serialized by group constraint const t1 = makeTask('multi-1', 'A'); const t2 = makeTask('multi-2', 'A'); manager.addTask(t1); manager.addTask(t2); await Promise.all([ manager.triggerTaskConstrained(t1), manager.triggerTaskConstrained(t2), ]); // With group maxConcurrent: 1, only 1 should run at a time expect(maxRunning).toBeLessThanOrEqual(1); await manager.stop(); }); // Test 8: Matcher returns null — task runs unconstrained tap.test('should run task unconstrained when matcher returns null', async () => { const manager = new taskbuffer.TaskManager(); const constraint = new taskbuffer.TaskConstraintGroup<{ skip: boolean }>({ name: 'selective', maxConcurrent: 1, constraintKeyForTask: (task) => (task.data.skip ? null : 'constrained'), }); manager.addConstraintGroup(constraint); let unconstrained = false; const task = new taskbuffer.Task({ name: 'skip-task', data: { skip: true }, taskFunction: async () => { unconstrained = true; }, }); manager.addTask(task); await manager.triggerTaskConstrained(task); expect(unconstrained).toBeTrue(); await manager.stop(); }); // Test 9: Error handling — failed task releases slot, queue drains tap.test('should release slot and drain queue when task fails', async () => { const manager = new taskbuffer.TaskManager(); const log: string[] = []; const constraint = new taskbuffer.TaskConstraintGroup<{ key: string }>({ name: 'error-drain', maxConcurrent: 1, constraintKeyForTask: (task) => task.data.key, }); manager.addConstraintGroup(constraint); const failTask = new taskbuffer.Task({ name: 'fail-task', data: { key: 'shared' }, catchErrors: true, taskFunction: async () => { log.push('fail'); throw new Error('intentional'); }, }); const successTask = new taskbuffer.Task({ name: 'success-task', data: { key: 'shared' }, taskFunction: async () => { log.push('success'); }, }); manager.addTask(failTask); manager.addTask(successTask); await Promise.all([ manager.triggerTaskConstrained(failTask), manager.triggerTaskConstrained(successTask), ]); expect(log).toContain('fail'); expect(log).toContain('success'); await manager.stop(); }); // Test 10: TaskManager integration — addConstraintGroup + triggerTaskByName tap.test('should route triggerTaskByName through constraints', async () => { const manager = new taskbuffer.TaskManager(); let running = 0; let maxRunning = 0; const constraint = new taskbuffer.TaskConstraintGroup({ name: 'manager-integration', maxConcurrent: 1, constraintKeyForTask: () => 'all', }); manager.addConstraintGroup(constraint); const t1 = new taskbuffer.Task({ name: 'managed-1', taskFunction: async () => { running++; maxRunning = Math.max(maxRunning, running); await smartdelay.delayFor(100); running--; }, }); const t2 = new taskbuffer.Task({ name: 'managed-2', taskFunction: async () => { running++; maxRunning = Math.max(maxRunning, running); await smartdelay.delayFor(100); running--; }, }); manager.addTask(t1); manager.addTask(t2); await Promise.all([ manager.triggerTaskByName('managed-1'), manager.triggerTaskByName('managed-2'), ]); expect(maxRunning).toBeLessThanOrEqual(1); await manager.stop(); }); // Test 11: removeConstraintGroup removes by name tap.test('should remove a constraint group by name', async () => { const manager = new taskbuffer.TaskManager(); const constraint = new taskbuffer.TaskConstraintGroup({ name: 'removable', maxConcurrent: 1, constraintKeyForTask: () => 'all', }); manager.addConstraintGroup(constraint); expect(manager.constraintGroups.length).toEqual(1); manager.removeConstraintGroup('removable'); expect(manager.constraintGroups.length).toEqual(0); await manager.stop(); }); // Test 12: TaskConstraintGroup reset clears state tap.test('should reset constraint group state', async () => { const constraint = new taskbuffer.TaskConstraintGroup({ name: 'resettable', maxConcurrent: 2, cooldownMs: 1000, constraintKeyForTask: () => 'key', }); // Simulate usage constraint.acquireSlot('key'); expect(constraint.getRunningCount('key')).toEqual(1); constraint.releaseSlot('key'); expect(constraint.getCooldownRemaining('key')).toBeGreaterThan(0); constraint.reset(); expect(constraint.getRunningCount('key')).toEqual(0); expect(constraint.getCooldownRemaining('key')).toEqual(0); }); // Test 13: Queued task returns correct result tap.test('should return correct result from queued tasks', async () => { const manager = new taskbuffer.TaskManager(); const constraint = new taskbuffer.TaskConstraintGroup({ name: 'return-value-test', maxConcurrent: 1, constraintKeyForTask: () => 'shared', }); manager.addConstraintGroup(constraint); const t1 = new taskbuffer.Task({ name: 'ret-1', taskFunction: async () => { await smartdelay.delayFor(100); return 'result-A'; }, }); const t2 = new taskbuffer.Task({ name: 'ret-2', taskFunction: async () => { return 'result-B'; }, }); manager.addTask(t1); manager.addTask(t2); const [r1, r2] = await Promise.all([ manager.triggerTaskConstrained(t1), manager.triggerTaskConstrained(t2), ]); expect(r1).toEqual('result-A'); expect(r2).toEqual('result-B'); await manager.stop(); }); // Test 14: Error propagation for queued tasks (catchErrors: false) tap.test('should propagate errors from queued tasks (catchErrors: false)', async () => { const manager = new taskbuffer.TaskManager(); const constraint = new taskbuffer.TaskConstraintGroup({ name: 'error-propagation', maxConcurrent: 1, constraintKeyForTask: () => 'shared', }); manager.addConstraintGroup(constraint); const t1 = new taskbuffer.Task({ name: 'err-first', taskFunction: async () => { await smartdelay.delayFor(100); return 'ok'; }, }); const t2 = new taskbuffer.Task({ name: 'err-second', catchErrors: false, taskFunction: async () => { throw new Error('queued-error'); }, }); manager.addTask(t1); manager.addTask(t2); const r1Promise = manager.triggerTaskConstrained(t1); const r2Promise = manager.triggerTaskConstrained(t2); const r1 = await r1Promise; expect(r1).toEqual('ok'); let caughtError: Error | null = null; try { await r2Promise; } catch (err) { caughtError = err as Error; } expect(caughtError).toBeTruthy(); expect(caughtError!.message).toEqual('queued-error'); await manager.stop(); }); // Test 15: triggerTask() routes through constraints tap.test('should route triggerTask() through constraints', async () => { const manager = new taskbuffer.TaskManager(); let running = 0; let maxRunning = 0; const constraint = new taskbuffer.TaskConstraintGroup({ name: 'trigger-task-test', maxConcurrent: 1, constraintKeyForTask: () => 'all', }); manager.addConstraintGroup(constraint); const makeTask = (id: number) => new taskbuffer.Task({ name: `tt-${id}`, taskFunction: async () => { running++; maxRunning = Math.max(maxRunning, running); await smartdelay.delayFor(100); running--; }, }); const t1 = makeTask(1); const t2 = makeTask(2); manager.addTask(t1); manager.addTask(t2); await Promise.all([ manager.triggerTask(t1), manager.triggerTask(t2), ]); expect(maxRunning).toBeLessThanOrEqual(1); await manager.stop(); }); // Test 16: addExecuteRemoveTask() routes through constraints tap.test('should route addExecuteRemoveTask() through constraints', async () => { const manager = new taskbuffer.TaskManager(); let running = 0; let maxRunning = 0; const constraint = new taskbuffer.TaskConstraintGroup({ name: 'add-execute-remove-test', maxConcurrent: 1, constraintKeyForTask: () => 'all', }); manager.addConstraintGroup(constraint); const makeTask = (id: number) => new taskbuffer.Task({ name: `aer-${id}`, taskFunction: async () => { running++; maxRunning = Math.max(maxRunning, running); await smartdelay.delayFor(100); running--; return `done-${id}`; }, }); const t1 = makeTask(1); const t2 = makeTask(2); const [report1, report2] = await Promise.all([ manager.addExecuteRemoveTask(t1), manager.addExecuteRemoveTask(t2), ]); expect(maxRunning).toBeLessThanOrEqual(1); expect(report1.result).toEqual('done-1'); expect(report2.result).toEqual('done-2'); await manager.stop(); }); // Test 17: FIFO ordering of queued tasks tap.test('should execute queued tasks in FIFO order', async () => { const manager = new taskbuffer.TaskManager(); const executionOrder: string[] = []; const constraint = new taskbuffer.TaskConstraintGroup({ name: 'fifo-test', maxConcurrent: 1, constraintKeyForTask: () => 'shared', }); manager.addConstraintGroup(constraint); const makeTask = (id: string) => new taskbuffer.Task({ name: `fifo-${id}`, taskFunction: async () => { executionOrder.push(id); await smartdelay.delayFor(50); }, }); const tA = makeTask('A'); const tB = makeTask('B'); const tC = makeTask('C'); manager.addTask(tA); manager.addTask(tB); manager.addTask(tC); await Promise.all([ manager.triggerTaskConstrained(tA), manager.triggerTaskConstrained(tB), manager.triggerTaskConstrained(tC), ]); expect(executionOrder).toEqual(['A', 'B', 'C']); await manager.stop(); }); // Test 18: Combined concurrency + cooldown tap.test('should enforce both concurrency and cooldown together', async () => { const manager = new taskbuffer.TaskManager(); let running = 0; let maxRunning = 0; const timestamps: number[] = []; const constraint = new taskbuffer.TaskConstraintGroup({ name: 'combined-test', maxConcurrent: 2, cooldownMs: 200, constraintKeyForTask: () => 'shared', }); manager.addConstraintGroup(constraint); const makeTask = (id: number) => new taskbuffer.Task({ name: `combo-${id}`, taskFunction: async () => { running++; maxRunning = Math.max(maxRunning, running); timestamps.push(Date.now()); await smartdelay.delayFor(100); running--; }, }); const tasks = [makeTask(1), makeTask(2), makeTask(3), makeTask(4)]; for (const t of tasks) { manager.addTask(t); } await Promise.all(tasks.map((t) => manager.triggerTaskConstrained(t))); // Concurrency never exceeded 2 expect(maxRunning).toBeLessThanOrEqual(2); // First 2 tasks start nearly together, 3rd task starts after first batch completes + cooldown // First batch completes ~100ms after start, then 200ms cooldown const gap = timestamps[2] - timestamps[0]; expect(gap).toBeGreaterThanOrEqual(250); // 100ms task + 200ms cooldown - 50ms tolerance await manager.stop(); }); // Test 19: Constraint removal unblocks queued tasks tap.test('should unblock queued tasks when constraint group is removed', async () => { const manager = new taskbuffer.TaskManager(); const log: string[] = []; const constraint = new taskbuffer.TaskConstraintGroup({ name: 'removable-constraint', maxConcurrent: 1, constraintKeyForTask: () => 'shared', }); manager.addConstraintGroup(constraint); const t1 = new taskbuffer.Task({ name: 'block-1', taskFunction: async () => { log.push('t1-start'); // Remove constraint while t1 is running so t2 runs unconstrained after drain manager.removeConstraintGroup('removable-constraint'); await smartdelay.delayFor(100); log.push('t1-end'); }, }); const t2 = new taskbuffer.Task({ name: 'block-2', taskFunction: async () => { log.push('t2-start'); log.push('t2-end'); }, }); manager.addTask(t1); manager.addTask(t2); await Promise.all([ manager.triggerTaskConstrained(t1), manager.triggerTaskConstrained(t2), ]); // Both tasks completed (drain didn't deadlock after constraint removal) expect(log).toContain('t1-start'); expect(log).toContain('t1-end'); expect(log).toContain('t2-start'); expect(log).toContain('t2-end'); // t2 started after t1 completed (drain fires after t1 finishes) const t1EndIdx = log.indexOf('t1-end'); const t2StartIdx = log.indexOf('t2-start'); expect(t2StartIdx).toBeGreaterThanOrEqual(t1EndIdx); await manager.stop(); }); export default tap.start();