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(100); // 300ms cooldown minus 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); }); export default tap.start();