refactor(tm-core): migrate to Vitest and Biome, implement clean architecture
- Migrated from Jest to Vitest for faster test execution (~4.2s vs ~4.6-5s) - Replaced ESLint and Prettier with Biome for unified, faster linting/formatting - Implemented BaseProvider with Template Method pattern following clean code principles - Created TaskEntity with business logic encapsulation - Added TaskMasterCore facade as main entry point - Implemented complete end-to-end listTasks functionality - All 50 tests passing with improved performance 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
es2022: true
|
||||
},
|
||||
extends: ['eslint:recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
// General code quality
|
||||
'no-console': 'warn',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'object-shorthand': 'error',
|
||||
'prefer-template': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
|
||||
// TypeScript specific rules (basic)
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
|
||||
},
|
||||
ignorePatterns: [
|
||||
'dist/',
|
||||
'node_modules/',
|
||||
'coverage/',
|
||||
'*.js',
|
||||
'!.eslintrc.cjs'
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.test.ts', '**/*.spec.ts'],
|
||||
env: {
|
||||
jest: true
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"quoteProps": "as-needed",
|
||||
"bracketSameLine": false,
|
||||
"proseWrap": "preserve"
|
||||
}
|
||||
98
packages/tm-core/biome.json
Normal file
98
packages/tm-core/biome.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "*.ts", "*.js", "*.json"],
|
||||
"ignore": ["**/node_modules", "**/dist", "**/.git", "**/coverage", "**/*.d.ts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useConst": "error",
|
||||
"useImportType": "warn",
|
||||
"useTemplate": "warn",
|
||||
"noUselessElse": "warn",
|
||||
"noVar": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedImports": "error",
|
||||
"useExhaustiveDependencies": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "error",
|
||||
"noForEach": "off",
|
||||
"noStaticOnlyClass": "warn",
|
||||
"noUselessConstructor": "error",
|
||||
"noUselessTypeConstraint": "error",
|
||||
"useArrowFunction": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"noImplicitAnyLet": "error",
|
||||
"noArrayIndexKey": "warn",
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noDoubleEquals": "warn",
|
||||
"noRedundantUseStrict": "error"
|
||||
},
|
||||
"security": {
|
||||
"noGlobalEval": "error"
|
||||
},
|
||||
"performance": {
|
||||
"noAccumulatingSpread": "warn",
|
||||
"noDelete": "warn"
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "none",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false
|
||||
},
|
||||
"parser": {
|
||||
"unsafeParameterDecoratorsEnabled": true
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 100,
|
||||
"trailingCommas": "none"
|
||||
},
|
||||
"parser": {
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/** @type {import('jest').Config} */
|
||||
export default {
|
||||
preset: 'ts-jest/presets/default-esm',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src', '<rootDir>/tests'],
|
||||
testMatch: [
|
||||
'**/tests/**/*.test.ts',
|
||||
'**/tests/**/*.spec.ts',
|
||||
'**/__tests__/**/*.ts',
|
||||
'**/?(*.)+(spec|test).ts'
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.ts$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
tsconfig: {
|
||||
module: 'ESNext',
|
||||
target: 'ES2022'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^@/types/(.*)$': '<rootDir>/src/types/$1',
|
||||
'^@/providers/(.*)$': '<rootDir>/src/providers/$1',
|
||||
'^@/storage/(.*)$': '<rootDir>/src/storage/$1',
|
||||
'^@/parser/(.*)$': '<rootDir>/src/parser/$1',
|
||||
'^@/utils/(.*)$': '<rootDir>/src/utils/$1',
|
||||
'^@/errors/(.*)$': '<rootDir>/src/errors/$1'
|
||||
},
|
||||
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts'],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80
|
||||
}
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||
testTimeout: 10000,
|
||||
verbose: true,
|
||||
clearMocks: true,
|
||||
restoreMocks: true
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@task-master/tm-core",
|
||||
"name": "@tm/core",
|
||||
"version": "1.0.0",
|
||||
"description": "Core library for Task Master - TypeScript task management system",
|
||||
"type": "module",
|
||||
@@ -46,28 +46,27 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "biome check --write",
|
||||
"lint:check": "biome check",
|
||||
"lint:fix": "biome check --fix --unsafe",
|
||||
"format": "biome format --write",
|
||||
"format:check": "biome format",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/node": "^20.11.30",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.1.2",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.3"
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^2.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { TaskPriority, TaskComplexity } from '../types/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Enum Schemas
|
||||
@@ -17,12 +16,7 @@ export const taskPrioritySchema = z.enum(['low', 'medium', 'high', 'critical']);
|
||||
/**
|
||||
* Task complexity validation schema
|
||||
*/
|
||||
export const taskComplexitySchema = z.enum([
|
||||
'simple',
|
||||
'moderate',
|
||||
'complex',
|
||||
'very-complex'
|
||||
]);
|
||||
export const taskComplexitySchema = z.enum(['simple', 'moderate', 'complex', 'very-complex']);
|
||||
|
||||
/**
|
||||
* Log level validation schema
|
||||
@@ -37,11 +31,7 @@ export const storageTypeSchema = z.enum(['file', 'memory', 'database']);
|
||||
/**
|
||||
* Tag naming convention validation schema
|
||||
*/
|
||||
export const tagNamingConventionSchema = z.enum([
|
||||
'kebab-case',
|
||||
'camelCase',
|
||||
'snake_case'
|
||||
]);
|
||||
export const tagNamingConventionSchema = z.enum(['kebab-case', 'camelCase', 'snake_case']);
|
||||
|
||||
/**
|
||||
* Buffer encoding validation schema
|
||||
@@ -233,6 +223,4 @@ export const cacheConfigSchema = z
|
||||
// ============================================================================
|
||||
|
||||
export type ConfigurationSchema = z.infer<typeof configurationSchema>;
|
||||
export type PartialConfigurationSchema = z.infer<
|
||||
typeof partialConfigurationSchema
|
||||
>;
|
||||
export type PartialConfigurationSchema = z.infer<typeof partialConfigurationSchema>;
|
||||
|
||||
239
packages/tm-core/src/core/entities/task.entity.ts
Normal file
239
packages/tm-core/src/core/entities/task.entity.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @fileoverview Task entity with business rules and domain logic
|
||||
*/
|
||||
|
||||
import { ERROR_CODES, TaskMasterError } from '../../errors/task-master-error.js';
|
||||
import type { Subtask, Task, TaskPriority, TaskStatus } from '../../types/index.js';
|
||||
|
||||
/**
|
||||
* Task entity representing a task with business logic
|
||||
* Encapsulates validation and state management rules
|
||||
*/
|
||||
export class TaskEntity implements Task {
|
||||
readonly id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
dependencies: string[];
|
||||
details: string;
|
||||
testStrategy: string;
|
||||
subtasks: Subtask[];
|
||||
|
||||
// Optional properties
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
effort?: number;
|
||||
actualEffort?: number;
|
||||
tags?: string[];
|
||||
assignee?: string;
|
||||
complexity?: Task['complexity'];
|
||||
|
||||
constructor(data: Task) {
|
||||
this.validate(data);
|
||||
|
||||
this.id = data.id;
|
||||
this.title = data.title;
|
||||
this.description = data.description;
|
||||
this.status = data.status;
|
||||
this.priority = data.priority;
|
||||
this.dependencies = data.dependencies || [];
|
||||
this.details = data.details;
|
||||
this.testStrategy = data.testStrategy;
|
||||
this.subtasks = data.subtasks || [];
|
||||
|
||||
// Optional properties
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
this.effort = data.effort;
|
||||
this.actualEffort = data.actualEffort;
|
||||
this.tags = data.tags;
|
||||
this.assignee = data.assignee;
|
||||
this.complexity = data.complexity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate task data
|
||||
*/
|
||||
private validate(data: Partial<Task>): void {
|
||||
if (!data.id || typeof data.id !== 'string') {
|
||||
throw new TaskMasterError(
|
||||
'Task ID is required and must be a string',
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.title || data.title.trim().length === 0) {
|
||||
throw new TaskMasterError('Task title is required', ERROR_CODES.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
if (!data.description || data.description.trim().length === 0) {
|
||||
throw new TaskMasterError('Task description is required', ERROR_CODES.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
if (!this.isValidStatus(data.status)) {
|
||||
throw new TaskMasterError(
|
||||
`Invalid task status: ${data.status}`,
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isValidPriority(data.priority)) {
|
||||
throw new TaskMasterError(
|
||||
`Invalid task priority: ${data.priority}`,
|
||||
ERROR_CODES.VALIDATION_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if status is valid
|
||||
*/
|
||||
private isValidStatus(status: any): status is TaskStatus {
|
||||
return [
|
||||
'pending',
|
||||
'in-progress',
|
||||
'done',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
'review'
|
||||
].includes(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if priority is valid
|
||||
*/
|
||||
private isValidPriority(priority: any): priority is TaskPriority {
|
||||
return ['low', 'medium', 'high', 'critical'].includes(priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task can be marked as complete
|
||||
*/
|
||||
canComplete(): boolean {
|
||||
// Cannot complete if status is already done or cancelled
|
||||
if (this.status === 'done' || this.status === 'cancelled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot complete if blocked
|
||||
if (this.status === 'blocked') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all subtasks are complete
|
||||
const allSubtasksComplete = this.subtasks.every(
|
||||
(subtask) => subtask.status === 'done' || subtask.status === 'cancelled'
|
||||
);
|
||||
|
||||
return allSubtasksComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark task as complete
|
||||
*/
|
||||
markAsComplete(): void {
|
||||
if (!this.canComplete()) {
|
||||
throw new TaskMasterError(
|
||||
'Task cannot be marked as complete',
|
||||
ERROR_CODES.TASK_STATUS_ERROR,
|
||||
{
|
||||
taskId: this.id,
|
||||
currentStatus: this.status,
|
||||
hasIncompleteSubtasks: this.subtasks.some(
|
||||
(s) => s.status !== 'done' && s.status !== 'cancelled'
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.status = 'done';
|
||||
this.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task has dependencies
|
||||
*/
|
||||
hasDependencies(): boolean {
|
||||
return this.dependencies.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if task has subtasks
|
||||
*/
|
||||
hasSubtasks(): boolean {
|
||||
return this.subtasks.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a subtask
|
||||
*/
|
||||
addSubtask(subtask: Omit<Subtask, 'id' | 'parentId'>): void {
|
||||
const nextId = this.subtasks.length + 1;
|
||||
this.subtasks.push({
|
||||
...subtask,
|
||||
id: nextId,
|
||||
parentId: this.id
|
||||
});
|
||||
this.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update task status
|
||||
*/
|
||||
updateStatus(newStatus: TaskStatus): void {
|
||||
if (!this.isValidStatus(newStatus)) {
|
||||
throw new TaskMasterError(`Invalid status: ${newStatus}`, ERROR_CODES.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
// Business rule: Cannot move from done to pending
|
||||
if (this.status === 'done' && newStatus === 'pending') {
|
||||
throw new TaskMasterError(
|
||||
'Cannot move completed task back to pending',
|
||||
ERROR_CODES.TASK_STATUS_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
this.status = newStatus;
|
||||
this.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entity to plain object
|
||||
*/
|
||||
toJSON(): Task {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
status: this.status,
|
||||
priority: this.priority,
|
||||
dependencies: this.dependencies,
|
||||
details: this.details,
|
||||
testStrategy: this.testStrategy,
|
||||
subtasks: this.subtasks,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
effort: this.effort,
|
||||
actualEffort: this.actualEffort,
|
||||
tags: this.tags,
|
||||
assignee: this.assignee,
|
||||
complexity: this.complexity
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create TaskEntity from plain object
|
||||
*/
|
||||
static fromObject(data: Task): TaskEntity {
|
||||
return new TaskEntity(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple TaskEntities from array
|
||||
*/
|
||||
static fromArray(data: Task[]): TaskEntity[] {
|
||||
return data.map((task) => new TaskEntity(task));
|
||||
}
|
||||
}
|
||||
@@ -174,8 +174,8 @@ export class TaskMasterError extends Error {
|
||||
}
|
||||
|
||||
// If we have a cause error, append its stack trace
|
||||
if (cause && cause.stack) {
|
||||
this.stack = this.stack + '\nCaused by: ' + cause.stack;
|
||||
if (cause?.stack) {
|
||||
this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,14 +211,7 @@ export class TaskMasterError extends Error {
|
||||
private containsSensitiveInfo(obj: any): boolean {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
|
||||
const sensitiveKeys = [
|
||||
'password',
|
||||
'token',
|
||||
'key',
|
||||
'secret',
|
||||
'auth',
|
||||
'credential'
|
||||
];
|
||||
const sensitiveKeys = ['password', 'token', 'key', 'secret', 'auth', 'credential'];
|
||||
const objString = JSON.stringify(obj).toLowerCase();
|
||||
|
||||
return sensitiveKeys.some((key) => objString.includes(key));
|
||||
@@ -300,9 +293,7 @@ export class TaskMasterError extends Error {
|
||||
/**
|
||||
* Create a new error with additional context
|
||||
*/
|
||||
public withContext(
|
||||
additionalContext: Partial<ErrorContext>
|
||||
): TaskMasterError {
|
||||
public withContext(additionalContext: Partial<ErrorContext>): TaskMasterError {
|
||||
return new TaskMasterError(
|
||||
this.message,
|
||||
this.code,
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
* This file exports all public APIs from the core Task Master library
|
||||
*/
|
||||
|
||||
// Export main facade
|
||||
export {
|
||||
TaskMasterCore,
|
||||
createTaskMasterCore,
|
||||
type TaskMasterCoreOptions,
|
||||
type ListTasksResult
|
||||
} from './task-master-core.js';
|
||||
|
||||
// Re-export types
|
||||
export type * from './types/index';
|
||||
|
||||
@@ -25,6 +33,9 @@ export * from './utils/index';
|
||||
// Re-export errors
|
||||
export * from './errors/index';
|
||||
|
||||
// Re-export entities
|
||||
export { TaskEntity } from './core/entities/task.entity.js';
|
||||
|
||||
// Package metadata
|
||||
export const version = '1.0.0';
|
||||
export const name = '@task-master/tm-core';
|
||||
|
||||
@@ -282,10 +282,7 @@ export abstract class BaseAIProvider implements IAIProvider {
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by concrete classes
|
||||
abstract generateCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): Promise<AIResponse>;
|
||||
abstract generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse>;
|
||||
abstract generateStreamingCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
@@ -309,9 +306,7 @@ export abstract class BaseAIProvider implements IAIProvider {
|
||||
const modelExists = availableModels.some((m) => m.id === model);
|
||||
|
||||
if (!modelExists) {
|
||||
throw new Error(
|
||||
`Model "${model}" is not available for provider "${this.getName()}"`
|
||||
);
|
||||
throw new Error(`Model "${model}" is not available for provider "${this.getName()}"`);
|
||||
}
|
||||
|
||||
this.currentModel = model;
|
||||
@@ -347,11 +342,7 @@ export abstract class BaseAIProvider implements IAIProvider {
|
||||
* @param duration - Request duration in milliseconds
|
||||
* @param success - Whether the request was successful
|
||||
*/
|
||||
protected updateUsageStats(
|
||||
response: AIResponse,
|
||||
duration: number,
|
||||
success: boolean
|
||||
): void {
|
||||
protected updateUsageStats(response: AIResponse, duration: number, success: boolean): void {
|
||||
if (!this.usageStats) return;
|
||||
|
||||
this.usageStats.totalRequests++;
|
||||
@@ -370,18 +361,15 @@ export abstract class BaseAIProvider implements IAIProvider {
|
||||
}
|
||||
|
||||
// Update average response time
|
||||
const totalTime =
|
||||
this.usageStats.averageResponseTime * (this.usageStats.totalRequests - 1);
|
||||
this.usageStats.averageResponseTime =
|
||||
(totalTime + duration) / this.usageStats.totalRequests;
|
||||
const totalTime = this.usageStats.averageResponseTime * (this.usageStats.totalRequests - 1);
|
||||
this.usageStats.averageResponseTime = (totalTime + duration) / this.usageStats.totalRequests;
|
||||
|
||||
// Update success rate
|
||||
const successCount = Math.floor(
|
||||
this.usageStats.successRate * (this.usageStats.totalRequests - 1)
|
||||
);
|
||||
const newSuccessCount = successCount + (success ? 1 : 0);
|
||||
this.usageStats.successRate =
|
||||
newSuccessCount / this.usageStats.totalRequests;
|
||||
this.usageStats.successRate = newSuccessCount / this.usageStats.totalRequests;
|
||||
|
||||
this.usageStats.lastRequestAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* This file defines the contract for configuration management
|
||||
*/
|
||||
|
||||
import type { TaskPriority, TaskComplexity } from '../types/index';
|
||||
import type { TaskComplexity, TaskPriority } from '../types/index';
|
||||
|
||||
/**
|
||||
* Model configuration for different AI roles
|
||||
|
||||
@@ -40,11 +40,7 @@ export interface IStorage {
|
||||
* @param tag - Optional tag context for the task
|
||||
* @returns Promise that resolves when update is complete
|
||||
*/
|
||||
updateTask(
|
||||
taskId: string,
|
||||
updates: Partial<Task>,
|
||||
tag?: string
|
||||
): Promise<void>;
|
||||
updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a task by ID
|
||||
@@ -177,11 +173,7 @@ export abstract class BaseStorage implements IStorage {
|
||||
abstract loadTasks(tag?: string): Promise<Task[]>;
|
||||
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
abstract updateTask(
|
||||
taskId: string,
|
||||
updates: Partial<Task>,
|
||||
tag?: string
|
||||
): Promise<void>;
|
||||
abstract updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<void>;
|
||||
abstract deleteTask(taskId: string, tag?: string): Promise<void>;
|
||||
abstract exists(tag?: string): Promise<boolean>;
|
||||
abstract loadMetadata(tag?: string): Promise<TaskMetadata | null>;
|
||||
|
||||
@@ -22,9 +22,7 @@ export interface TaskParser {
|
||||
export class PlaceholderParser implements TaskParser {
|
||||
async parse(content: string): Promise<PlaceholderTask[]> {
|
||||
// Simple placeholder parsing logic
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().startsWith('-'));
|
||||
const lines = content.split('\n').filter((line) => line.trim().startsWith('-'));
|
||||
return lines.map((line, index) => ({
|
||||
id: `task-${index + 1}`,
|
||||
title: line.trim().replace(/^-\s*/, ''),
|
||||
|
||||
405
packages/tm-core/src/providers/ai/base-provider.ts
Normal file
405
packages/tm-core/src/providers/ai/base-provider.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @fileoverview Abstract base provider with Template Method pattern for AI providers
|
||||
* Provides common functionality, error handling, and retry logic
|
||||
*/
|
||||
|
||||
import { ERROR_CODES, TaskMasterError } from '../../errors/task-master-error.js';
|
||||
import type { AIOptions, AIResponse, IAIProvider } from '../../interfaces/ai-provider.interface.js';
|
||||
|
||||
// Constants for retry logic
|
||||
const DEFAULT_MAX_RETRIES = 3;
|
||||
const BASE_RETRY_DELAY_MS = 1000;
|
||||
const MAX_RETRY_DELAY_MS = 32000;
|
||||
const BACKOFF_MULTIPLIER = 2;
|
||||
const JITTER_FACTOR = 0.1;
|
||||
|
||||
// Constants for validation
|
||||
const MIN_PROMPT_LENGTH = 1;
|
||||
const MAX_PROMPT_LENGTH = 100000;
|
||||
const MIN_TEMPERATURE = 0;
|
||||
const MAX_TEMPERATURE = 2;
|
||||
const MIN_MAX_TOKENS = 1;
|
||||
const MAX_MAX_TOKENS = 100000;
|
||||
|
||||
/**
|
||||
* Configuration for BaseProvider
|
||||
*/
|
||||
export interface BaseProviderConfig {
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal completion result structure
|
||||
*/
|
||||
export interface CompletionResult {
|
||||
content: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
finishReason?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result for input validation
|
||||
*/
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepared request after preprocessing
|
||||
*/
|
||||
interface PreparedRequest {
|
||||
prompt: string;
|
||||
options: AIOptions;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base provider implementing Template Method pattern
|
||||
* Provides common error handling, retry logic, and validation
|
||||
*/
|
||||
export abstract class BaseProvider implements IAIProvider {
|
||||
protected readonly apiKey: string;
|
||||
protected model: string;
|
||||
|
||||
constructor(config: BaseProviderConfig) {
|
||||
if (!config.apiKey) {
|
||||
throw new TaskMasterError('API key is required', ERROR_CODES.AUTHENTICATION_ERROR);
|
||||
}
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || this.getDefaultModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Template method for generating completions
|
||||
* Handles validation, retries, and error handling
|
||||
*/
|
||||
async generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse> {
|
||||
// Validate input
|
||||
const validation = this.validateInput(prompt, options);
|
||||
if (!validation.valid) {
|
||||
throw new TaskMasterError(validation.error || 'Invalid input', ERROR_CODES.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
// Prepare request
|
||||
const prepared = this.prepareRequest(prompt, options);
|
||||
|
||||
// Execute with retry logic
|
||||
let lastError: Error | undefined;
|
||||
const maxRetries = this.getMaxRetries();
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const result = await this.generateCompletionInternal(prepared.prompt, prepared.options);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
return this.handleResponse(result, duration, prepared);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (!this.shouldRetry(error, attempt)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = this.calculateBackoffDelay(attempt);
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries failed
|
||||
this.handleError(lastError || new Error('Unknown error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input prompt and options
|
||||
*/
|
||||
protected validateInput(prompt: string, options?: AIOptions): ValidationResult {
|
||||
// Validate prompt
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
return { valid: false, error: 'Prompt must be a non-empty string' };
|
||||
}
|
||||
|
||||
const trimmedPrompt = prompt.trim();
|
||||
if (trimmedPrompt.length < MIN_PROMPT_LENGTH) {
|
||||
return { valid: false, error: 'Prompt cannot be empty' };
|
||||
}
|
||||
|
||||
if (trimmedPrompt.length > MAX_PROMPT_LENGTH) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Prompt exceeds maximum length of ${MAX_PROMPT_LENGTH} characters`
|
||||
};
|
||||
}
|
||||
|
||||
// Validate options if provided
|
||||
if (options) {
|
||||
const optionValidation = this.validateOptions(options);
|
||||
if (!optionValidation.valid) {
|
||||
return optionValidation;
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate completion options
|
||||
*/
|
||||
protected validateOptions(options: AIOptions): ValidationResult {
|
||||
if (options.temperature !== undefined) {
|
||||
if (options.temperature < MIN_TEMPERATURE || options.temperature > MAX_TEMPERATURE) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Temperature must be between ${MIN_TEMPERATURE} and ${MAX_TEMPERATURE}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxTokens !== undefined) {
|
||||
if (options.maxTokens < MIN_MAX_TOKENS || options.maxTokens > MAX_MAX_TOKENS) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Max tokens must be between ${MIN_MAX_TOKENS} and ${MAX_MAX_TOKENS}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (options.topP !== undefined) {
|
||||
if (options.topP < 0 || options.topP > 1) {
|
||||
return { valid: false, error: 'Top-p must be between 0 and 1' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare request for processing
|
||||
*/
|
||||
protected prepareRequest(prompt: string, options?: AIOptions): PreparedRequest {
|
||||
const defaultOptions = this.getDefaultOptions();
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
|
||||
return {
|
||||
prompt: prompt.trim(),
|
||||
options: mergedOptions,
|
||||
metadata: {
|
||||
provider: this.getName(),
|
||||
model: this.model,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process and format the response
|
||||
*/
|
||||
protected handleResponse(
|
||||
result: CompletionResult,
|
||||
duration: number,
|
||||
request: PreparedRequest
|
||||
): AIResponse {
|
||||
const inputTokens = result.inputTokens || this.calculateTokens(request.prompt);
|
||||
const outputTokens = result.outputTokens || this.calculateTokens(result.content);
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens + outputTokens,
|
||||
model: result.model || this.model,
|
||||
provider: this.getName(),
|
||||
timestamp: request.metadata.timestamp,
|
||||
duration,
|
||||
finishReason: result.finishReason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors with proper wrapping
|
||||
*/
|
||||
protected handleError(error: unknown): never {
|
||||
if (error instanceof TaskMasterError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorCode = this.getErrorCode(error);
|
||||
|
||||
throw new TaskMasterError(
|
||||
`${this.getName()} provider error: ${errorMessage}`,
|
||||
errorCode,
|
||||
{
|
||||
operation: 'generateCompletion',
|
||||
resource: this.getName(),
|
||||
details:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
model: this.model
|
||||
}
|
||||
: { error: String(error), model: this.model }
|
||||
},
|
||||
error instanceof Error ? error : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if request should be retried
|
||||
*/
|
||||
protected shouldRetry(error: unknown, attempt: number): boolean {
|
||||
if (attempt >= this.getMaxRetries()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isRetryableError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*/
|
||||
protected isRetryableError(error: unknown): boolean {
|
||||
if (this.isRateLimitError(error)) return true;
|
||||
if (this.isTimeoutError(error)) return true;
|
||||
if (this.isNetworkError(error)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a rate limit error
|
||||
*/
|
||||
protected isRateLimitError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('rate limit') ||
|
||||
message.includes('too many requests') ||
|
||||
message.includes('429')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a timeout error
|
||||
*/
|
||||
protected isTimeoutError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('timeout') ||
|
||||
message.includes('timed out') ||
|
||||
message.includes('econnreset')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a network error
|
||||
*/
|
||||
protected isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('network') ||
|
||||
message.includes('enotfound') ||
|
||||
message.includes('econnrefused')
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay with jitter
|
||||
*/
|
||||
protected calculateBackoffDelay(attempt: number): number {
|
||||
const exponentialDelay = BASE_RETRY_DELAY_MS * BACKOFF_MULTIPLIER ** (attempt - 1);
|
||||
const clampedDelay = Math.min(exponentialDelay, MAX_RETRY_DELAY_MS);
|
||||
|
||||
// Add jitter to prevent thundering herd
|
||||
const jitter = clampedDelay * JITTER_FACTOR * (Math.random() - 0.5) * 2;
|
||||
|
||||
return Math.round(clampedDelay + jitter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error code from error
|
||||
*/
|
||||
protected getErrorCode(error: unknown): string {
|
||||
if (this.isRateLimitError(error)) return ERROR_CODES.API_ERROR;
|
||||
if (this.isTimeoutError(error)) return ERROR_CODES.NETWORK_ERROR;
|
||||
if (this.isNetworkError(error)) return ERROR_CODES.NETWORK_ERROR;
|
||||
|
||||
if (error instanceof Error && error.message.includes('401')) {
|
||||
return ERROR_CODES.AUTHENTICATION_ERROR;
|
||||
}
|
||||
|
||||
return ERROR_CODES.PROVIDER_ERROR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility for delays
|
||||
*/
|
||||
protected sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default options for completions
|
||||
*/
|
||||
protected getDefaultOptions(): AIOptions {
|
||||
return {
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
topP: 1.0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum retry attempts
|
||||
*/
|
||||
protected getMaxRetries(): number {
|
||||
return DEFAULT_MAX_RETRIES;
|
||||
}
|
||||
|
||||
// Public interface methods
|
||||
getModel(): string {
|
||||
return this.model;
|
||||
}
|
||||
|
||||
setModel(model: string): void {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
// Abstract methods that must be implemented by concrete providers
|
||||
protected abstract generateCompletionInternal(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): Promise<CompletionResult>;
|
||||
|
||||
abstract calculateTokens(text: string, model?: string): number;
|
||||
abstract getName(): string;
|
||||
abstract getDefaultModel(): string;
|
||||
|
||||
// IAIProvider methods that must be implemented
|
||||
abstract generateStreamingCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): AsyncIterator<Partial<AIResponse>>;
|
||||
abstract isAvailable(): Promise<boolean>;
|
||||
abstract getProviderInfo(): import('../../interfaces/ai-provider.interface.js').ProviderInfo;
|
||||
abstract getAvailableModels(): import('../../interfaces/ai-provider.interface.js').AIModel[];
|
||||
abstract validateCredentials(): Promise<boolean>;
|
||||
abstract getUsageStats(): Promise<
|
||||
import('../../interfaces/ai-provider.interface.js').ProviderUsageStats | null
|
||||
>;
|
||||
abstract initialize(): Promise<void>;
|
||||
abstract close(): Promise<void>;
|
||||
}
|
||||
14
packages/tm-core/src/providers/ai/index.ts
Normal file
14
packages/tm-core/src/providers/ai/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @fileoverview Barrel export for AI provider modules
|
||||
*/
|
||||
|
||||
export { BaseProvider } from './base-provider.js';
|
||||
export type { BaseProviderConfig, CompletionResult } from './base-provider.js';
|
||||
|
||||
// Export provider factory when implemented
|
||||
// export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
// Export concrete providers when implemented
|
||||
// export { AnthropicProvider } from './adapters/anthropic-provider.js';
|
||||
// export { OpenAIProvider } from './adapters/openai-provider.js';
|
||||
// export { GoogleProvider } from './adapters/google-provider.js';
|
||||
@@ -3,11 +3,11 @@
|
||||
* Provides common functionality and properties for all AI provider implementations
|
||||
*/
|
||||
|
||||
import {
|
||||
IAIProvider,
|
||||
import type {
|
||||
AIModel,
|
||||
AIOptions,
|
||||
AIResponse,
|
||||
AIModel,
|
||||
IAIProvider,
|
||||
ProviderInfo,
|
||||
ProviderUsageStats
|
||||
} from '../interfaces/ai-provider.interface.js';
|
||||
@@ -32,9 +32,9 @@ export abstract class BaseProvider implements IAIProvider {
|
||||
/** Current model being used */
|
||||
protected model: string;
|
||||
/** Maximum number of retry attempts */
|
||||
protected maxRetries: number = 3;
|
||||
protected maxRetries = 3;
|
||||
/** Delay between retries in milliseconds */
|
||||
protected retryDelay: number = 1000;
|
||||
protected retryDelay = 1000;
|
||||
|
||||
/**
|
||||
* Constructor for BaseProvider
|
||||
@@ -54,10 +54,7 @@ export abstract class BaseProvider implements IAIProvider {
|
||||
}
|
||||
|
||||
// Abstract methods that concrete providers must implement
|
||||
abstract generateCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
): Promise<AIResponse>;
|
||||
abstract generateCompletion(prompt: string, options?: AIOptions): Promise<AIResponse>;
|
||||
abstract generateStreamingCompletion(
|
||||
prompt: string,
|
||||
options?: AIOptions
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
/**
|
||||
* @fileoverview AI provider implementations for the tm-core package
|
||||
* This file exports all AI provider classes and interfaces
|
||||
* @fileoverview Barrel export for provider modules
|
||||
*/
|
||||
|
||||
// Provider interfaces and implementations
|
||||
export * from './base-provider.js';
|
||||
// export * from './anthropic-provider.js';
|
||||
// export * from './openai-provider.js';
|
||||
// export * from './perplexity-provider.js';
|
||||
// Export AI providers from subdirectory
|
||||
export { BaseProvider } from './ai/base-provider.js';
|
||||
export type {
|
||||
BaseProviderConfig,
|
||||
CompletionResult
|
||||
} from './ai/base-provider.js';
|
||||
|
||||
// Placeholder exports - these will be implemented in later tasks
|
||||
export interface AIProvider {
|
||||
name: string;
|
||||
generateResponse(prompt: string): Promise<string>;
|
||||
}
|
||||
// Export all from AI module
|
||||
export * from './ai/index.js';
|
||||
|
||||
/**
|
||||
* @deprecated This is a placeholder class that will be properly implemented in later tasks
|
||||
*/
|
||||
export class PlaceholderProvider implements AIProvider {
|
||||
name = 'placeholder';
|
||||
// Storage providers will be exported here when implemented
|
||||
// export * from './storage/index.js';
|
||||
|
||||
async generateResponse(prompt: string): Promise<string> {
|
||||
return `Placeholder response for: ${prompt}`;
|
||||
}
|
||||
}
|
||||
// Placeholder provider for tests
|
||||
export { PlaceholderProvider } from './placeholder-provider.js';
|
||||
|
||||
15
packages/tm-core/src/providers/placeholder-provider.ts
Normal file
15
packages/tm-core/src/providers/placeholder-provider.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @fileoverview Placeholder provider for testing purposes
|
||||
* @deprecated This is a placeholder implementation that will be replaced
|
||||
*/
|
||||
|
||||
/**
|
||||
* PlaceholderProvider for smoke tests
|
||||
*/
|
||||
export class PlaceholderProvider {
|
||||
name = 'placeholder';
|
||||
|
||||
async generateResponse(prompt: string): Promise<string> {
|
||||
return `Mock response to: ${prompt}`;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* File-based storage implementation for Task Master
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Task, TaskMetadata } from '../types/index.js';
|
||||
import { BaseStorage, type StorageStats } from './storage.interface.js';
|
||||
|
||||
@@ -228,9 +228,7 @@ export class FileStorage extends BaseStorage {
|
||||
/**
|
||||
* Read and parse JSON file with error handling
|
||||
*/
|
||||
private async readJsonFile(
|
||||
filePath: string
|
||||
): Promise<FileStorageData | null> {
|
||||
private async readJsonFile(filePath: string): Promise<FileStorageData | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
@@ -248,10 +246,7 @@ export class FileStorage extends BaseStorage {
|
||||
/**
|
||||
* Write JSON file with atomic operation using temp file
|
||||
*/
|
||||
private async writeJsonFile(
|
||||
filePath: string,
|
||||
data: FileStorageData
|
||||
): Promise<void> {
|
||||
private async writeJsonFile(filePath: string, data: FileStorageData): Promise<void> {
|
||||
// Use file locking to prevent concurrent writes
|
||||
const lockKey = filePath;
|
||||
const existingLock = this.fileLocks.get(lockKey);
|
||||
@@ -273,10 +268,7 @@ export class FileStorage extends BaseStorage {
|
||||
/**
|
||||
* Perform the actual write operation
|
||||
*/
|
||||
private async performWrite(
|
||||
filePath: string,
|
||||
data: FileStorageData
|
||||
): Promise<void> {
|
||||
private async performWrite(filePath: string, data: FileStorageData): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
|
||||
try {
|
||||
@@ -331,9 +323,7 @@ export class FileStorage extends BaseStorage {
|
||||
try {
|
||||
const files = await fs.readdir(dir);
|
||||
const backupFiles = files
|
||||
.filter(
|
||||
(f) => f.startsWith(`${basename}.backup.`) && f.endsWith('.json')
|
||||
)
|
||||
.filter((f) => f.startsWith(`${basename}.backup.`) && f.endsWith('.json'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
* Storage interface and base implementation for Task Master
|
||||
*/
|
||||
|
||||
import type {
|
||||
Task,
|
||||
TaskMetadata,
|
||||
TaskFilter,
|
||||
TaskSortOptions
|
||||
} from '../types/index.js';
|
||||
import type { Task, TaskFilter, TaskMetadata, TaskSortOptions } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Storage statistics
|
||||
@@ -38,11 +33,7 @@ export interface IStorage {
|
||||
loadTasks(tag?: string): Promise<Task[]>;
|
||||
saveTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
appendTasks(tasks: Task[], tag?: string): Promise<void>;
|
||||
updateTask(
|
||||
taskId: string,
|
||||
updates: Partial<Task>,
|
||||
tag?: string
|
||||
): Promise<boolean>;
|
||||
updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<boolean>;
|
||||
deleteTask(taskId: string, tag?: string): Promise<boolean>;
|
||||
exists(tag?: string): Promise<boolean>;
|
||||
|
||||
@@ -100,11 +91,7 @@ export abstract class BaseStorage implements IStorage {
|
||||
await this.saveTasks(mergedTasks, tag);
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
taskId: string,
|
||||
updates: Partial<Task>,
|
||||
tag?: string
|
||||
): Promise<boolean> {
|
||||
async updateTask(taskId: string, updates: Partial<Task>, tag?: string): Promise<boolean> {
|
||||
const tasks = await this.loadTasks(tag);
|
||||
const taskIndex = tasks.findIndex((t) => t.id === taskId);
|
||||
|
||||
@@ -149,7 +136,7 @@ export abstract class BaseStorage implements IStorage {
|
||||
};
|
||||
}
|
||||
|
||||
async saveMetadata(metadata: TaskMetadata, tag?: string): Promise<void> {
|
||||
async saveMetadata(_metadata: TaskMetadata, _tag?: string): Promise<void> {
|
||||
// Default implementation: metadata is derived from tasks
|
||||
// Subclasses can override if they store metadata separately
|
||||
}
|
||||
@@ -189,26 +176,19 @@ export abstract class BaseStorage implements IStorage {
|
||||
return tasks.filter((task) => {
|
||||
// Status filter
|
||||
if (filter.status) {
|
||||
const statuses = Array.isArray(filter.status)
|
||||
? filter.status
|
||||
: [filter.status];
|
||||
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
||||
if (!statuses.includes(task.status)) return false;
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (filter.priority) {
|
||||
const priorities = Array.isArray(filter.priority)
|
||||
? filter.priority
|
||||
: [filter.priority];
|
||||
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
|
||||
if (!priorities.includes(task.priority)) return false;
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
if (
|
||||
!task.tags ||
|
||||
!filter.tags.some((tag) => task.tags?.includes(tag))
|
||||
) {
|
||||
if (!task.tags || !filter.tags.some((tag) => task.tags?.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -223,9 +203,7 @@ export abstract class BaseStorage implements IStorage {
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
const inTitle = task.title.toLowerCase().includes(searchLower);
|
||||
const inDescription = task.description
|
||||
.toLowerCase()
|
||||
.includes(searchLower);
|
||||
const inDescription = task.description.toLowerCase().includes(searchLower);
|
||||
const inDetails = task.details.toLowerCase().includes(searchLower);
|
||||
if (!inTitle && !inDescription && !inDetails) return false;
|
||||
}
|
||||
@@ -240,8 +218,7 @@ export abstract class BaseStorage implements IStorage {
|
||||
const complexities = Array.isArray(filter.complexity)
|
||||
? filter.complexity
|
||||
: [filter.complexity];
|
||||
if (!task.complexity || !complexities.includes(task.complexity))
|
||||
return false;
|
||||
if (!task.complexity || !complexities.includes(task.complexity)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
302
packages/tm-core/src/task-master-core.ts
Normal file
302
packages/tm-core/src/task-master-core.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @fileoverview TaskMasterCore facade - main entry point for tm-core functionality
|
||||
*/
|
||||
|
||||
import { TaskEntity } from './core/entities/task.entity.js';
|
||||
import { ERROR_CODES, TaskMasterError } from './errors/task-master-error.js';
|
||||
import type { IConfiguration } from './interfaces/configuration.interface.js';
|
||||
import type { IStorage } from './interfaces/storage.interface.js';
|
||||
import { FileStorage } from './storage/file-storage.js';
|
||||
import type { Task, TaskFilter, TaskStatus } from './types/index.js';
|
||||
|
||||
/**
|
||||
* Options for creating TaskMasterCore instance
|
||||
*/
|
||||
export interface TaskMasterCoreOptions {
|
||||
projectPath: string;
|
||||
configuration?: Partial<IConfiguration>;
|
||||
storage?: IStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* List tasks result with metadata
|
||||
*/
|
||||
export interface ListTasksResult {
|
||||
tasks: Task[];
|
||||
total: number;
|
||||
filtered: number;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskMasterCore facade class
|
||||
* Provides simplified API for all tm-core operations
|
||||
*/
|
||||
export class TaskMasterCore {
|
||||
private storage: IStorage;
|
||||
private projectPath: string;
|
||||
private configuration: Partial<IConfiguration>;
|
||||
private initialized = false;
|
||||
|
||||
constructor(options: TaskMasterCoreOptions) {
|
||||
if (!options.projectPath) {
|
||||
throw new TaskMasterError('Project path is required', ERROR_CODES.MISSING_CONFIGURATION);
|
||||
}
|
||||
|
||||
this.projectPath = options.projectPath;
|
||||
this.configuration = options.configuration || {};
|
||||
|
||||
// Use provided storage or create default FileStorage
|
||||
this.storage = options.storage || new FileStorage(this.projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the TaskMasterCore instance
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
try {
|
||||
await this.storage.initialize();
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to initialize TaskMasterCore',
|
||||
ERROR_CODES.INTERNAL_ERROR,
|
||||
{ operation: 'initialize' },
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the instance is initialized
|
||||
*/
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tasks with optional filtering
|
||||
*/
|
||||
async listTasks(options?: {
|
||||
tag?: string;
|
||||
filter?: TaskFilter;
|
||||
includeSubtasks?: boolean;
|
||||
}): Promise<ListTasksResult> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
try {
|
||||
// Load tasks from storage
|
||||
const rawTasks = await this.storage.loadTasks(options?.tag);
|
||||
|
||||
// Convert to TaskEntity for business logic
|
||||
const taskEntities = TaskEntity.fromArray(rawTasks);
|
||||
|
||||
// Apply filters if provided
|
||||
let filteredTasks = taskEntities;
|
||||
|
||||
if (options?.filter) {
|
||||
filteredTasks = this.applyFilters(taskEntities, options.filter);
|
||||
}
|
||||
|
||||
// Convert back to plain objects
|
||||
const tasks = filteredTasks.map((entity) => entity.toJSON());
|
||||
|
||||
// Optionally exclude subtasks
|
||||
const finalTasks =
|
||||
options?.includeSubtasks === false
|
||||
? tasks.map((task) => ({ ...task, subtasks: [] }))
|
||||
: tasks;
|
||||
|
||||
return {
|
||||
tasks: finalTasks,
|
||||
total: rawTasks.length,
|
||||
filtered: filteredTasks.length,
|
||||
tag: options?.tag
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TaskMasterError(
|
||||
'Failed to list tasks',
|
||||
ERROR_CODES.INTERNAL_ERROR,
|
||||
{
|
||||
operation: 'listTasks',
|
||||
tag: options?.tag
|
||||
},
|
||||
error as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific task by ID
|
||||
*/
|
||||
async getTask(taskId: string, tag?: string): Promise<Task | null> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
const result = await this.listTasks({ tag });
|
||||
const task = result.tasks.find((t) => t.id === taskId);
|
||||
|
||||
return task || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks by status
|
||||
*/
|
||||
async getTasksByStatus(status: TaskStatus | TaskStatus[], tag?: string): Promise<Task[]> {
|
||||
const statuses = Array.isArray(status) ? status : [status];
|
||||
const result = await this.listTasks({
|
||||
tag,
|
||||
filter: { status: statuses }
|
||||
});
|
||||
|
||||
return result.tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task statistics
|
||||
*/
|
||||
async getTaskStats(tag?: string): Promise<{
|
||||
total: number;
|
||||
byStatus: Record<TaskStatus, number>;
|
||||
withSubtasks: number;
|
||||
blocked: number;
|
||||
}> {
|
||||
const result = await this.listTasks({ tag });
|
||||
|
||||
const stats = {
|
||||
total: result.total,
|
||||
byStatus: {} as Record<TaskStatus, number>,
|
||||
withSubtasks: 0,
|
||||
blocked: 0
|
||||
};
|
||||
|
||||
// Initialize status counts
|
||||
const statuses: TaskStatus[] = [
|
||||
'pending',
|
||||
'in-progress',
|
||||
'done',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
'review'
|
||||
];
|
||||
|
||||
statuses.forEach((status) => {
|
||||
stats.byStatus[status] = 0;
|
||||
});
|
||||
|
||||
// Count tasks
|
||||
result.tasks.forEach((task) => {
|
||||
stats.byStatus[task.status]++;
|
||||
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
stats.withSubtasks++;
|
||||
}
|
||||
|
||||
if (task.status === 'blocked') {
|
||||
stats.blocked++;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters to tasks
|
||||
*/
|
||||
private applyFilters(tasks: TaskEntity[], filter: TaskFilter): TaskEntity[] {
|
||||
return tasks.filter((task) => {
|
||||
// Filter by status
|
||||
if (filter.status) {
|
||||
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
|
||||
if (!statuses.includes(task.status)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by priority
|
||||
if (filter.priority) {
|
||||
const priorities = Array.isArray(filter.priority) ? filter.priority : [filter.priority];
|
||||
if (!priorities.includes(task.priority)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by tags
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
if (!task.tags || !filter.tags.some((tag) => task.tags?.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by assignee
|
||||
if (filter.assignee) {
|
||||
if (task.assignee !== filter.assignee) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by complexity
|
||||
if (filter.complexity) {
|
||||
const complexities = Array.isArray(filter.complexity)
|
||||
? filter.complexity
|
||||
: [filter.complexity];
|
||||
if (!task.complexity || !complexities.includes(task.complexity)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase();
|
||||
const inTitle = task.title.toLowerCase().includes(searchLower);
|
||||
const inDescription = task.description.toLowerCase().includes(searchLower);
|
||||
const inDetails = task.details.toLowerCase().includes(searchLower);
|
||||
|
||||
if (!inTitle && !inDescription && !inDetails) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by hasSubtasks
|
||||
if (filter.hasSubtasks !== undefined) {
|
||||
const hasSubtasks = task.subtasks.length > 0;
|
||||
if (hasSubtasks !== filter.hasSubtasks) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup resources
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.storage) {
|
||||
await this.storage.close();
|
||||
}
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create TaskMasterCore instance
|
||||
*/
|
||||
export function createTaskMasterCore(
|
||||
projectPath: string,
|
||||
options?: {
|
||||
configuration?: Partial<IConfiguration>;
|
||||
storage?: IStorage;
|
||||
}
|
||||
): TaskMasterCore {
|
||||
return new TaskMasterCore({
|
||||
projectPath,
|
||||
configuration: options?.configuration,
|
||||
storage: options?.storage
|
||||
});
|
||||
}
|
||||
@@ -93,10 +93,7 @@ export interface TaskCollection {
|
||||
/**
|
||||
* Type for creating a new task (without generated fields)
|
||||
*/
|
||||
export type CreateTask = Omit<
|
||||
Task,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'subtasks'
|
||||
> & {
|
||||
export type CreateTask = Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'subtasks'> & {
|
||||
subtasks?: Omit<Subtask, 'id' | 'parentId' | 'createdAt' | 'updatedAt'>[];
|
||||
};
|
||||
|
||||
@@ -138,15 +135,7 @@ export interface TaskSortOptions {
|
||||
export function isTaskStatus(value: unknown): value is TaskStatus {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
[
|
||||
'pending',
|
||||
'in-progress',
|
||||
'done',
|
||||
'deferred',
|
||||
'cancelled',
|
||||
'blocked',
|
||||
'review'
|
||||
].includes(value)
|
||||
['pending', 'in-progress', 'done', 'deferred', 'cancelled', 'blocked', 'review'].includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,10 +143,7 @@ export function isTaskStatus(value: unknown): value is TaskStatus {
|
||||
* Type guard to check if a value is a valid TaskPriority
|
||||
*/
|
||||
export function isTaskPriority(value: unknown): value is TaskPriority {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
['low', 'medium', 'high', 'critical'].includes(value)
|
||||
);
|
||||
return typeof value === 'string' && ['low', 'medium', 'high', 'critical'].includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,8 +151,7 @@ export function isTaskPriority(value: unknown): value is TaskPriority {
|
||||
*/
|
||||
export function isTaskComplexity(value: unknown): value is TaskComplexity {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
['simple', 'moderate', 'complex', 'very-complex'].includes(value)
|
||||
typeof value === 'string' && ['simple', 'moderate', 'complex', 'very-complex'].includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides functions to generate unique identifiers for tasks and subtasks
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Generates a unique task ID using the format: TASK-{timestamp}-{random}
|
||||
@@ -33,28 +33,22 @@ export function generateTaskId(): string {
|
||||
* // Returns: "TASK-123-A7B3.2"
|
||||
* ```
|
||||
*/
|
||||
export function generateSubtaskId(
|
||||
parentId: string,
|
||||
existingSubtasks: string[] = []
|
||||
): string {
|
||||
export function generateSubtaskId(parentId: string, existingSubtasks: string[] = []): string {
|
||||
// Find existing subtasks for this parent
|
||||
const parentSubtasks = existingSubtasks.filter((id) =>
|
||||
id.startsWith(`${parentId}.`)
|
||||
);
|
||||
const parentSubtasks = existingSubtasks.filter((id) => id.startsWith(`${parentId}.`));
|
||||
|
||||
// Extract sequential numbers and find the highest
|
||||
const sequentialNumbers = parentSubtasks
|
||||
.map((id) => {
|
||||
const parts = id.split('.');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
return parseInt(lastPart, 10);
|
||||
return Number.parseInt(lastPart, 10);
|
||||
})
|
||||
.filter((num) => !isNaN(num))
|
||||
.filter((num) => !Number.isNaN(num))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
// Determine the next sequential number
|
||||
const nextSequential =
|
||||
sequentialNumbers.length > 0 ? Math.max(...sequentialNumbers) + 1 : 1;
|
||||
const nextSequential = sequentialNumbers.length > 0 ? Math.max(...sequentialNumbers) + 1 : 1;
|
||||
|
||||
return `${parentId}.${nextSequential}`;
|
||||
}
|
||||
@@ -118,8 +112,8 @@ export function isValidSubtaskId(id: string): boolean {
|
||||
// Remaining parts should be positive integers
|
||||
const sequentialParts = parts.slice(1);
|
||||
return sequentialParts.every((part) => {
|
||||
const num = parseInt(part, 10);
|
||||
return !isNaN(num) && num > 0 && part === num.toString();
|
||||
const num = Number.parseInt(part, 10);
|
||||
return !Number.isNaN(num) && num > 0 && part === num.toString();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
412
packages/tm-core/tests/integration/list-tasks.test.ts
Normal file
412
packages/tm-core/tests/integration/list-tasks.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @fileoverview End-to-end integration test for listTasks functionality
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type Task,
|
||||
type TaskMasterCore,
|
||||
type TaskStatus,
|
||||
createTaskMasterCore
|
||||
} from '../../src/index';
|
||||
|
||||
describe('TaskMasterCore - listTasks E2E', () => {
|
||||
let tmpDir: string;
|
||||
let tmCore: TaskMasterCore;
|
||||
|
||||
// Sample tasks data
|
||||
const sampleTasks: Task[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Setup project',
|
||||
description: 'Initialize the project structure',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Create all necessary directories and config files',
|
||||
testStrategy: 'Manual verification',
|
||||
subtasks: [
|
||||
{
|
||||
id: 1,
|
||||
parentId: '1',
|
||||
title: 'Create directories',
|
||||
description: 'Create project directories',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Create src, tests, docs directories',
|
||||
testStrategy: 'Check directories exist'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
parentId: '1',
|
||||
title: 'Initialize package.json',
|
||||
description: 'Create package.json file',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Run npm init',
|
||||
testStrategy: 'Verify package.json exists'
|
||||
}
|
||||
],
|
||||
tags: ['setup', 'infrastructure']
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Implement core features',
|
||||
description: 'Build the main functionality',
|
||||
status: 'in-progress',
|
||||
priority: 'high',
|
||||
dependencies: ['1'],
|
||||
details: 'Implement all core business logic',
|
||||
testStrategy: 'Unit tests for all features',
|
||||
subtasks: [],
|
||||
tags: ['feature', 'core'],
|
||||
assignee: 'developer1'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Write documentation',
|
||||
description: 'Create user and developer docs',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: ['2'],
|
||||
details: 'Write comprehensive documentation',
|
||||
testStrategy: 'Review by team',
|
||||
subtasks: [],
|
||||
tags: ['documentation'],
|
||||
complexity: 'simple'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Performance optimization',
|
||||
description: 'Optimize for speed and efficiency',
|
||||
status: 'blocked',
|
||||
priority: 'low',
|
||||
dependencies: ['2'],
|
||||
details: 'Profile and optimize bottlenecks',
|
||||
testStrategy: 'Performance benchmarks',
|
||||
subtasks: [],
|
||||
assignee: 'developer2',
|
||||
complexity: 'complex'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Security audit',
|
||||
description: 'Review security vulnerabilities',
|
||||
status: 'deferred',
|
||||
priority: 'critical',
|
||||
dependencies: [],
|
||||
details: 'Complete security assessment',
|
||||
testStrategy: 'Security scanning tools',
|
||||
subtasks: [],
|
||||
tags: ['security', 'audit']
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory for testing
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-core-test-'));
|
||||
|
||||
// Create .taskmaster/tasks directory
|
||||
const tasksDir = path.join(tmpDir, '.taskmaster', 'tasks');
|
||||
await fs.mkdir(tasksDir, { recursive: true });
|
||||
|
||||
// Write sample tasks.json
|
||||
const tasksFile = path.join(tasksDir, 'tasks.json');
|
||||
const tasksData = {
|
||||
tasks: sampleTasks,
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: sampleTasks.length,
|
||||
completedCount: 1
|
||||
}
|
||||
};
|
||||
await fs.writeFile(tasksFile, JSON.stringify(tasksData, null, 2));
|
||||
|
||||
// Create TaskMasterCore instance
|
||||
tmCore = createTaskMasterCore(tmpDir);
|
||||
await tmCore.initialize();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Cleanup
|
||||
if (tmCore) {
|
||||
await tmCore.close();
|
||||
}
|
||||
|
||||
// Remove temp directory
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('Basic listing', () => {
|
||||
it('should list all tasks', async () => {
|
||||
const result = await tmCore.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(5);
|
||||
expect(result.total).toBe(5);
|
||||
expect(result.filtered).toBe(5);
|
||||
expect(result.tag).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include subtasks by default', async () => {
|
||||
const result = await tmCore.listTasks();
|
||||
const setupTask = result.tasks.find((t) => t.id === '1');
|
||||
|
||||
expect(setupTask?.subtasks).toHaveLength(2);
|
||||
expect(setupTask?.subtasks[0].title).toBe('Create directories');
|
||||
});
|
||||
|
||||
it('should exclude subtasks when requested', async () => {
|
||||
const result = await tmCore.listTasks({ includeSubtasks: false });
|
||||
const setupTask = result.tasks.find((t) => t.id === '1');
|
||||
|
||||
expect(setupTask?.subtasks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('should filter by status', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { status: 'done' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should filter by multiple statuses', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { status: ['done', 'in-progress'] }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(2);
|
||||
const ids = result.tasks.map((t) => t.id);
|
||||
expect(ids).toContain('1');
|
||||
expect(ids).toContain('2');
|
||||
});
|
||||
|
||||
it('should filter by priority', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { priority: 'high' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by tags', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { tags: ['setup'] }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should filter by assignee', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { assignee: 'developer1' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('should filter by complexity', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { complexity: 'complex' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('4');
|
||||
});
|
||||
|
||||
it('should filter by search term', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: { search: 'documentation' }
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should filter by hasSubtasks', async () => {
|
||||
const withSubtasks = await tmCore.listTasks({
|
||||
filter: { hasSubtasks: true }
|
||||
});
|
||||
|
||||
expect(withSubtasks.filtered).toBe(1);
|
||||
expect(withSubtasks.tasks[0].id).toBe('1');
|
||||
|
||||
const withoutSubtasks = await tmCore.listTasks({
|
||||
filter: { hasSubtasks: false }
|
||||
});
|
||||
|
||||
expect(withoutSubtasks.filtered).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle combined filters', async () => {
|
||||
const result = await tmCore.listTasks({
|
||||
filter: {
|
||||
priority: ['high', 'critical'],
|
||||
status: ['pending', 'deferred']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.filtered).toBe(1);
|
||||
expect(result.tasks[0].id).toBe('5'); // Critical priority, deferred status
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper methods', () => {
|
||||
it('should get task by ID', async () => {
|
||||
const task = await tmCore.getTask('2');
|
||||
|
||||
expect(task).not.toBeNull();
|
||||
expect(task?.title).toBe('Implement core features');
|
||||
});
|
||||
|
||||
it('should return null for non-existent task', async () => {
|
||||
const task = await tmCore.getTask('999');
|
||||
|
||||
expect(task).toBeNull();
|
||||
});
|
||||
|
||||
it('should get tasks by status', async () => {
|
||||
const pendingTasks = await tmCore.getTasksByStatus('pending');
|
||||
|
||||
expect(pendingTasks).toHaveLength(1);
|
||||
expect(pendingTasks[0].id).toBe('3');
|
||||
|
||||
const multipleTasks = await tmCore.getTasksByStatus(['done', 'blocked']);
|
||||
|
||||
expect(multipleTasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should get task statistics', async () => {
|
||||
const stats = await tmCore.getTaskStats();
|
||||
|
||||
expect(stats.total).toBe(5);
|
||||
expect(stats.byStatus.done).toBe(1);
|
||||
expect(stats.byStatus['in-progress']).toBe(1);
|
||||
expect(stats.byStatus.pending).toBe(1);
|
||||
expect(stats.byStatus.blocked).toBe(1);
|
||||
expect(stats.byStatus.deferred).toBe(1);
|
||||
expect(stats.byStatus.cancelled).toBe(0);
|
||||
expect(stats.byStatus.review).toBe(0);
|
||||
expect(stats.withSubtasks).toBe(1);
|
||||
expect(stats.blocked).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle missing tasks file gracefully', async () => {
|
||||
// Create new instance with empty directory
|
||||
const emptyDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-empty-'));
|
||||
const emptyCore = createTaskMasterCore(emptyDir);
|
||||
|
||||
try {
|
||||
const result = await emptyCore.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
expect(result.filtered).toBe(0);
|
||||
} finally {
|
||||
await emptyCore.close();
|
||||
await fs.rm(emptyDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate task entities', async () => {
|
||||
// Write invalid task data
|
||||
const invalidDir = await fs.mkdtemp(path.join(os.tmpdir(), 'tm-invalid-'));
|
||||
const tasksDir = path.join(invalidDir, '.taskmaster', 'tasks');
|
||||
await fs.mkdir(tasksDir, { recursive: true });
|
||||
|
||||
const invalidData = {
|
||||
tasks: [
|
||||
{
|
||||
id: '', // Invalid: empty ID
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
status: 'done',
|
||||
priority: 'high',
|
||||
dependencies: [],
|
||||
details: 'Test',
|
||||
testStrategy: 'Test',
|
||||
subtasks: []
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: 1,
|
||||
completedCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
await fs.writeFile(path.join(tasksDir, 'tasks.json'), JSON.stringify(invalidData));
|
||||
|
||||
const invalidCore = createTaskMasterCore(invalidDir);
|
||||
|
||||
try {
|
||||
await expect(invalidCore.listTasks()).rejects.toThrow();
|
||||
} finally {
|
||||
await invalidCore.close();
|
||||
await fs.rm(invalidDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags support', () => {
|
||||
beforeEach(async () => {
|
||||
// Create tasks for a different tag
|
||||
const taggedTasks = [
|
||||
{
|
||||
id: 'tag-1',
|
||||
title: 'Tagged task',
|
||||
description: 'Task with tag',
|
||||
status: 'pending' as TaskStatus,
|
||||
priority: 'medium' as const,
|
||||
dependencies: [],
|
||||
details: 'Tagged task details',
|
||||
testStrategy: 'Test',
|
||||
subtasks: []
|
||||
}
|
||||
];
|
||||
|
||||
const tagFile = path.join(tmpDir, '.taskmaster', 'tasks', 'feature-branch.json');
|
||||
await fs.writeFile(
|
||||
tagFile,
|
||||
JSON.stringify({
|
||||
tasks: taggedTasks,
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
lastModified: new Date().toISOString(),
|
||||
taskCount: 1,
|
||||
completedCount: 0
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should list tasks for specific tag', async () => {
|
||||
const result = await tmCore.listTasks({ tag: 'feature-branch' });
|
||||
|
||||
expect(result.tasks).toHaveLength(1);
|
||||
expect(result.tasks[0].id).toBe('tag-1');
|
||||
expect(result.tag).toBe('feature-branch');
|
||||
});
|
||||
|
||||
it('should list default tasks when no tag specified', async () => {
|
||||
const result = await tmCore.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(5);
|
||||
expect(result.tasks[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
205
packages/tm-core/tests/mocks/mock-provider.ts
Normal file
205
packages/tm-core/tests/mocks/mock-provider.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @fileoverview Mock provider for testing BaseProvider functionality
|
||||
*/
|
||||
|
||||
import type {
|
||||
AIModel,
|
||||
AIOptions,
|
||||
AIResponse,
|
||||
ProviderInfo,
|
||||
ProviderUsageStats
|
||||
} from '../../src/interfaces/ai-provider.interface';
|
||||
import {
|
||||
BaseProvider,
|
||||
type BaseProviderConfig,
|
||||
type CompletionResult
|
||||
} from '../../src/providers/ai/base-provider';
|
||||
|
||||
/**
|
||||
* Configuration for MockProvider behavior
|
||||
*/
|
||||
export interface MockProviderOptions extends BaseProviderConfig {
|
||||
shouldFail?: boolean;
|
||||
failAfterAttempts?: number;
|
||||
simulateRateLimit?: boolean;
|
||||
simulateTimeout?: boolean;
|
||||
responseDelay?: number;
|
||||
tokenMultiplier?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock provider for testing BaseProvider functionality
|
||||
*/
|
||||
export class MockProvider extends BaseProvider {
|
||||
private attemptCount = 0;
|
||||
private readonly options: MockProviderOptions;
|
||||
|
||||
constructor(options: MockProviderOptions) {
|
||||
super(options);
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate completion generation with configurable behavior
|
||||
*/
|
||||
protected async generateCompletionInternal(
|
||||
prompt: string,
|
||||
_options?: AIOptions
|
||||
): Promise<CompletionResult> {
|
||||
this.attemptCount++;
|
||||
|
||||
// Simulate delay if configured
|
||||
if (this.options.responseDelay) {
|
||||
await this.sleep(this.options.responseDelay);
|
||||
}
|
||||
|
||||
// Simulate failures based on configuration
|
||||
if (this.options.shouldFail) {
|
||||
throw new Error('Mock provider error');
|
||||
}
|
||||
|
||||
if (this.options.failAfterAttempts && this.attemptCount <= this.options.failAfterAttempts) {
|
||||
if (this.options.simulateRateLimit) {
|
||||
throw new Error('Rate limit exceeded - too many requests (429)');
|
||||
}
|
||||
if (this.options.simulateTimeout) {
|
||||
throw new Error('Request timeout - ECONNRESET');
|
||||
}
|
||||
throw new Error('Temporary failure');
|
||||
}
|
||||
|
||||
// Return successful mock response
|
||||
return {
|
||||
content: `Mock response to: ${prompt}`,
|
||||
inputTokens: this.calculateTokens(prompt),
|
||||
outputTokens: this.calculateTokens(`Mock response to: ${prompt}`),
|
||||
finishReason: 'complete',
|
||||
model: this.model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple token calculation for testing
|
||||
*/
|
||||
calculateTokens(text: string, _model?: string): number {
|
||||
const multiplier = this.options.tokenMultiplier || 1;
|
||||
// Rough approximation: 1 token per 4 characters
|
||||
return Math.ceil((text.length / 4) * multiplier);
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
return 'mock-model-v1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of attempts made
|
||||
*/
|
||||
getAttemptCount(): number {
|
||||
return this.attemptCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset attempt counter
|
||||
*/
|
||||
resetAttempts(): void {
|
||||
this.attemptCount = 0;
|
||||
}
|
||||
|
||||
// Implement remaining abstract methods
|
||||
async generateStreamingCompletion(
|
||||
prompt: string,
|
||||
_options?: AIOptions
|
||||
): AsyncIterator<Partial<AIResponse>> {
|
||||
// Simple mock implementation
|
||||
const response: Partial<AIResponse> = {
|
||||
content: `Mock streaming response to: ${prompt}`,
|
||||
provider: this.getName(),
|
||||
model: this.model
|
||||
};
|
||||
|
||||
return {
|
||||
async next() {
|
||||
return { value: response, done: true };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return !this.options.shouldFail;
|
||||
}
|
||||
|
||||
getProviderInfo(): ProviderInfo {
|
||||
return {
|
||||
name: 'mock',
|
||||
displayName: 'Mock Provider',
|
||||
description: 'Mock provider for testing',
|
||||
models: this.getAvailableModels(),
|
||||
defaultModel: this.getDefaultModel(),
|
||||
requiresApiKey: true,
|
||||
features: {
|
||||
streaming: true,
|
||||
functions: false,
|
||||
vision: false,
|
||||
embeddings: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableModels(): AIModel[] {
|
||||
return [
|
||||
{
|
||||
id: 'mock-model-v1',
|
||||
name: 'Mock Model v1',
|
||||
description: 'First mock model',
|
||||
contextLength: 4096,
|
||||
inputCostPer1K: 0.001,
|
||||
outputCostPer1K: 0.002,
|
||||
supportsStreaming: true
|
||||
},
|
||||
{
|
||||
id: 'mock-model-v2',
|
||||
name: 'Mock Model v2',
|
||||
description: 'Second mock model',
|
||||
contextLength: 8192,
|
||||
inputCostPer1K: 0.002,
|
||||
outputCostPer1K: 0.004,
|
||||
supportsStreaming: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async validateCredentials(): Promise<boolean> {
|
||||
return this.apiKey === 'valid-key';
|
||||
}
|
||||
|
||||
async getUsageStats(): Promise<ProviderUsageStats | null> {
|
||||
return {
|
||||
totalRequests: this.attemptCount,
|
||||
totalTokens: 1000,
|
||||
totalCost: 0.01,
|
||||
requestsToday: this.attemptCount,
|
||||
tokensToday: 1000,
|
||||
costToday: 0.01,
|
||||
averageResponseTime: 100,
|
||||
successRate: 0.9,
|
||||
lastRequestAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// No-op for mock
|
||||
}
|
||||
|
||||
// Override retry configuration for testing
|
||||
protected getMaxRetries(): number {
|
||||
return this.options.failAfterAttempts ? this.options.failAfterAttempts + 1 : 3;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
/**
|
||||
* Jest setup file for tm-core package
|
||||
* This file is executed before running tests and can be used to configure
|
||||
* testing utilities, global mocks, and test environment setup.
|
||||
* @fileoverview Vitest test setup file
|
||||
*/
|
||||
|
||||
// Configure test environment
|
||||
process.env.NODE_ENV = 'test';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
|
||||
// Global test utilities can be added here
|
||||
// Custom matchers and global types can be defined here in the future
|
||||
// Setup any global test configuration here
|
||||
// For example, increase timeout for slow CI environments
|
||||
if (process.env.CI) {
|
||||
// Vitest timeout is configured in vitest.config.ts
|
||||
}
|
||||
|
||||
// Set up any global mocks or configurations here
|
||||
beforeEach(() => {
|
||||
// Reset any global state before each test
|
||||
jest.clearAllMocks();
|
||||
// Suppress console errors during tests unless explicitly testing them
|
||||
const originalError = console.error;
|
||||
beforeAll(() => {
|
||||
console.error = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
jest.restoreAllMocks();
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
258
packages/tm-core/tests/unit/base-provider.test.ts
Normal file
258
packages/tm-core/tests/unit/base-provider.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for BaseProvider abstract class
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ERROR_CODES, TaskMasterError } from '../../src/errors/task-master-error';
|
||||
import { MockProvider } from '../mocks/mock-provider';
|
||||
|
||||
describe('BaseProvider', () => {
|
||||
describe('constructor', () => {
|
||||
it('should require an API key', () => {
|
||||
expect(() => {
|
||||
new MockProvider({ apiKey: '' });
|
||||
}).toThrow(TaskMasterError);
|
||||
});
|
||||
|
||||
it('should initialize with provided API key and model', () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
model: 'mock-model-v2'
|
||||
});
|
||||
|
||||
expect(provider.getModel()).toBe('mock-model-v2');
|
||||
});
|
||||
|
||||
it('should use default model if not provided', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
expect(provider.getModel()).toBe('mock-model-v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCompletion', () => {
|
||||
let provider: MockProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new MockProvider({ apiKey: 'test-key' });
|
||||
});
|
||||
|
||||
it('should successfully generate a completion', async () => {
|
||||
const response = await provider.generateCompletion('Test prompt');
|
||||
|
||||
expect(response).toMatchObject({
|
||||
content: 'Mock response to: Test prompt',
|
||||
provider: 'mock',
|
||||
model: 'mock-model-v1',
|
||||
inputTokens: expect.any(Number),
|
||||
outputTokens: expect.any(Number),
|
||||
totalTokens: expect.any(Number),
|
||||
duration: expect.any(Number),
|
||||
timestamp: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate empty prompts', async () => {
|
||||
await expect(provider.generateCompletion('')).rejects.toThrow(
|
||||
'Prompt must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate prompt type', async () => {
|
||||
await expect(provider.generateCompletion(null as any)).rejects.toThrow(
|
||||
'Prompt must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate temperature range', async () => {
|
||||
await expect(provider.generateCompletion('Test', { temperature: 3 })).rejects.toThrow(
|
||||
'Temperature must be between 0 and 2'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate maxTokens range', async () => {
|
||||
await expect(provider.generateCompletion('Test', { maxTokens: 0 })).rejects.toThrow(
|
||||
'Max tokens must be between 1 and 100000'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate topP range', async () => {
|
||||
await expect(provider.generateCompletion('Test', { topP: 1.5 })).rejects.toThrow(
|
||||
'Top-p must be between 0 and 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry logic', () => {
|
||||
it('should retry on rate limit errors', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
failAfterAttempts: 2,
|
||||
simulateRateLimit: true,
|
||||
responseDelay: 10
|
||||
});
|
||||
|
||||
const response = await provider.generateCompletion('Test prompt');
|
||||
|
||||
expect(response.content).toBe('Mock response to: Test prompt');
|
||||
expect(provider.getAttemptCount()).toBe(3); // 2 failures + 1 success
|
||||
});
|
||||
|
||||
it('should retry on timeout errors', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
failAfterAttempts: 1,
|
||||
simulateTimeout: true
|
||||
});
|
||||
|
||||
const response = await provider.generateCompletion('Test prompt');
|
||||
|
||||
expect(response.content).toBe('Mock response to: Test prompt');
|
||||
expect(provider.getAttemptCount()).toBe(2); // 1 failure + 1 success
|
||||
});
|
||||
|
||||
it('should fail after max retries', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
shouldFail: true
|
||||
});
|
||||
|
||||
await expect(provider.generateCompletion('Test prompt')).rejects.toThrow(
|
||||
'mock provider error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should calculate exponential backoff delays', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
|
||||
// Access protected method through type assertion
|
||||
const calculateDelay = (provider as any).calculateBackoffDelay.bind(provider);
|
||||
|
||||
const delay1 = calculateDelay(1);
|
||||
const delay2 = calculateDelay(2);
|
||||
const delay3 = calculateDelay(3);
|
||||
|
||||
// Check exponential growth (with jitter, so use ranges)
|
||||
expect(delay1).toBeGreaterThanOrEqual(900);
|
||||
expect(delay1).toBeLessThanOrEqual(1100);
|
||||
|
||||
expect(delay2).toBeGreaterThanOrEqual(1800);
|
||||
expect(delay2).toBeLessThanOrEqual(2200);
|
||||
|
||||
expect(delay3).toBeGreaterThanOrEqual(3600);
|
||||
expect(delay3).toBeLessThanOrEqual(4400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should wrap provider errors properly', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
shouldFail: true
|
||||
});
|
||||
|
||||
try {
|
||||
await provider.generateCompletion('Test prompt');
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(TaskMasterError);
|
||||
const tmError = error as TaskMasterError;
|
||||
expect(tmError.code).toBe(ERROR_CODES.PROVIDER_ERROR);
|
||||
expect(tmError.context.operation).toBe('generateCompletion');
|
||||
expect(tmError.context.resource).toBe('mock');
|
||||
}
|
||||
});
|
||||
|
||||
it('should identify rate limit errors correctly', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const isRateLimitError = (provider as any).isRateLimitError.bind(provider);
|
||||
|
||||
expect(isRateLimitError(new Error('Rate limit exceeded'))).toBe(true);
|
||||
expect(isRateLimitError(new Error('Too many requests'))).toBe(true);
|
||||
expect(isRateLimitError(new Error('Status: 429'))).toBe(true);
|
||||
expect(isRateLimitError(new Error('Some other error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify timeout errors correctly', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const isTimeoutError = (provider as any).isTimeoutError.bind(provider);
|
||||
|
||||
expect(isTimeoutError(new Error('Request timeout'))).toBe(true);
|
||||
expect(isTimeoutError(new Error('Operation timed out'))).toBe(true);
|
||||
expect(isTimeoutError(new Error('ECONNRESET'))).toBe(true);
|
||||
expect(isTimeoutError(new Error('Some other error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify network errors correctly', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const isNetworkError = (provider as any).isNetworkError.bind(provider);
|
||||
|
||||
expect(isNetworkError(new Error('Network error'))).toBe(true);
|
||||
expect(isNetworkError(new Error('ENOTFOUND'))).toBe(true);
|
||||
expect(isNetworkError(new Error('ECONNREFUSED'))).toBe(true);
|
||||
expect(isNetworkError(new Error('Some other error'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model management', () => {
|
||||
it('should get and set model', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
|
||||
expect(provider.getModel()).toBe('mock-model-v1');
|
||||
|
||||
provider.setModel('mock-model-v2');
|
||||
expect(provider.getModel()).toBe('mock-model-v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider information', () => {
|
||||
it('should return provider info', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const info = provider.getProviderInfo();
|
||||
|
||||
expect(info.name).toBe('mock');
|
||||
expect(info.displayName).toBe('Mock Provider');
|
||||
expect(info.requiresApiKey).toBe(true);
|
||||
expect(info.models).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return available models', () => {
|
||||
const provider = new MockProvider({ apiKey: 'test-key' });
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0].id).toBe('mock-model-v1');
|
||||
expect(models[1].id).toBe('mock-model-v2');
|
||||
});
|
||||
|
||||
it('should validate credentials', async () => {
|
||||
const validProvider = new MockProvider({ apiKey: 'valid-key' });
|
||||
const invalidProvider = new MockProvider({ apiKey: 'invalid-key' });
|
||||
|
||||
expect(await validProvider.validateCredentials()).toBe(true);
|
||||
expect(await invalidProvider.validateCredentials()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('template method pattern', () => {
|
||||
it('should follow the template method flow', async () => {
|
||||
const provider = new MockProvider({
|
||||
apiKey: 'test-key',
|
||||
responseDelay: 50
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
const response = await provider.generateCompletion('Test prompt', {
|
||||
temperature: 0.5,
|
||||
maxTokens: 100
|
||||
});
|
||||
const endTime = Date.now();
|
||||
|
||||
// Verify the response was processed through the template
|
||||
expect(response.content).toBeDefined();
|
||||
expect(response.duration).toBeGreaterThanOrEqual(50);
|
||||
expect(response.duration).toBeLessThanOrEqual(endTime - startTime + 10);
|
||||
expect(response.timestamp).toBeDefined();
|
||||
expect(response.provider).toBe('mock');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,26 +3,21 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
generateTaskId,
|
||||
isValidTaskId,
|
||||
formatDate,
|
||||
PlaceholderParser,
|
||||
PlaceholderProvider,
|
||||
PlaceholderStorage,
|
||||
PlaceholderParser,
|
||||
TmCoreError,
|
||||
TaskNotFoundError,
|
||||
ValidationError,
|
||||
StorageError,
|
||||
version,
|
||||
name
|
||||
TaskNotFoundError,
|
||||
TmCoreError,
|
||||
ValidationError,
|
||||
formatDate,
|
||||
generateTaskId,
|
||||
isValidTaskId,
|
||||
name,
|
||||
version
|
||||
} from '@/index';
|
||||
|
||||
import type {
|
||||
TaskId,
|
||||
TaskStatus,
|
||||
TaskPriority,
|
||||
PlaceholderTask
|
||||
} from '@/types/index';
|
||||
import type { PlaceholderTask, TaskId, TaskPriority, TaskStatus } from '@/types/index';
|
||||
|
||||
describe('tm-core smoke tests', () => {
|
||||
describe('package metadata', () => {
|
||||
|
||||
46
packages/tm-core/vitest.config.ts
Normal file
46
packages/tm-core/vitest.config.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts', 'tests/**/*.spec.ts', 'src/**/*.test.ts', 'src/**/*.spec.ts'],
|
||||
exclude: ['node_modules', 'dist', '.git', '.cache'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'tests',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
'**/*.d.ts',
|
||||
'**/index.ts'
|
||||
],
|
||||
thresholds: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80
|
||||
}
|
||||
},
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
testTimeout: 10000,
|
||||
clearMocks: true,
|
||||
restoreMocks: true,
|
||||
mockReset: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@/types': path.resolve(__dirname, './src/types'),
|
||||
'@/providers': path.resolve(__dirname, './src/providers'),
|
||||
'@/storage': path.resolve(__dirname, './src/storage'),
|
||||
'@/parser': path.resolve(__dirname, './src/parser'),
|
||||
'@/utils': path.resolve(__dirname, './src/utils'),
|
||||
'@/errors': path.resolve(__dirname, './src/errors')
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user