diff --git a/.cursor/rules/errors.mdc b/.cursor/rules/errors.mdc new file mode 100644 index 0000000..ceacf7c --- /dev/null +++ b/.cursor/rules/errors.mdc @@ -0,0 +1,57 @@ +--- +description: +globs: **/errors.ts +alwaysApply: false +--- +# Error Flow + +```mermaid +graph TD + subgraph Core_Logic + FS[FileSystemService: e.g., FileReadError] --> TM[TaskManager: Throws App Errors, e.g., ProjectNotFound, TaskNotDone] + TM -->|App Error with code ERR_xxxx| CLI_Handler["cli.ts Command Handler"] + TM -->|App Error with code ERR_xxxx| ToolExec["toolExecutors.ts: execute"] + end + + subgraph CLI_Path + CLI_Handler -->|App Error| CLI_Catch["cli.ts catch block"] + CLI_Catch -->|Error Object| FormatCLI["client errors.ts formatCliError"] + FormatCLI -->|"Error [ERR_xxxx]: message"| ConsoleOut["console.error Output"] + end + + subgraph MCP_Server_Path + subgraph Validation_Layer + ToolExecVal["toolExecutors.ts Validation"] -->|App Error, e.g., MissingParameter| ExecToolErrHandler + end + + subgraph App_Execution + ToolExec -->|App Error with code ERR_xxxx| ExecToolErrHandler["tools.ts executeToolAndHandleErrors catch block"] + ExecToolErrHandler -->|Map AppError to Protocol Error or Tool Result| ErrorMapping + ErrorMapping -->|"If validation error (ERR_1xxx)"| McpError["Create McpError with appropriate ErrorCode"] + ErrorMapping -->|"If business logic error (ERR_2xxx+)"| FormatResult["Format as isError true result"] + + McpError -->|Throw| SDKHandler["server index.ts SDK Handler"] + FormatResult -->|"{ content: [{ text: Error [ERR_xxxx]: message }], isError: true }"| SDKHandler + end + + SDKHandler -- Protocol Error --> SDKFormatError["SDK Formats as JSON-RPC Error Response"] + SDKHandler -- Tool Result --> SDKFormatResult["SDK Formats as JSON-RPC Success Response"] + + SDKFormatError -->|"{ error: { code: -326xx, message: ... } }"| MCPClient["MCP Client"] + SDKFormatResult -->|"{ result: { content: [...], isError: true } }"| MCPClient + end +``` + +**Explanation of Updated Error Flow and Transformations:** + +Errors are consistently through a unified `AppError` system: + +1. **Validation Errors** (`ERR_1xxx` series) + - Used for validation issues (e.g., MissingParameter, InvalidArgument) + - Thrown by tool executors during parameter validation + - Mapped to protocol-level McpErrors in `executeToolAndHandleErrors` + +2. **Business Logic Errors** (`ERR_2xxx` and higher) + - Used for all business logic and application-specific errors + - Include specific error codes + - Returned as serialized CallToolResults with `isError: true` \ No newline at end of file diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc new file mode 100644 index 0000000..b272aba --- /dev/null +++ b/.cursor/rules/general.mdc @@ -0,0 +1,6 @@ +--- +description: +globs: +alwaysApply: true +--- +Work step-by-step. If presented with an implementation plan, implement the plan exactly. If the plan presents more than one implementation option, consult with the human user to decide between options. If you are tempted to embellish or imporve upon the plan, consult with the human user. Always complete the current task and wait for human review before proceeding to the next task. \ No newline at end of file diff --git a/.cursor/rules/tests.mdc b/.cursor/rules/tests.mdc index 8977081..8f39314 100644 --- a/.cursor/rules/tests.mdc +++ b/.cursor/rules/tests.mdc @@ -3,197 +3,4 @@ description: Writing unit tests with `jest` globs: tests/**/* alwaysApply: false --- -# Testing Guidelines for TypeScript + ES Modules + Jest - -This guide contains cumulative in-context learnings about working with this project's testing stack. - -## Unit vs. Integration Tests - -**Never Mix Test Types**: Separate integration tests from unit tests into different files: - - Simple unit tests without mocks for validating rules (like state transitions) - - Integration tests with mocks for filesystem and external dependencies - -## File Path Handling in Tests - -1. **Environment Variables**: - - Use `process.env.TASK_MANAGER_FILE_PATH` for configuring file paths in tests - - Set this in `beforeEach` and clean up in `afterEach`: - ```typescript - beforeEach(async () => { - tempDir = path.join(os.tmpdir(), `test-${Date.now()}`); - await fs.mkdir(tempDir, { recursive: true }); - tasksFilePath = path.join(tempDir, "test-tasks.json"); - process.env.TASK_MANAGER_FILE_PATH = tasksFilePath; - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - delete process.env.TASK_MANAGER_FILE_PATH; - }); - ``` - -2. **Temporary Files**: - - Create unique temp directories for each test run - - Use `os.tmpdir()` for platform-independent temp directories - - Include timestamps in directory names to prevent conflicts - - Always clean up temp files in `afterEach` - -## Jest ESM Mocking, Step-by-Step - -1. **Type-Only Import:** - Import types for static analysis without actually executing the module code: - ```typescript - import type { MyService as MyServiceType } from 'path/to/MyService.js'; - import type { readFile as ReadFileType } from 'node:fs/promises'; - ``` - -2. **Register Mock:** - Use `jest.unstable_mockModule` to replace the real module: - ```typescript - jest.unstable_mockModule('node:fs/promises', () => ({ - __esModule: true, - readFile: jest.fn(), - })); - ``` - -3. **Set Default Mock Implementations, Then Dynamically Import Modules:** - You must dynamically import the modules to be mocked and/or tested *after* registering mocks and setting any mock implementations. This ensures that when `MyService` attempts to import `node:fs/promises`, it gets your mocked version. Depending how you want to scope your mock implementations, you can do this in `beforeAll`, `beforeEach`, or at the top of each test. - ```typescript - let MyService: typeof MyServiceType; - let readFile: jest.MockedFunction; - - beforeAll(async () => { - const fsPromisesMock = await import('node:fs/promises'); - readFile = fsPromisesMock.readFile as jest.MockedFunction; - - // Set default implementation - readFile.mockResolvedValue('default mocked content'); - - const serviceModule = await import('path/to/MyService.js'); - MyService = serviceModule.MyService; - }); - ``` - -4. **Setup in `beforeEach`:** - Reset mocks and set default behaviors before each test: - ```typescript - beforeEach(() => { - jest.clearAllMocks(); - readFile.mockResolvedValue(''); - }); - ``` - -5. **Write a Test:** - Now you can test your service with the mocked `readFile`: - ```typescript - describe('MyService', () => { - let myServiceInstance: MyServiceType; - - beforeEach(() => { - myServiceInstance = new MyService('somePath'); - }); - - it('should do something', async () => { - readFile.mockResolvedValueOnce('some data'); - const result = await myServiceInstance.someMethod(); - expect(result).toBe('expected result'); - expect(readFile).toHaveBeenCalledWith('somePath', 'utf-8'); - }); - }); - ``` - -### Mocking a Class with Methods - -If you have a class `MyClass` that has both instance methods and static methods, you can mock it in an **ES Modules + TypeScript** setup using the same pattern. For instance: - -```typescript -// 1. Create typed jest mock functions using the original types -type InitResult = { data: string }; - -const mockInit = jest.fn() as jest.MockedFunction; -const mockDoWork = jest.fn() as jest.MockedFunction; -const mockStaticHelper = jest.fn() as jest.MockedFunction; - -// 2. Use jest.unstable_mockModule with an ES6 class in the factory -jest.unstable_mockModule('path/to/MyClass.js', () => { - class MockMyClass { - // Instance methods - init = mockInit; - doWork = mockDoWork; - - // Static method - static staticHelper = mockStaticHelper; - } - - return { - __esModule: true, - MyClass: MockMyClass, // same name/structure as real export - }; -}); - -// 3. Import your class after mocking -let MyClass: typeof import('path/to/MyClass.js')['MyClass']; - -beforeAll(async () => { - const myClassModule = await import('path/to/MyClass.js'); - MyClass = myClassModule.MyClass; -}); - -// 4. Write tests and reset mocks -beforeEach(() => { - jest.clearAllMocks(); - mockInit.mockResolvedValue({ data: 'default' }); - mockStaticHelper.mockReturnValue(42); -}); - -describe('MyClass', () => { - it('should call init', async () => { - const instance = new MyClass(); - const result = await instance.init(); - expect(result).toEqual({ data: 'default' }); - expect(mockInit).toHaveBeenCalledTimes(1); - }); - - it('should call the static helper', () => { - const val = MyClass.staticHelper(); - expect(val).toBe(42); - expect(mockStaticHelper).toHaveBeenCalledTimes(1); - }); -}); -``` - -### Best Practice: **Type** Your Mocked Functions - -By default, `jest.fn()` is very generic and doesn't enforce parameter or return types. This can cause TypeScript errors like: - -> `Argument of type 'undefined' is not assignable to parameter of type 'never'` - -or - -> `Type 'Promise' is not assignable to type 'FunctionLike'` - -To avoid these, **use the original type with `jest.MockedFunction`**. For example, if your real function is: - -```typescript -async function loadStuff(id: string): Promise { - // ... -} -``` - -then you should type the mock as: - -```typescript -const mockLoadStuff = jest.fn() as jest.MockedFunction; -``` - -For class methods, use the class type to get the method signature: - -```typescript -const mockClassMethod = jest.fn() as jest.MockedFunction; -``` - -This helps TypeScript catch mistakes if you: -- call the function with the wrong argument types -- use `mockResolvedValue` with the wrong shape - -Once typed properly, your `mockResolvedValue(...)`, `mockImplementation(...)`, etc. calls will be fully type-safe. +Make use of the helpers in tests/mcp/test-helpers.ts. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 2069499..075b2e2 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -13,7 +13,6 @@ jobs: node-version: 'latest' - run: npm ci - run: npm install -g tsx - - run: npm run build - run: npm test publish: diff --git a/README.md b/README.md index 9175aae..c751d63 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,14 @@ This will show the available commands and options. The task manager supports multiple LLM providers for generating project plans. You can configure one or more of the following environment variables depending on which providers you want to use: - `OPENAI_API_KEY`: Required for using OpenAI models (e.g., GPT-4) -- `GEMINI_API_KEY`: Required for using Google's Gemini models +- `GOOGLE_GENERATIVE_AI_API_KEY`: Required for using Google's Gemini models - `DEEPSEEK_API_KEY`: Required for using Deepseek models To generate project plans using the CLI, set these environment variables in your shell: ```bash export OPENAI_API_KEY="your-api-key" -export GEMINI_API_KEY="your-api-key" +export GOOGLE_GENERATIVE_AI_API_KEY="your-api-key" export DEEPSEEK_API_KEY="your-api-key" ``` @@ -61,7 +61,7 @@ Or you can include them in your MCP client configuration to generate project pla "args": ["-y", "taskqueue-mcp"], "env": { "OPENAI_API_KEY": "your-api-key", - "GEMINI_API_KEY": "your-api-key", + "GOOGLE_GENERATIVE_AI_API_KEY": "your-api-key", "DEEPSEEK_API_KEY": "your-api-key" } } diff --git a/babel.config.cjs b/babel.config.cjs new file mode 100644 index 0000000..a0b8524 --- /dev/null +++ b/babel.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + '@babel/preset-typescript', + ], +}; \ No newline at end of file diff --git a/jest.config.cjs b/jest.config.cjs index 3e1a10f..9f01f6a 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,14 +1,7 @@ -const { createDefaultEsmPreset } = require('ts-jest'); - -const presetConfig = createDefaultEsmPreset({ - useESM: true, -}); - module.exports = { - ...presetConfig, testEnvironment: 'node', moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', + '^(\\.{1,2}/.*)\\.js$': '$1' }, modulePathIgnorePatterns: ['/dist/'], // Force Jest to exit after all tests have completed diff --git a/package-lock.json b/package-lock.json index 7b1fbbc..ad37864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "taskqueue-mcp", - "version": "1.3.4", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "taskqueue-mcp", - "version": "1.3.4", + "version": "1.4.0", "license": "MIT", "dependencies": { - "@ai-sdk/deepseek": "^0.2.2", - "@ai-sdk/google": "^1.2.3", - "@ai-sdk/openai": "^1.3.4", + "@ai-sdk/deepseek": "^0.2.4", + "@ai-sdk/google": "^1.2.5", + "@ai-sdk/openai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.8.0", - "ai": "^4.2.8", + "ai": "^4.2.10", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^13.1.0", @@ -23,13 +23,17 @@ }, "bin": { "taskqueue": "dist/src/client/index.js", - "taskqueue-mcp": "dist/index.js" + "taskqueue-mcp": "dist/src/server/index.js" }, "devDependencies": { + "@babel/core": "^7.26.10", + "@babel/preset-env": "^7.26.9", + "@babel/preset-typescript": "^7.27.0", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.14", "@types/json-schema": "^7.0.15", "@types/node": "^22.13.14", + "babel-jest": "^29.7.0", "dotenv": "^16.4.7", "jest": "^29.7.0", "shx": "^0.4.0", @@ -38,14 +42,14 @@ } }, "node_modules/@ai-sdk/deepseek": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-0.2.2.tgz", - "integrity": "sha512-utqalXPkAMPsPRAxQt0isbtgjBbGsiIRzg24xdBMl5pZFDRgo7XOWhBMwhHnB7Ii1cHobjVRxKNMqvcJSa9gmQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-0.2.4.tgz", + "integrity": "sha512-D98J+qgZbr3qYoQCIfkd1E6SzmGPi7SelskvxT2329eCC7sPBlbHNbRT/fayShQT3qkqd7u/y24beI2rmMAuMg==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/openai-compatible": "0.2.2", + "@ai-sdk/openai-compatible": "0.2.4", "@ai-sdk/provider": "1.1.0", - "@ai-sdk/provider-utils": "2.2.1" + "@ai-sdk/provider-utils": "2.2.3" }, "engines": { "node": ">=18" @@ -55,13 +59,13 @@ } }, "node_modules/@ai-sdk/google": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-1.2.3.tgz", - "integrity": "sha512-zsgwko7T+MFIdEfhg4fIXv6O2dnzTLFr6BOpAA21eo/moOBA5szVzOto1jTwIwoBYsF2ixPGNZBoc+k/fQ2AWw==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-1.2.5.tgz", + "integrity": "sha512-ykSPjYDmaDg7Qblo6Ea6n6O01NpyehZJE0j3+HCYBtUXKXP2RZWesr7XlceIfFBKHd0sumovRtX4ozHrb+1+sw==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.0", - "@ai-sdk/provider-utils": "2.2.1" + "@ai-sdk/provider-utils": "2.2.3" }, "engines": { "node": ">=18" @@ -71,13 +75,13 @@ } }, "node_modules/@ai-sdk/openai": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.4.tgz", - "integrity": "sha512-BOw7dQpiTlpaqi1u/NU4Or2+jA6buzl6GOUuYyu/uFI7dxJs1zPkY8IjAp4DQhi+kQGH6GGbEPw0LkIbeK4BVA==", + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.6.tgz", + "integrity": "sha512-Lyp6W6dg+ERMJru3DI8/pWAjXLB0GbMMlXh4jxA3mVny8CJHlCAjlEJRuAdLg1/CFz4J1UDN2/4qBnIWtLFIqw==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.0", - "@ai-sdk/provider-utils": "2.2.1" + "@ai-sdk/provider-utils": "2.2.3" }, "engines": { "node": ">=18" @@ -87,13 +91,13 @@ } }, "node_modules/@ai-sdk/openai-compatible": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-0.2.2.tgz", - "integrity": "sha512-pMc21dXF8qWP5AZkNtm+/jvBg1lHlC0HsP5yJRYZ5/6fYuRMl5JYMQZc4Gl8azd19LdWmPPi1HJT+jYE4vM04g==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-0.2.4.tgz", + "integrity": "sha512-hLQnBn5e69rUXvXW+9SOkiL+S4yQX62hjtlX3zKXBI/3VnfOTcGKMamK51GoQB7uQCN1h7l9orvWqWpuQXxzRg==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.0", - "@ai-sdk/provider-utils": "2.2.1" + "@ai-sdk/provider-utils": "2.2.3" }, "engines": { "node": ">=18" @@ -115,9 +119,9 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.1.tgz", - "integrity": "sha512-BuExLp+NcpwsAVj1F4bgJuQkSqO/+roV9wM7RdIO+NVrcT8RBUTdXzf5arHt5T58VpK7bZyB2V9qigjaPHE+Dg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.3.tgz", + "integrity": "sha512-o3fWTzkxzI5Af7U7y794MZkYNEsxbjLam2nxyoUZSScqkacb7vZ3EYHLh21+xCcSSzEC161C7pZAGHtC0hTUMw==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.0", @@ -132,13 +136,13 @@ } }, "node_modules/@ai-sdk/react": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.3.tgz", - "integrity": "sha512-EQ6nmmQBBAal1yg72GB/Q7QnmDXMfgYvCo9Gym2mESXUHTqwpXU0JFHtk5Kq3EEkk7CVMf1oBWlNFNvU5ckQBg==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.5.tgz", + "integrity": "sha512-0jOop3S2WkDOdO4X5I+5fTGqZlNX8/h1T1eYokpkR9xh8Vmrxqw8SsovqGvrddTsZykH8uXRsvI+G4FTyy894A==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "2.2.1", - "@ai-sdk/ui-utils": "1.2.2", + "@ai-sdk/provider-utils": "2.2.3", + "@ai-sdk/ui-utils": "1.2.4", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -156,13 +160,13 @@ } }, "node_modules/@ai-sdk/ui-utils": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.2.tgz", - "integrity": "sha512-6rCx2jSEPuiF6fytfMNscSOinHQZp52aFCHyPVpPPkcWnOur1jPWhol+0TFCUruDl7dCfcSIfTexQUq2ioLwaA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.4.tgz", + "integrity": "sha512-wLTxEZrKZRyBmlVZv8nGXgLBg5tASlqXwbuhoDu0MhZa467ZFREEnosH/OC/novyEHTQXko2zC606xoVbMrUcA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.0", - "@ai-sdk/provider-utils": "2.2.1", + "@ai-sdk/provider-utils": "2.2.3", "zod-to-json-schema": "^3.24.1" }, "engines": { @@ -259,6 +263,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", @@ -276,6 +293,77 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz", + "integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.27.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz", + "integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", + "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", @@ -308,6 +396,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.26.5", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", @@ -318,6 +419,56 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", + "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -348,6 +499,21 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helpers": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", @@ -378,6 +544,103 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -417,26 +680,916 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", + "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.26.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", + "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz", + "integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", + "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", + "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", + "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.26.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", + "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz", + "integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -449,36 +1602,43 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-jsx": { + "node_modules/@babel/plugin-transform-sticky-regex": { "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", - "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", "dev": true, "license": "MIT", "dependencies": { @@ -491,108 +1651,201 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", + "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz", + "integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.26.5" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.0.tgz", + "integrity": "sha512-fRGGjO2UEGPjvEcyAZXRXAS8AfdaQoq7HnxAbJoAoW10B9xOKesmmndJv+Sym2a+9FHWZ9KbyyLCe9s0Sn5jtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.27.0", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/@babel/preset-env": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", + "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/compat-data": "^7.26.8", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.26.8", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.26.5", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.26.3", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.26.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.26.8", + "@babel/plugin-transform-typeof-symbol": "^7.26.7", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.40.0", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -601,14 +1854,33 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", - "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.0.tgz", + "integrity": "sha512-vxaPFfJtHhgeOVXRKuHpHPAOgymmy8V8I65T1q53R7GCZlefKeCaTyDs3zOPHTTbmquvNlQYC5klEvWsBAtrBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.26.5", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.26.3", + "@babel/plugin-transform-typescript": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -617,6 +1889,19 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", @@ -1600,15 +2885,15 @@ } }, "node_modules/ai": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/ai/-/ai-4.2.8.tgz", - "integrity": "sha512-0gwfPZAuuQ+uTfk/GssrfnNTYxliCFKojbSQoEhzpbpSVaPao9NoU3iuE8vwBjWuDKqILRGzYGFE4+vTak0Oxg==", + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/ai/-/ai-4.2.10.tgz", + "integrity": "sha512-rOfKbNRWlzwxbFll6W9oAdnC0R5VVbAJoof+p92CatHzA3reqQZmYn33IBnj+CgqeXYUsH9KX9Wnj7g2wCHc9Q==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "1.1.0", - "@ai-sdk/provider-utils": "2.2.1", - "@ai-sdk/react": "1.2.3", - "@ai-sdk/ui-utils": "1.2.2", + "@ai-sdk/provider-utils": "2.2.3", + "@ai-sdk/react": "1.2.5", + "@ai-sdk/ui-utils": "1.2.4", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, @@ -1802,6 +3087,48 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", + "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.4", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", + "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.4" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/babel-preset-current-node-syntax": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", @@ -2304,6 +3631,20 @@ "node": ">=6.6.0" } }, + "node_modules/core-js-compat": { + "version": "3.41.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", + "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2652,6 +3993,16 @@ "node": ">=4" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4829,6 +6180,13 @@ "node": ">=8" } }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5549,6 +6907,94 @@ "node": ">= 0.10" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6584,6 +8030,50 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 9cbf84d..63925f0 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,25 @@ { "name": "taskqueue-mcp", - "version": "1.3.4", + "version": "1.4.0", "description": "Task Queue MCP Server", "author": "Christopher C. Smith (christopher.smith@promptlytechnologies.com)", - "main": "dist/index.js", + "main": "dist/src/server/index.js", "type": "module", "bin": { - "taskqueue-mcp": "dist/index.js", + "taskqueue-mcp": "dist/src/server/index.js", "taskqueue": "dist/src/client/index.js" }, "files": [ - "dist/index.js", "dist/src/**/*.js", "dist/src/**/*.d.ts", "dist/src/**/*.js.map" ], "scripts": { "build": "tsc", - "start": "node dist/index.js", - "dev": "tsc && node dist/index.js", - "test": "NODE_OPTIONS=--experimental-vm-modules jest", - "approve-task": "node dist/src/cli.js approve-task", - "list-tasks": "node dist/src/cli.js list" + "start": "node dist/src/server/index.js", + "dev": "tsc && node dist/src/server/index.js", + "test": "tsc && NODE_OPTIONS=--experimental-vm-modules jest", + "cli": "node dist/src/cli.js" }, "repository": { "type": "git", @@ -39,11 +37,11 @@ "access": "public" }, "dependencies": { - "@ai-sdk/deepseek": "^0.2.2", - "@ai-sdk/google": "^1.2.3", - "@ai-sdk/openai": "^1.3.4", + "@ai-sdk/deepseek": "^0.2.4", + "@ai-sdk/google": "^1.2.5", + "@ai-sdk/openai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.8.0", - "ai": "^4.2.8", + "ai": "^4.2.10", "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^13.1.0", @@ -52,10 +50,14 @@ "zod-to-json-schema": "^3.24.5" }, "devDependencies": { + "@babel/core": "^7.26.10", + "@babel/preset-env": "^7.26.9", + "@babel/preset-typescript": "^7.27.0", "@jest/globals": "^29.7.0", "@types/jest": "^29.5.14", "@types/json-schema": "^7.0.15", "@types/node": "^22.13.14", + "babel-jest": "^29.7.0", "dotenv": "^16.4.7", "jest": "^29.7.0", "shx": "^0.4.0", diff --git a/src/client/cli.ts b/src/client/cli.ts index dac7535..af3ade2 100644 --- a/src/client/cli.ts +++ b/src/client/cli.ts @@ -1,13 +1,11 @@ import { Command } from "commander"; import chalk from "chalk"; import { - ErrorCode, TaskState, Task, Project -} from "../types/index.js"; +} from "../types/data.js"; import { TaskManager } from "../server/TaskManager.js"; -import { createError, normalizeError } from "../utils/errors.js"; import { formatCliError } from "./errors.js"; import { formatProjectsList, formatTaskProgressTable } from "./taskFormattingUtils.js"; @@ -16,7 +14,7 @@ const program = new Command(); program .name("taskqueue") .description("CLI for the Task Manager MCP Server") - .version("1.3.4") + .version("1.4.0") .option( '-f, --file-path ', 'Specify the path to the tasks JSON file. Overrides TASK_MANAGER_FILE_PATH env var.' @@ -32,7 +30,7 @@ program.hook('preAction', (thisCommand, actionCommand) => { try { taskManager = new TaskManager(resolvedPath); } catch (error) { - console.error(chalk.red(`Failed to initialize TaskManager: ${formatCliError(normalizeError(error))}`)); + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -51,15 +49,8 @@ program let project: Project; let task: Task | undefined; try { - const projectResponse = await taskManager.readProject(projectId); - if ('error' in projectResponse) { - throw projectResponse.error; - } - if (projectResponse.status !== "success") { - throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); - } - project = projectResponse.data; - task = project.tasks.find(t => t.id === taskId); + project = await taskManager.readProject(projectId); + task = project.tasks.find((t: Task) => t.id === taskId); if (!task) { console.error(chalk.red(`Task ${chalk.bold(taskId)} not found in project ${chalk.bold(projectId)}.`)); @@ -70,25 +61,8 @@ program process.exit(1); } } catch (error) { - const normalized = normalizeError(error); - if (normalized.code === ErrorCode.ProjectNotFound) { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - // Optionally list available projects - const projectsResponse = await taskManager.listProjects(); - if ('error' in projectsResponse) { - throw projectsResponse.error; - } - if (projectsResponse.status === "success" && projectsResponse.data.projects.length > 0) { - console.log(chalk.yellow('Available projects:')); - projectsResponse.data.projects.forEach((p: { projectId: string; initialPrompt: string }) => { - console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); - }); - } else { - console.log(chalk.yellow('No projects available.')); - } - process.exit(1); - } - throw error; // Re-throw other errors + console.error(chalk.red(formatCliError(error as Error))); + process.exit(1); } // Pre-check task status if not using force @@ -104,22 +78,12 @@ program } // Attempt to approve the task - const approvalResponse = await taskManager.approveTaskCompletion(projectId, taskId); - if ('error' in approvalResponse) { - throw approvalResponse.error; - } + const approvedTask = await taskManager.approveTaskCompletion(projectId, taskId); console.log(chalk.green(`✅ Task ${chalk.bold(taskId)} in project ${chalk.bold(projectId)} has been approved.`)); // Fetch updated project data for display - const updatedProjectResponse = await taskManager.readProject(projectId); - if ('error' in updatedProjectResponse) { - throw updatedProjectResponse.error; - } - if (updatedProjectResponse.status !== "success") { - throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); - } - const updatedProject = updatedProjectResponse.data; - const updatedTask = updatedProject.tasks.find(t => t.id === taskId); + const updatedProject = await taskManager.readProject(projectId); + const updatedTask = updatedProject.tasks.find((t: Task) => t.id === taskId); // Show task info if (updatedTask) { @@ -139,8 +103,8 @@ program // Show progress info const totalTasks = updatedProject.tasks.length; - const completedTasks = updatedProject.tasks.filter(t => t.status === "done").length; - const approvedTasks = updatedProject.tasks.filter(t => t.approved).length; + const completedTasks = updatedProject.tasks.filter((t: Task) => t.status === "done").length; + const approvedTasks = updatedProject.tasks.filter((t: Task) => t.approved).length; console.log(chalk.cyan(`\n📊 Progress: ${chalk.bold(`${approvedTasks}/${completedTasks}/${totalTasks}`)} (approved/completed/total)`)); @@ -160,16 +124,7 @@ program } } } catch (error) { - const normalized = normalizeError(error); - if (normalized.code === ErrorCode.TaskNotDone) { - console.error(chalk.red(`Approval failed: Task ${chalk.bold(taskId)} is not marked as 'done' according to the Task Manager.`)); - // Just show the error message which should contain all relevant information - // No need to try to access status from details since it's not guaranteed to be there - console.error(chalk.red(normalized.message)); - process.exit(1); - } - // Handle other errors generally - console.error(chalk.red(formatCliError(normalized))); + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -185,34 +140,10 @@ program // First, verify the project exists and get its details let project: Project; try { - const projectResponse = await taskManager.readProject(projectId); - if ('error' in projectResponse) { - throw projectResponse.error; - } - if (projectResponse.status !== "success") { - throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); - } - project = projectResponse.data; + project = await taskManager.readProject(projectId); } catch (error) { - const normalized = normalizeError(error); - if (normalized.code === ErrorCode.ProjectNotFound) { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - // Optionally list available projects - const projectsResponse = await taskManager.listProjects(); - if ('error' in projectsResponse) { - throw projectsResponse.error; - } - if (projectsResponse.status === "success" && projectsResponse.data.projects.length > 0) { - console.log(chalk.yellow('Available projects:')); - projectsResponse.data.projects.forEach((p: { projectId: string; initialPrompt: string }) => { - console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); - }); - } else { - console.log(chalk.yellow('No projects available.')); - } - process.exit(1); - } - throw error; // Re-throw other errors + console.error(chalk.red(formatCliError(error as Error))); + process.exit(1); } // Pre-check project status @@ -243,21 +174,11 @@ program } // Attempt to finalize the project - const finalizationResponse = await taskManager.approveProjectCompletion(projectId); - if ('error' in finalizationResponse) { - throw finalizationResponse.error; - } + await taskManager.approveProjectCompletion(projectId); console.log(chalk.green(`✅ Project ${chalk.bold(projectId)} has been approved and marked as complete.`)); // Fetch updated project data for display - const updatedProjectResponse = await taskManager.readProject(projectId); - if ('error' in updatedProjectResponse) { - throw updatedProjectResponse.error; - } - if (updatedProjectResponse.status !== "success") { - throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response format from TaskManager"); - } - const updatedProject = updatedProjectResponse.data; + const updatedProject = await taskManager.readProject(projectId); // Show project info console.log(chalk.cyan('\n📋 Project details:')); @@ -283,23 +204,7 @@ program console.log(chalk.blue(` taskqueue list -p ${projectId}`)); } catch (error) { - const normalized = normalizeError(error); - if (normalized.code === ErrorCode.TasksNotAllDone) { - console.error(chalk.red(`Finalization failed: Not all tasks in project ${chalk.bold(projectId)} are marked as done.`)); - // We already showed pending tasks in pre-check, no need to show again - process.exit(1); - } - if (normalized.code === ErrorCode.TasksNotAllApproved) { - console.error(chalk.red(`Finalization failed: Not all completed tasks in project ${chalk.bold(projectId)} are approved yet.`)); - // We already showed unapproved tasks in pre-check, no need to show again - process.exit(1); - } - if (normalized.code === ErrorCode.ProjectAlreadyCompleted) { - console.log(chalk.yellow(`Project ${chalk.bold(projectId)} was already marked as completed.`)); - process.exit(0); - } - // Handle other errors generally - console.error(chalk.red(formatCliError(normalized))); + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -325,74 +230,49 @@ program // Show details for a specific project const projectId = options.project; try { - const projectResponse = await taskManager.readProject(projectId); - if ('error' in projectResponse) throw projectResponse.error; - if (projectResponse.status !== "success") throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response"); - - const project = projectResponse.data; - - // Filter tasks based on state if provided - const tasksToList = filterState - ? project.tasks.filter((task) => { - if (filterState === 'open') return task.status !== 'done'; - if (filterState === 'pending_approval') return task.status === 'done' && !task.approved; - if (filterState === 'completed') return task.status === 'done' && task.approved; - return true; // Should not happen - }) - : project.tasks; - - // Use the formatter for the progress table - it now includes the header - const projectForTableDisplay = { ...project, tasks: tasksToList }; - console.log(formatTaskProgressTable(projectForTableDisplay)); - - if (tasksToList.length === 0) { - console.log(chalk.yellow(`\nNo tasks found${filterState ? ` matching state '${filterState}'` : ''} in project ${projectId}.`)); - } else if (filterState) { - console.log(chalk.dim(`(Filtered by state: ${filterState})`)); - } - - } catch (error: unknown) { - const normalized = normalizeError(error); - if (normalized.code === ErrorCode.ProjectNotFound) { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - // Optionally list available projects - const projectsResponse = await taskManager.listProjects(); // Fetch summaries - if (projectsResponse.status === "success" && projectsResponse.data.projects.length > 0) { - console.log(chalk.yellow('Available projects:')); - projectsResponse.data.projects.forEach((p: { projectId: string; initialPrompt: string }) => { - console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); - }); - } else if (projectsResponse.status === "success"){ - console.log(chalk.yellow('No projects available.')); - } - // else: error fetching list, handled by outer catch - process.exit(1); - } else { - console.error(chalk.red(formatCliError(normalized))); - process.exit(1); - } + const project = await taskManager.readProject(projectId); + + // Filter tasks based on state if provided + const tasksToList = filterState + ? project.tasks.filter((task: Task) => { + if (filterState === 'open') return !task.approved; + if (filterState === 'pending_approval') return task.status === 'done' && !task.approved; + if (filterState === 'completed') return task.status === 'done' && task.approved; + return true; // Should not happen + }) + : project.tasks; + + // Use the formatter for the progress table - it now includes the header + const projectForTableDisplay = { ...project, tasks: tasksToList }; + console.log(formatTaskProgressTable(projectForTableDisplay)); + + if (tasksToList.length === 0) { + console.log(chalk.yellow(`\nNo tasks found${filterState ? ` matching state '${filterState}'` : ''} in project ${projectId}.`)); + } else if (filterState) { + console.log(chalk.dim(`(Filtered by state: ${filterState})`)); + } + + } catch (error) { + console.error(chalk.red(formatCliError(error as Error))); + process.exit(1); } } else { // List all projects, potentially filtered - const projectsSummaryResponse = await taskManager.listProjects(filterState); - if ('error' in projectsSummaryResponse) throw projectsSummaryResponse.error; - if (projectsSummaryResponse.status !== "success") throw createError(ErrorCode.InvalidResponseFormat, "Unexpected response"); + const projects = await taskManager.listProjects(filterState); - const projectSummaries = projectsSummaryResponse.data.projects; - - if (projectSummaries.length === 0) { + if (projects.projects.length === 0) { console.log(chalk.yellow(`No projects found${filterState ? ` matching state '${filterState}'` : ''}.`)); return; } // Use the formatter directly with the summary data - console.log(chalk.cyan(formatProjectsList(projectSummaries))); + console.log(chalk.cyan(formatProjectsList(projects.projects))); if (filterState) { console.log(chalk.dim(`(Filtered by state: ${filterState})`)); } } } catch (error) { - console.error(chalk.red(formatCliError(normalizeError(error)))); + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -407,57 +287,33 @@ program .action(async (options) => { try { console.log(chalk.blue(`Generating project plan from prompt...`)); - console.log(options.attachment); // Pass attachment filenames directly to the server - const response = await taskManager.generateProjectPlan({ + const result = await taskManager.generateProjectPlan({ prompt: options.prompt, provider: options.provider, model: options.model, attachments: options.attachment }); - if ('error' in response) { - throw response.error; - } - - if (response.status !== "success") { - throw createError( - ErrorCode.InvalidResponseFormat, - "Unexpected response format from TaskManager" - ); - } - - const data = response.data as { - projectId: string; - totalTasks: number; - tasks: Array<{ - id: string; - title: string; - description: string; - }>; - message?: string; - }; - // Display the results console.log(chalk.green(`✅ Project plan generated successfully!`)); console.log(chalk.cyan('\n📋 Project details:')); - console.log(` - ${chalk.bold('Project ID:')} ${data.projectId}`); - console.log(` - ${chalk.bold('Total Tasks:')} ${data.totalTasks}`); + console.log(` - ${chalk.bold('Project ID:')} ${result.projectId}`); + console.log(` - ${chalk.bold('Total Tasks:')} ${result.totalTasks}`); console.log(chalk.cyan('\n📝 Tasks:')); - data.tasks.forEach((task) => { + result.tasks.forEach((task) => { console.log(`\n ${chalk.bold(task.id)}:`); console.log(` Title: ${task.title}`); console.log(` Description: ${task.description}`); }); - if (data.message) { - console.log(`\n${data.message}`); + if (result.message) { + console.log(`\n${result.message}`); } - } catch (err: unknown) { - const normalized = normalizeError(err); - console.error(`Error: ${chalk.red(formatCliError(normalized))}`); + } catch (error) { + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); diff --git a/src/client/errors.ts b/src/client/errors.ts index da798d7..ba72ffa 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -1,28 +1,19 @@ -import { StandardError } from "../types/index.js"; +import { AppError } from "../types/errors.js"; + /** - * Formats an error message for CLI output, optionally including relevant details. + * Formats an error message for CLI output */ -export function formatCliError(error: StandardError, includeDetails: boolean = true): string { - const codePrefix = error.message.includes(`[${error.code}]`) ? '' : `[${error.code}] `; - let message = `${codePrefix}${error.message}`; - - if (includeDetails && error.details) { - // Prioritize showing nested originalError message if it exists and is different - const originalErrorMessage = (error.details as any)?.originalError?.message; - if (originalErrorMessage && typeof originalErrorMessage === 'string' && originalErrorMessage !== error.message) { - message += `\n -> Details: ${originalErrorMessage}`; - } - // Add a fallback for simpler string details or stringified objects if needed, - // but avoid dumping large complex objects unless necessary for debugging. - // Example: uncomment if you often have simple string details - // else if (typeof error.details === 'string') { - // message += `\n -> Details: ${error.details}`; - // } - // Example: uncomment ONLY if you need to see the raw JSON details often - // else { - // message += `\nDetails: ${JSON.stringify(error.details, null, 2)}`; - // } +export function formatCliError(error: Error): string { + // Handle our custom file system errors by prefixing the error code + if (error instanceof AppError) { + let details = ''; + if (error.details) { + const detailsStr = typeof error.details === 'string' ? error.details : String(error.details); + details = `\n-> Details: ${detailsStr.replace(/^AppError:\s*/, '')}`; + } + return `[${error.code}] ${error.message}${details}`; } - return message; + // For unknown errors, just return the error message + return error.message; } \ No newline at end of file diff --git a/src/client/taskFormattingUtils.ts b/src/client/taskFormattingUtils.ts index 0eca4b9..5852dae 100644 --- a/src/client/taskFormattingUtils.ts +++ b/src/client/taskFormattingUtils.ts @@ -1,6 +1,7 @@ import Table from 'cli-table3'; // Import the library import chalk from 'chalk'; // Import chalk for consistent styling -import { ListProjectsSuccessData, Project } from "../types/index.js"; +import { ListProjectsSuccessData } from "../types/response.js"; +import { Project } from "../types/data.js"; /** * Formats the project details and a progress table for its tasks using cli-table3. diff --git a/src/server/FileSystemService.ts b/src/server/FileSystemService.ts index 739d79a..cfeb11c 100644 --- a/src/server/FileSystemService.ts +++ b/src/server/FileSystemService.ts @@ -1,8 +1,9 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { dirname, join, resolve } from "node:path"; import { homedir } from "node:os"; -import { TaskManagerFile, ErrorCode } from "../types/index.js"; -import { createError } from "../utils/errors.js"; +import { AppError, AppErrorCode } from "../types/errors.js"; +import { TaskManagerFile } from "../types/data.js"; +import * as fs from 'node:fs'; export interface InitializedTaskData { data: TaskManagerFile; @@ -12,12 +13,11 @@ export interface InitializedTaskData { export class FileSystemService { private filePath: string; - // Simple in-memory queue to prevent concurrent file operations - private operationInProgress: boolean = false; - private operationQueue: (() => void)[] = []; + private lockFilePath: string; constructor(filePath: string) { this.filePath = filePath; + this.lockFilePath = `${filePath}.lock`; } /** @@ -41,39 +41,48 @@ export class FileSystemService { } /** - * Queue a file operation to prevent concurrent access - * @param operation The operation to perform - * @returns Promise that resolves when the operation completes + * Acquires a file system lock */ - private async queueOperation(operation: () => Promise): Promise { - // If another operation is in progress, wait for it to complete - if (this.operationInProgress) { - return new Promise((resolve, reject) => { - this.operationQueue.push(() => { - this.executeOperation(operation).then(resolve).catch(reject); - }); - }); + private async acquireLock(): Promise { + while (true) { + try { + // Try to create lock file + const fd = fs.openSync(this.lockFilePath, 'wx'); + fs.closeSync(fd); + return; + } catch (error: any) { + if (error.code === 'EEXIST') { + // Lock file exists, wait and retry + await new Promise(resolve => setTimeout(resolve, 100)); + continue; + } + throw error; + } } + } - return this.executeOperation(operation); + /** + * Releases the file system lock + */ + private async releaseLock(): Promise { + try { + await fs.promises.unlink(this.lockFilePath); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } } /** - * Execute a file operation with mutex protection - * @param operation The operation to perform - * @returns Promise that resolves when the operation completes + * Execute a file operation with file system lock */ private async executeOperation(operation: () => Promise): Promise { - this.operationInProgress = true; + await this.acquireLock(); try { return await operation(); } finally { - this.operationInProgress = false; - // Process the next operation in the queue, if any - const nextOperation = this.operationQueue.shift(); - if (nextOperation) { - nextOperation(); - } + await this.releaseLock(); } } @@ -81,7 +90,7 @@ export class FileSystemService { * Loads and initializes task data from the JSON file */ public async loadAndInitializeTasks(): Promise { - return this.queueOperation(async () => { + return this.executeOperation(async () => { const data = await this.loadTasks(); const { maxProjectId, maxTaskId } = this.calculateMaxIds(data); @@ -95,11 +104,9 @@ export class FileSystemService { /** * Explicitly reloads task data from the disk - * This is useful when the file may have been changed by another process - * @returns The latest task data from disk */ public async reloadTasks(): Promise { - return this.queueOperation(async () => { + return this.executeOperation(async () => { return this.loadTasks(); }); } @@ -138,17 +145,22 @@ export class FileSystemService { const data = await readFile(this.filePath, "utf-8"); return JSON.parse(data); } catch (error) { - // Initialize with empty data for any initialization error - // This includes file not found, permission issues, invalid JSON, etc. - return { projects: [] }; + if (error instanceof Error) { + if (error.message.includes('ENOENT')) { + // If file doesn't exist, return empty data + return { projects: [] }; + } + throw new AppError(`Failed to read tasks file: ${error.message}`, AppErrorCode.FileReadError, error); + } + throw new AppError('Unknown error reading tasks file', AppErrorCode.FileReadError, error); } } /** - * Saves task data to the JSON file with an in-memory mutex to prevent concurrent writes + * Saves task data to the JSON file with file system lock */ public async saveTasks(data: TaskManagerFile): Promise { - return this.queueOperation(async () => { + return this.executeOperation(async () => { try { // Ensure directory exists before writing const dir = dirname(this.filePath); @@ -162,17 +174,9 @@ export class FileSystemService { ); } catch (error) { if (error instanceof Error && error.message.includes("EROFS")) { - throw createError( - ErrorCode.ReadOnlyFileSystem, - "Cannot save tasks: read-only file system", - { originalError: error } - ); + throw new AppError("Cannot save tasks: read-only file system", AppErrorCode.ReadOnlyFileSystem, error); } - throw createError( - ErrorCode.FileWriteError, - "Failed to save tasks file", - { originalError: error } - ); + throw new AppError("Failed to save tasks file", AppErrorCode.FileWriteError, error); } }); } @@ -181,7 +185,7 @@ export class FileSystemService { * Reads an attachment file from the current working directory * @param filename The name of the file to read (relative to cwd) * @returns The contents of the file as a string - * @throws {StandardError} If the file cannot be read + * @throws {FileReadError} If the file cannot be read */ public async readAttachmentFile(filename: string): Promise { try { @@ -189,17 +193,9 @@ export class FileSystemService { return await readFile(filePath, 'utf-8'); } catch (error) { if (error instanceof Error && error.message.includes('ENOENT')) { - throw createError( - ErrorCode.FileReadError, - `Attachment file not found: ${filename}`, - { originalError: error } - ); + throw new AppError(`Attachment file not found: ${filename}`, AppErrorCode.FileReadError, error); } - throw createError( - ErrorCode.FileReadError, - `Failed to read attachment file: ${filename}`, - { originalError: error } - ); + throw new AppError(`Failed to read attachment file: ${filename}`, AppErrorCode.FileReadError, error); } } } \ No newline at end of file diff --git a/src/server/TaskManager.ts b/src/server/TaskManager.ts index 2fdd47f..662b4ce 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -3,9 +3,9 @@ import { Task, TaskManagerFile, TaskState, - StandardResponse, - ErrorCode, - Project, + Project +} from "../types/data.js"; +import { ProjectCreationSuccessData, ApproveTaskSuccessData, ApproveProjectSuccessData, @@ -14,16 +14,26 @@ import { ListTasksSuccessData, AddTasksSuccessData, DeleteTaskSuccessData, - ReadProjectSuccessData -} from "../types/index.js"; -import { createError, createSuccessResponse } from "../utils/errors.js"; -import { generateObject, jsonSchema } from "ai"; + ReadProjectSuccessData, +} from "../types/response.js"; +import { AppError, AppErrorCode } from "../types/errors.js"; import { FileSystemService } from "./FileSystemService.js"; +import { generateObject, jsonSchema } from "ai"; // Default path follows platform-specific conventions const DEFAULT_PATH = path.join(FileSystemService.getAppDataDir(), "tasks.json"); const TASK_FILE_PATH = process.env.TASK_MANAGER_FILE_PATH || DEFAULT_PATH; +interface ProjectPlanOutput { + projectPlan: string; + tasks: Array<{ + title: string; + description: string; + toolRecommendations?: string; + ruleRecommendations?: string; + }>; +} + export class TaskManager { private projectCounter = 0; private taskCounter = 0; @@ -33,31 +43,58 @@ export class TaskManager { constructor(testFilePath?: string) { this.fileSystemService = new FileSystemService(testFilePath || TASK_FILE_PATH); - this.initialized = this.loadTasks(); + this.initialized = this.loadTasks().catch(error => { + console.error('Failed to initialize TaskManager:', error); + // Set default values for failed initialization + this.data = { projects: [] }; + this.projectCounter = 0; + this.taskCounter = 0; + }); } private async loadTasks() { - const { data, maxProjectId, maxTaskId } = await this.fileSystemService.loadAndInitializeTasks(); - this.data = data; - this.projectCounter = maxProjectId; - this.taskCounter = maxTaskId; + try { + const { data, maxProjectId, maxTaskId } = await this.fileSystemService.loadAndInitializeTasks(); + this.data = data; + this.projectCounter = maxProjectId; + this.taskCounter = maxTaskId; + } catch (error) { + // Propagate the error to be handled by the constructor + throw new AppError('Failed to load tasks from disk', AppErrorCode.FileReadError, error); + } } private async ensureInitialized() { - await this.initialized; + try { + await this.initialized; + } catch (error) { + // If initialization failed, throw an AppError that can be handled by the tool executor + throw new AppError( + 'Failed to initialize task manager', + AppErrorCode.FileReadError, + error + ); + } } - /** - * Reloads data from disk - * This is helpful when the task file might have been modified by another process - * Used internally before read operations - */ public async reloadFromDisk(): Promise { - const data = await this.fileSystemService.reloadTasks(); - this.data = data; - const { maxProjectId, maxTaskId } = this.fileSystemService.calculateMaxIds(data); - this.projectCounter = maxProjectId; - this.taskCounter = maxTaskId; + try { + const data = await this.fileSystemService.reloadTasks(); + this.data = data; + const { maxProjectId, maxTaskId } = this.fileSystemService.calculateMaxIds(data); + this.projectCounter = maxProjectId; + this.taskCounter = maxTaskId; + } catch (error) { + // Propagate as AppError to be handled by the tool executor + if (error instanceof AppError) { + throw error; + } + throw new AppError( + 'Failed to reload tasks from disk', + AppErrorCode.FileReadError, + error + ); + } } private async saveTasks() { @@ -69,10 +106,10 @@ export class TaskManager { tasks: { title: string; description: string; toolRecommendations?: string; ruleRecommendations?: string }[], projectPlan?: string, autoApprove?: boolean - ): Promise> { + ): Promise { await this.ensureInitialized(); - // Reload before creating to ensure counters are up-to-date await this.reloadFromDisk(); + this.projectCounter += 1; const projectId = `proj-${this.projectCounter}`; @@ -101,10 +138,9 @@ export class TaskManager { }; this.data.projects.push(newProject); - await this.saveTasks(); - return createSuccessResponse({ + return { projectId, totalTasks: newTasks.length, tasks: newTasks.map((t) => ({ @@ -113,7 +149,7 @@ export class TaskManager { description: t.description, })), message: `Project ${projectId} created with ${newTasks.length} tasks.`, - }); + }; } public async generateProjectPlan({ @@ -126,23 +162,17 @@ export class TaskManager { provider: string; model: string; attachments: string[]; - }): Promise> { + }): Promise { await this.ensureInitialized(); // Read all attachment files const attachmentContents: string[] = []; for (const filename of attachments) { try { - console.log("We are about to try to read the file.") const content = await this.fileSystemService.readAttachmentFile(filename); attachmentContents.push(content); } catch (error) { - // Propagate file read errors - throw createError( - ErrorCode.FileReadError, - `Failed to read attachment file: ${filename}`, - { originalError: error } - ); + throw new AppError(`Failed to read attachment file: ${filename}`, AppErrorCode.FileReadError, error); } } @@ -191,180 +221,106 @@ export class TaskManager { modelProvider = deepseek(model); break; default: - throw createError( - ErrorCode.InvalidArgument, - `Invalid provider: ${provider}` - ); - } - console.log("set model and provider") - - interface ProjectPlanOutput { - projectPlan: string; - tasks: Array<{ - title: string; - description: string; - toolRecommendations?: string; - ruleRecommendations?: string; - }>; + throw new AppError(`Invalid provider: ${provider}`, AppErrorCode.InvalidProvider); } try { - // Call the LLM to generate the project plan - const { object } = await generateObject({ + const { object } = await generateObject({ model: modelProvider, schema: projectPlanSchema, prompt: llmPrompt, }); - - // Create a new project with the generated plan and tasks - const result = await this.createProject( - prompt, - object.tasks, - object.projectPlan - ); - - return result; - } catch (err) { - // Handle specific AI SDK errors - if (err instanceof Error) { - // Check for specific error names or messages - if (err.name === 'NoObjectGeneratedError') { - throw createError( - ErrorCode.InvalidResponseFormat, - "The LLM failed to generate a valid project plan. Please try again with a clearer prompt.", - { originalError: err } - ); - } - if (err.name === 'InvalidJSONError') { - throw createError( - ErrorCode.InvalidResponseFormat, - "The LLM generated invalid JSON. Please try again.", - { originalError: err } - ); - } - if (err.message.includes('rate limit') || err.message.includes('quota')) { - throw createError( - ErrorCode.ConfigurationError, - "Rate limit or quota exceeded for the LLM provider. Please try again later.", - { originalError: err } - ); - } - // --- Updated Check for API Key Errors --- - // Check by name (more robust) or message content - if (err.name === 'LoadAPIKeyError' || err.message.includes('API key is missing')) { - throw createError( - ErrorCode.ConfigurationError, // Use the correct code for config issues - "Invalid or missing API key. Please check your environment variables.", // More specific message - { originalError: err } - ); - } - // Existing check for general auth errors (might still be relevant for other cases) - if (err.message.includes('authentication') || err.message.includes('unauthorized')) { - throw createError( - ErrorCode.ConfigurationError, - "Authentication failed with the LLM provider. Please check your credentials.", - { originalError: err } - ); - } + return await this.createProject(prompt, object.tasks, object.projectPlan); + } catch (err: any) { + if (err.name === 'LoadAPIKeyError' || + err.message.includes('API key is missing') || + err.message.includes('You didn\'t provide an API key') || + err.message.includes('unregistered callers') || + (err.responseBody && err.responseBody.includes('Authentication Fails'))) { + throw new AppError( + `Missing API key environment variable required for ${provider}`, + AppErrorCode.ConfigurationError, + err + ); } - - // For unknown errors from the LLM/SDK, preserve the original error but wrap it. - // Use a more generic error code here if it's not one of the above. - // Perhaps keep InvalidResponseFormat or create a new one like LLMInteractionError? - // Let's stick with InvalidResponseFormat for now as it often manifests as bad output. - throw createError( - ErrorCode.InvalidResponseFormat, // Fallback code - "Failed to generate project plan due to an unexpected error.", // Fallback message - { originalError: err } // Always include original error for debugging + // Check for invalid model errors by looking at the error code, type, and message + if ((err.data?.error?.code === 'model_not_found') && + err.message.includes('model')) { + throw new AppError( + `Invalid model: ${model} is not available for ${provider}`, + AppErrorCode.InvalidModel, + err + ); + } + // For unknown errors, preserve the original error but wrap it + throw new AppError( + "Failed to generate project plan due to an unexpected error", + AppErrorCode.LLMGenerationError, + err ); } } - public async getNextTask(projectId: string): Promise> { + public async getNextTask(projectId: string): Promise { await this.ensureInitialized(); - // Reload from disk to ensure we have the latest data await this.reloadFromDisk(); const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } if (proj.completed) { - throw createError( - ErrorCode.ProjectAlreadyCompleted, - "Project is already completed" - ); + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } - const nextTask = proj.tasks.find((t) => t.status !== "done"); + + if (!proj.tasks.length) { + throw new AppError('Project has no tasks', AppErrorCode.TaskNotFound); + } + + const nextTask = proj.tasks.find((t) => !(t.status === "done" && t.approved)); if (!nextTask) { - // all tasks done? - const allDone = proj.tasks.every((t) => t.status === "done"); - if (allDone && !proj.completed) { + // all tasks done and approved? + const allDoneAndApproved = proj.tasks.every((t) => t.status === "done" && t.approved); + if (allDoneAndApproved && !proj.completed) { return { - status: "all_tasks_done", - data: { - message: `All tasks have been completed. Awaiting project completion approval.` - } + message: `All tasks have been completed and approved. Awaiting project completion approval.` }; } - throw createError( - ErrorCode.TaskNotFound, - "No undone tasks found" - ); + throw new AppError('No incomplete or unapproved tasks found', AppErrorCode.TaskNotFound); } - // Return the full task details similar to openTaskDetails - return createSuccessResponse({ + return { projectId: proj.projectId, task: { ...nextTask }, - }); + }; } - public async approveTaskCompletion(projectId: string, taskId: string): Promise> { + public async approveTaskCompletion(projectId: string, taskId: string): Promise { await this.ensureInitialized(); - // Reload before modifying await this.reloadFromDisk(); + const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } + const task = proj.tasks.find((t) => t.id === taskId); if (!task) { - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); } + if (task.status !== "done") { - throw createError( - ErrorCode.TaskNotDone, - "Task not done yet" - ); + throw new AppError('Task not done yet', AppErrorCode.TaskNotDone); } + if (task.approved) { - // Return the full expected data structure even if already approved - return createSuccessResponse({ - message: "Task already approved.", - projectId: proj.projectId, - task: { - id: task.id, - title: task.title, - description: task.description, - completedDetails: task.completedDetails, - approved: task.approved, - }, - }); + throw new AppError('Task is already approved', AppErrorCode.TaskAlreadyApproved); } task.approved = true; await this.saveTasks(); - return createSuccessResponse({ + + return { projectId: proj.projectId, task: { id: task.id, @@ -373,179 +329,160 @@ export class TaskManager { completedDetails: task.completedDetails, approved: task.approved, }, - }); + }; } - public async approveProjectCompletion(projectId: string): Promise> { + public async approveProjectCompletion(projectId: string): Promise { await this.ensureInitialized(); - // Reload before modifying await this.reloadFromDisk(); + const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } - // Check if project is already completed if (proj.completed) { - throw createError( - ErrorCode.ProjectAlreadyCompleted, - "Project is already completed" - ); + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } - // Check if all tasks are done and approved const allDone = proj.tasks.every((t) => t.status === "done"); if (!allDone) { - throw createError( - ErrorCode.TasksNotAllDone, - "Not all tasks are done" - ); + throw new AppError('Not all tasks are done', AppErrorCode.TasksNotAllDone); } + const allApproved = proj.tasks.every((t) => t.status === "done" && t.approved); if (!allApproved) { - throw createError( - ErrorCode.TasksNotAllApproved, - "Not all done tasks are approved" - ); + throw new AppError('Not all done tasks are approved', AppErrorCode.TasksNotAllApproved); } proj.completed = true; await this.saveTasks(); - return createSuccessResponse({ + + return { projectId: proj.projectId, message: "Project is fully completed and approved.", - }); + }; } - public async openTaskDetails(taskId: string): Promise> { + public async openTaskDetails(projectId: string, taskId: string): Promise { await this.ensureInitialized(); - // Reload from disk to ensure we have the latest data await this.reloadFromDisk(); - for (const proj of this.data.projects) { - const target = proj.tasks.find((t) => t.id === taskId); - if (target) { - // Return only projectId and the full task object - return createSuccessResponse({ - projectId: proj.projectId, - task: { ...target }, // Return all fields from the found task - }); - } + const project = this.data.projects.find((p) => p.projectId === projectId); + if (!project) { + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); + } + + const target = project.tasks.find((t) => t.id === taskId); + if (!target) { + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); } - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); + + return { + projectId: project.projectId, + task: { ...target }, + }; } - public async listProjects(state?: TaskState): Promise> { + public async listProjects(state?: TaskState): Promise { await this.ensureInitialized(); - // Reload from disk to ensure we have the latest data await this.reloadFromDisk(); + if (state && !["all", "open", "completed", "pending_approval"].includes(state)) { + throw new AppError(`Invalid state filter: ${state}`, AppErrorCode.InvalidState); + } + let filteredProjects = [...this.data.projects]; if (state && state !== "all") { - filteredProjects = filteredProjects.filter((proj) => { + filteredProjects = filteredProjects.filter((p) => { switch (state) { case "open": - return !proj.completed && proj.tasks.some((task) => task.status !== "done"); - case "pending_approval": - return proj.tasks.some((task) => task.status === "done" && !task.approved); + return !p.completed; case "completed": - return proj.completed && proj.tasks.every((task) => task.status === "done" && task.approved); + return p.completed; + case "pending_approval": + return !p.completed && p.tasks.every((t) => t.status === "done"); default: - return true; // Should not happen due to type safety + return true; } }); } - return createSuccessResponse({ + return { message: `Current projects in the system:`, - projects: filteredProjects.map((proj) => ({ - projectId: proj.projectId, - initialPrompt: proj.initialPrompt, - totalTasks: proj.tasks.length, - completedTasks: proj.tasks.filter((task) => task.status === "done").length, - approvedTasks: proj.tasks.filter((task) => task.approved).length, + projects: filteredProjects.map((p) => ({ + projectId: p.projectId, + initialPrompt: p.initialPrompt, + totalTasks: p.tasks.length, + completedTasks: p.tasks.filter((t) => t.status === "done").length, + approvedTasks: p.tasks.filter((t) => t.approved).length, })), - }); + }; } - public async listTasks(projectId?: string, state?: TaskState): Promise> { + public async listTasks(projectId?: string, state?: TaskState): Promise { await this.ensureInitialized(); - // Reload from disk to ensure we have the latest data await this.reloadFromDisk(); - - // If projectId is provided, verify the project exists + + if (state && !["all", "open", "completed", "pending_approval"].includes(state)) { + throw new AppError(`Invalid state filter: ${state}`, AppErrorCode.InvalidState); + } + + let allTasks: Task[] = []; + if (projectId) { - const project = this.data.projects.find((p) => p.projectId === projectId); - if (!project) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + const proj = this.data.projects.find((p) => p.projectId === projectId); + if (!proj) { + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } + allTasks = [...proj.tasks]; + } else { + // Collect tasks from all projects + allTasks = this.data.projects.flatMap((p) => p.tasks); } - // Flatten all tasks from all projects if no projectId is given - let tasks = projectId - ? this.data.projects.find((p) => p.projectId === projectId)?.tasks || [] - : this.data.projects.flatMap((p) => p.tasks); - - // Apply state filtering if (state && state !== "all") { - tasks = tasks.filter((task) => { + allTasks = allTasks.filter((task) => { switch (state) { case "open": - return task.status !== "done"; - case "pending_approval": - return task.status === "done" && !task.approved; + return !task.approved; case "completed": return task.status === "done" && task.approved; + case "pending_approval": + return task.status === "done" && !task.approved; default: - return true; // Should not happen due to type safety + return true; } }); } - return createSuccessResponse({ - message: `Tasks in the system${projectId ? ` for project ${projectId}` : ""}:\n${tasks.length} tasks found.`, - tasks: tasks.map(task => ({ - id: task.id, - title: task.title, - description: task.description, - status: task.status, - approved: task.approved, - completedDetails: task.completedDetails, - toolRecommendations: task.toolRecommendations, - ruleRecommendations: task.ruleRecommendations - })) - }); + return { + message: `Tasks in the system${projectId ? ` for project ${projectId}` : ""}:\n${allTasks.length} tasks found.`, + tasks: allTasks, + }; } public async addTasksToProject( projectId: string, tasks: { title: string; description: string; toolRecommendations?: string; ruleRecommendations?: string }[] - ): Promise> { + ): Promise { await this.ensureInitialized(); - // Reload before modifying await this.reloadFromDisk(); + const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); + } + + if (proj.completed) { + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } const newTasks: Task[] = []; for (const taskDef of tasks) { this.taskCounter += 1; - newTasks.push({ + const newTask: Task = { id: `task-${this.taskCounter}`, title: taskDef.title, description: taskDef.description, @@ -554,20 +491,21 @@ export class TaskManager { completedDetails: "", toolRecommendations: taskDef.toolRecommendations, ruleRecommendations: taskDef.ruleRecommendations, - }); + }; + newTasks.push(newTask); + proj.tasks.push(newTask); } - proj.tasks.push(...newTasks); await this.saveTasks(); - return createSuccessResponse({ - message: `Added ${newTasks.length} new tasks to project ${projectId}.`, + return { newTasks: newTasks.map((t) => ({ id: t.id, title: t.title, description: t.description, })), - }); + message: `Added ${newTasks.length} tasks to project ${projectId}`, + }; } public async updateTask( @@ -581,90 +519,82 @@ export class TaskManager { status?: "not started" | "in progress" | "done"; completedDetails?: string; } - ): Promise> { + ): Promise { await this.ensureInitialized(); - // Reload before modifying await this.reloadFromDisk(); - const project = this.data.projects.find((p) => p.projectId === projectId); - if (!project) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + + const proj = this.data.projects.find((p) => p.projectId === projectId); + if (!proj) { + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } - const taskIndex = project.tasks.findIndex((t) => t.id === taskId); - if (taskIndex === -1) { - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); + if (proj.completed) { + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } - // Update the task with the provided updates - project.tasks[taskIndex] = { ...project.tasks[taskIndex], ...updates }; + const task = proj.tasks.find((t) => t.id === taskId); + if (!task) { + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); + } - // Check if status was updated to 'done' and if project has autoApprove enabled - if (updates.status === 'done' && project.autoApprove) { - project.tasks[taskIndex].approved = true; + if (task.approved) { + throw new AppError('Cannot modify an approved task', AppErrorCode.CannotModifyApprovedTask); } + // Apply updates + Object.assign(task, updates); + await this.saveTasks(); - return createSuccessResponse(project.tasks[taskIndex]); + return task; } - public async deleteTask(projectId: string, taskId: string): Promise> { + public async deleteTask(projectId: string, taskId: string): Promise { await this.ensureInitialized(); - // Reload before modifying await this.reloadFromDisk(); + const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); + } + + if (proj.completed) { + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } const taskIndex = proj.tasks.findIndex((t) => t.id === taskId); if (taskIndex === -1) { - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); } - if (proj.tasks[taskIndex].status === "done") { - throw createError( - ErrorCode.CannotDeleteCompletedTask, - "Cannot delete completed task" - ); + + const task = proj.tasks[taskIndex]; + if (task.approved) { + throw new AppError('Cannot delete an approved task', AppErrorCode.CannotModifyApprovedTask); } proj.tasks.splice(taskIndex, 1); await this.saveTasks(); - return createSuccessResponse({ - message: `Task ${taskId} has been deleted from project ${projectId}.`, - }); + return { + message: `Task ${taskId} deleted from project ${projectId}`, + }; } - public async readProject(projectId: string): Promise> { + public async readProject(projectId: string): Promise { await this.ensureInitialized(); - // Reload from disk to ensure we have the latest data await this.reloadFromDisk(); - - const project = this.data.projects.find(p => p.projectId === projectId); + + const project = this.data.projects.find((p) => p.projectId === projectId); if (!project) { - throw createError( - ErrorCode.ProjectNotFound, - `Project ${projectId} not found` - ); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } - return createSuccessResponse({ + + return { projectId: project.projectId, initialPrompt: project.initialPrompt, projectPlan: project.projectPlan, completed: project.completed, - tasks: project.tasks - }); + autoApprove: project.autoApprove, + tasks: project.tasks, + }; } } \ No newline at end of file diff --git a/index.ts b/src/server/index.ts similarity index 65% rename from index.ts rename to src/server/index.ts index af1000e..6c96d56 100644 --- a/index.ts +++ b/src/server/index.ts @@ -2,15 +2,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { TaskManager } from "./src/server/TaskManager.js"; -import { ALL_TOOLS, executeToolWithErrorHandling } from "./src/server/tools.js"; +import { TaskManager } from "./TaskManager.js"; +import { ALL_TOOLS, executeToolAndHandleErrors } from "./tools.js"; import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; // Create server with capabilities BEFORE setting up handlers const server = new Server( { name: "task-manager-server", - version: "1.3.4" + version: "1.4.0" }, { capabilities: { @@ -22,12 +22,6 @@ const server = new Server( } ); -// Debug logging -console.error('Server starting with env:', { - TASK_MANAGER_FILE_PATH: process.env.TASK_MANAGER_FILE_PATH, - NODE_ENV: process.env.NODE_ENV -}); - // Create task manager instance const taskManager = new TaskManager(); @@ -39,11 +33,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }); server.setRequestHandler(CallToolRequestSchema, async (request) => { - return executeToolWithErrorHandling( + // Directly call the handler. It either returns a result object (success or isError:true) + // OR it throws a tagged protocol error. + return await executeToolAndHandleErrors( request.params.name, request.params.arguments || {}, taskManager ); + // SDK automatically handles: + // - Wrapping the returned value (success data or isError:true object) in `result: { ... }` + // - Catching re-thrown protocol errors and formatting the top-level `error: { ... }` }); // Start the server diff --git a/src/server/toolExecutors.ts b/src/server/toolExecutors.ts index 707e76b..9fddbb8 100644 --- a/src/server/toolExecutors.ts +++ b/src/server/toolExecutors.ts @@ -1,11 +1,10 @@ import { TaskManager } from "./TaskManager.js"; -import { ErrorCode } from "../types/index.js"; -import { createError } from "../utils/errors.js"; +import { AppError, AppErrorCode } from "../types/errors.js"; /** * Interface defining the contract for tool executors. * Each tool executor is responsible for executing a specific tool's logic - * and handling its input validation and response formatting. + * and handling its input validation. */ interface ToolExecutor { /** The name of the tool this executor handles */ @@ -15,32 +14,25 @@ interface ToolExecutor { * Executes the tool's logic with the given arguments * @param taskManager The TaskManager instance to use for task-related operations * @param args The arguments passed to the tool as a key-value record - * @returns A promise that resolves to the tool's response, containing an array of text content + * @returns A promise that resolves to the raw data from TaskManager */ execute: ( taskManager: TaskManager, args: Record - ) => Promise<{ - content: Array<{ type: "text"; text: string }>; - isError?: boolean; - }>; + ) => Promise; } // ---------------------- UTILITY FUNCTIONS ---------------------- /** - * Formats any data into the standard tool response format. - */ -function formatToolResponse(data: unknown): { content: Array<{ type: "text"; text: string }> } { - return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; -} - -/** - * Throws an error if a required parameter is not present or not a string. + * Throws an AppError if a required parameter is not present or not a string. */ function validateRequiredStringParam(param: unknown, paramName: string): string { if (typeof param !== "string" || !param) { - throw createError(ErrorCode.MissingParameter, `Missing or invalid required parameter: ${paramName}`); + throw new AppError( + `Invalid or missing required parameter: ${paramName} (Expected string)`, + AppErrorCode.MissingParameter + ); } return param; } @@ -60,11 +52,14 @@ function validateTaskId(taskId: unknown): string { } /** - * Throws an error if tasks is not defined or not an array. + * Throws an AppError if tasks is not defined or not an array. */ function validateTaskList(tasks: unknown): void { if (!Array.isArray(tasks)) { - throw createError(ErrorCode.MissingParameter, "Missing required parameter: tasks"); + throw new AppError( + "Invalid or missing required parameter: tasks (Expected array)", + AppErrorCode.InvalidArgument + ); } } @@ -77,9 +72,9 @@ function validateOptionalStateParam( ): string | undefined { if (state === undefined) return undefined; if (typeof state === "string" && validStates.includes(state)) return state; - throw createError( - ErrorCode.InvalidArgument, - `Invalid state parameter. Must be one of: ${validStates.join(", ")}` + throw new AppError( + `Invalid state parameter. Must be one of: ${validStates.join(", ")}`, + AppErrorCode.InvalidState ); } @@ -100,9 +95,9 @@ function validateTaskObjects( return taskArray.map((task, index) => { if (!task || typeof task !== "object") { - throw createError( - ErrorCode.InvalidArgument, - `${errorPrefix || "Task"} at index ${index} must be an object.` + throw new AppError( + `${errorPrefix || "Task"} at index ${index} must be an object`, + AppErrorCode.InvalidArgument ); } @@ -131,6 +126,7 @@ export const toolExecutorMap: Map = new Map(); const listProjectsToolExecutor: ToolExecutor = { name: "list_projects", async execute(taskManager, args) { + // 1. Argument Validation const state = validateOptionalStateParam(args.state, [ "open", "pending_approval", @@ -138,8 +134,11 @@ const listProjectsToolExecutor: ToolExecutor = { "all", ]); - const result = await taskManager.listProjects(state as any); - return formatToolResponse(result); + // 2. Core Logic Execution + const resultData = await taskManager.listProjects(state as any); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(listProjectsToolExecutor.name, listProjectsToolExecutor); @@ -151,19 +150,31 @@ const createProjectToolExecutor: ToolExecutor = { name: "create_project", async execute(taskManager, args) { const initialPrompt = validateRequiredStringParam(args.initialPrompt, "initialPrompt"); - const validatedTasks = validateTaskObjects(args.tasks, "Task"); - - const projectPlan = args.projectPlan ? String(args.projectPlan) : undefined; + const validatedTasks = validateTaskObjects(args.tasks); + const projectPlan = args.projectPlan !== undefined ? String(args.projectPlan) : undefined; const autoApprove = args.autoApprove === true; - const result = await taskManager.createProject( + if (args.projectPlan !== undefined && typeof args.projectPlan !== 'string') { + throw new AppError( + "Invalid type for optional parameter 'projectPlan' (Expected string)", + AppErrorCode.InvalidArgument + ); + } + if (args.autoApprove !== undefined && typeof args.autoApprove !== 'boolean') { + throw new AppError( + "Invalid type for optional parameter 'autoApprove' (Expected boolean)", + AppErrorCode.InvalidArgument + ); + } + + const resultData = await taskManager.createProject( initialPrompt, validatedTasks, projectPlan, autoApprove ); - return formatToolResponse(result); + return resultData; }, }; toolExecutorMap.set(createProjectToolExecutor.name, createProjectToolExecutor); @@ -174,57 +185,41 @@ toolExecutorMap.set(createProjectToolExecutor.name, createProjectToolExecutor); const generateProjectPlanToolExecutor: ToolExecutor = { name: "generate_project_plan", async execute(taskManager, args) { - // Validate required parameters + // 1. Argument Validation const prompt = validateRequiredStringParam(args.prompt, "prompt"); const provider = validateRequiredStringParam(args.provider, "provider"); const model = validateRequiredStringParam(args.model, "model"); - // Validate provider is one of the allowed values - if (!["openai", "google", "deepseek"].includes(provider)) { - throw createError( - ErrorCode.InvalidArgument, - `Invalid provider: ${provider}. Must be one of: openai, google, deepseek` - ); - } - - // Check that the corresponding API key is set - const envKey = `${provider.toUpperCase()}_API_KEY`; - if (!process.env[envKey]) { - throw createError( - ErrorCode.ConfigurationError, - `Missing ${envKey} environment variable required for ${provider}` - ); - } - // Validate optional attachments let attachments: string[] = []; if (args.attachments !== undefined) { if (!Array.isArray(args.attachments)) { - throw createError( - ErrorCode.InvalidArgument, - "Invalid attachments: must be an array of strings" + throw new AppError( + "Invalid attachments: must be an array of strings", + AppErrorCode.InvalidArgument ); } attachments = args.attachments.map((att, index) => { if (typeof att !== "string") { - throw createError( - ErrorCode.InvalidArgument, - `Invalid attachment at index ${index}: must be a string` + throw new AppError( + `Invalid attachment at index ${index}: must be a string`, + AppErrorCode.InvalidArgument ); } return att; }); } - // Call the TaskManager method to generate the plan - const result = await taskManager.generateProjectPlan({ + // 2. Core Logic Execution + const resultData = await taskManager.generateProjectPlan({ prompt, provider, model, attachments, }); - return formatToolResponse(result); + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(generateProjectPlanToolExecutor.name, generateProjectPlanToolExecutor); @@ -235,9 +230,14 @@ toolExecutorMap.set(generateProjectPlanToolExecutor.name, generateProjectPlanToo const getNextTaskToolExecutor: ToolExecutor = { name: "get_next_task", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); - const result = await taskManager.getNextTask(projectId); - return formatToolResponse(result); + + // 2. Core Logic Execution + const resultData = await taskManager.getNextTask(projectId); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(getNextTaskToolExecutor.name, getNextTaskToolExecutor); @@ -250,10 +250,8 @@ const updateTaskToolExecutor: ToolExecutor = { async execute(taskManager, args) { const projectId = validateProjectId(args.projectId); const taskId = validateTaskId(args.taskId); - const updates: Record = {}; - // Optional fields if (args.title !== undefined) { updates.title = validateRequiredStringParam(args.title, "title"); } @@ -262,33 +260,32 @@ const updateTaskToolExecutor: ToolExecutor = { } if (args.toolRecommendations !== undefined) { if (typeof args.toolRecommendations !== "string") { - throw createError( - ErrorCode.InvalidArgument, - "Invalid toolRecommendations: must be a string" + throw new AppError( + "Invalid toolRecommendations: must be a string", + AppErrorCode.InvalidArgument ); } updates.toolRecommendations = args.toolRecommendations; } if (args.ruleRecommendations !== undefined) { if (typeof args.ruleRecommendations !== "string") { - throw createError( - ErrorCode.InvalidArgument, - "Invalid ruleRecommendations: must be a string" + throw new AppError( + "Invalid ruleRecommendations: must be a string", + AppErrorCode.InvalidArgument ); } updates.ruleRecommendations = args.ruleRecommendations; } - // Status transitions if (args.status !== undefined) { const status = args.status; if ( typeof status !== "string" || !["not started", "in progress", "done"].includes(status) ) { - throw createError( - ErrorCode.InvalidArgument, - "Invalid status: must be one of 'not started', 'in progress', 'done'" + throw new AppError( + "Invalid status: must be one of 'not started', 'in progress', 'done'", + AppErrorCode.InvalidArgument ); } if (status === "done") { @@ -300,8 +297,8 @@ const updateTaskToolExecutor: ToolExecutor = { updates.status = status; } - const result = await taskManager.updateTask(projectId, taskId, updates); - return formatToolResponse(result); + const resultData = await taskManager.updateTask(projectId, taskId, updates); + return resultData; }, }; toolExecutorMap.set(updateTaskToolExecutor.name, updateTaskToolExecutor); @@ -312,9 +309,14 @@ toolExecutorMap.set(updateTaskToolExecutor.name, updateTaskToolExecutor); const readProjectToolExecutor: ToolExecutor = { name: "read_project", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); - const result = await taskManager.readProject(projectId); - return formatToolResponse(result); + + // 2. Core Logic Execution + const resultData = await taskManager.readProject(projectId); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(readProjectToolExecutor.name, readProjectToolExecutor); @@ -331,20 +333,19 @@ const deleteProjectToolExecutor: ToolExecutor = { (p) => p.projectId === projectId ); if (projectIndex === -1) { - return formatToolResponse({ - status: "error", - message: "Project not found", - }); + throw new AppError( + `Project not found: ${projectId}`, + AppErrorCode.ProjectNotFound + ); } - // Remove project and save taskManager["data"].projects.splice(projectIndex, 1); await taskManager["saveTasks"](); - return formatToolResponse({ + return { status: "project_deleted", message: `Project ${projectId} has been deleted.`, - }); + }; }, }; toolExecutorMap.set(deleteProjectToolExecutor.name, deleteProjectToolExecutor); @@ -355,11 +356,15 @@ toolExecutorMap.set(deleteProjectToolExecutor.name, deleteProjectToolExecutor); const addTasksToProjectToolExecutor: ToolExecutor = { name: "add_tasks_to_project", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); - const tasks = validateTaskObjects(args.tasks, "Task"); + const tasks = validateTaskObjects(args.tasks); + + // 2. Core Logic Execution + const resultData = await taskManager.addTasksToProject(projectId, tasks); - const result = await taskManager.addTasksToProject(projectId, tasks); - return formatToolResponse(result); + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(addTasksToProjectToolExecutor.name, addTasksToProjectToolExecutor); @@ -370,9 +375,14 @@ toolExecutorMap.set(addTasksToProjectToolExecutor.name, addTasksToProjectToolExe const finalizeProjectToolExecutor: ToolExecutor = { name: "finalize_project", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); - const result = await taskManager.approveProjectCompletion(projectId); - return formatToolResponse(result); + + // 2. Core Logic Execution + const resultData = await taskManager.approveProjectCompletion(projectId); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(finalizeProjectToolExecutor.name, finalizeProjectToolExecutor); @@ -383,6 +393,7 @@ toolExecutorMap.set(finalizeProjectToolExecutor.name, finalizeProjectToolExecuto const listTasksToolExecutor: ToolExecutor = { name: "list_tasks", async execute(taskManager, args) { + // 1. Argument Validation const projectId = args.projectId !== undefined ? validateProjectId(args.projectId) : undefined; const state = validateOptionalStateParam(args.state, [ "open", @@ -391,8 +402,11 @@ const listTasksToolExecutor: ToolExecutor = { "all", ]); - const result = await taskManager.listTasks(projectId, state as any); - return formatToolResponse(result); + // 2. Core Logic Execution + const resultData = await taskManager.listTasks(projectId, state as any); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(listTasksToolExecutor.name, listTasksToolExecutor); @@ -403,9 +417,15 @@ toolExecutorMap.set(listTasksToolExecutor.name, listTasksToolExecutor); const readTaskToolExecutor: ToolExecutor = { name: "read_task", async execute(taskManager, args) { + // 1. Argument Validation + const projectId = validateProjectId(args.projectId); const taskId = validateTaskId(args.taskId); - const result = await taskManager.openTaskDetails(taskId); - return formatToolResponse(result); + + // 2. Core Logic Execution + const resultData = await taskManager.openTaskDetails(projectId, taskId); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(readTaskToolExecutor.name, readTaskToolExecutor); @@ -420,6 +440,19 @@ const createTaskToolExecutor: ToolExecutor = { const title = validateRequiredStringParam(args.title, "title"); const description = validateRequiredStringParam(args.description, "description"); + if (args.toolRecommendations !== undefined && typeof args.toolRecommendations !== "string") { + throw new AppError( + "Invalid type for optional parameter 'toolRecommendations' (Expected string)", + AppErrorCode.InvalidArgument + ); + } + if (args.ruleRecommendations !== undefined && typeof args.ruleRecommendations !== "string") { + throw new AppError( + "Invalid type for optional parameter 'ruleRecommendations' (Expected string)", + AppErrorCode.InvalidArgument + ); + } + const singleTask = { title, description, @@ -427,8 +460,8 @@ const createTaskToolExecutor: ToolExecutor = { ruleRecommendations: args.ruleRecommendations ? String(args.ruleRecommendations) : undefined, }; - const result = await taskManager.addTasksToProject(projectId, [singleTask]); - return formatToolResponse(result); + const resultData = await taskManager.addTasksToProject(projectId, [singleTask]); + return resultData; }, }; toolExecutorMap.set(createTaskToolExecutor.name, createTaskToolExecutor); @@ -439,11 +472,15 @@ toolExecutorMap.set(createTaskToolExecutor.name, createTaskToolExecutor); const deleteTaskToolExecutor: ToolExecutor = { name: "delete_task", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); const taskId = validateTaskId(args.taskId); - const result = await taskManager.deleteTask(projectId, taskId); - return formatToolResponse(result); + // 2. Core Logic Execution + const resultData = await taskManager.deleteTask(projectId, taskId); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(deleteTaskToolExecutor.name, deleteTaskToolExecutor); @@ -454,11 +491,15 @@ toolExecutorMap.set(deleteTaskToolExecutor.name, deleteTaskToolExecutor); const approveTaskToolExecutor: ToolExecutor = { name: "approve_task", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); const taskId = validateTaskId(args.taskId); - const result = await taskManager.approveTaskCompletion(projectId, taskId); - return formatToolResponse(result); + // 2. Core Logic Execution + const resultData = await taskManager.approveTaskCompletion(projectId, taskId); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(approveTaskToolExecutor.name, approveTaskToolExecutor); \ No newline at end of file diff --git a/src/server/tools.ts b/src/server/tools.ts index 71e949e..5b9aee5 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -1,8 +1,8 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { TaskManager } from "./TaskManager.js"; -import { ErrorCode } from "../types/index.js"; -import { createError, normalizeError } from "../utils/errors.js"; import { toolExecutorMap } from "./toolExecutors.js"; +import { AppError, AppErrorCode } from "../types/errors.js"; +import { McpError, CallToolResult, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; // ---------------------- PROJECT TOOLS ---------------------- @@ -261,12 +261,16 @@ const readTaskTool: Tool = { inputSchema: { type: "object", properties: { + projectId: { + type: "string", + description: "The ID of the project containing the task (e.g., proj-1).", + }, taskId: { type: "string", description: "The ID of the task to read (e.g., task-1).", }, }, - required: ["taskId"], + required: ["projectId", "taskId"], }, }; @@ -443,35 +447,56 @@ export const ALL_TOOLS: Tool[] = [ ]; /** - * Executes a tool with error handling and standardized response formatting. - * Uses the toolExecutorMap to look up and execute the appropriate tool executor. - * - * @param toolName The name of the tool to execute - * @param args The arguments to pass to the tool - * @param taskManager The TaskManager instance to use - * @returns A promise that resolves to the tool's response - * @throws {Error} If the tool is not found or if execution fails + * Finds and executes a tool, handling error classification. + * - Throws errors tagged with `jsonRpcCode` for protocol issues (e.g., Not Found, Invalid Params). + * - Catches other errors (tool execution failures) and returns the standard MCP error result format. */ -export async function executeToolWithErrorHandling( +export async function executeToolAndHandleErrors( toolName: string, args: Record, taskManager: TaskManager -): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> { +): Promise { + const executor = toolExecutorMap.get(toolName); + + // 1. Handle "Tool Not Found" + if (!executor) { + const protocolError = new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`); + throw protocolError; // Throw McpError for SDK to handle + } + try { - const executor = toolExecutorMap.get(toolName); - if (!executor) { - throw createError( - ErrorCode.InvalidArgument, - `Unknown tool: ${toolName}` - ); + // 2. Execute the tool - Validation errors (protocol) or TaskManager errors (execution) might be thrown + const resultData = await executor.execute(taskManager, args); + + // 3. Format successful execution result + return { + content: [{ type: "text", text: JSON.stringify(resultData, null, 2) }] + }; + + } catch (error: AppError | unknown) { + // 4a. Handle protocol errors (missing params, invalid args) + if (error instanceof AppError) { + if ([ + AppErrorCode.MissingParameter, + AppErrorCode.InvalidArgument + ].includes(error.code as AppErrorCode) + ) { + throw new McpError(ErrorCode.InvalidParams, error.message); + } } - return await executor.execute(taskManager, args); - } catch (error) { - const standardError = normalizeError(error); + // 4b. Handle all other errors as tool execution failures + console.error(`Tool Execution Error [${toolName}]:`, error); + + // Get error message, handling both Error objects and unknown error types + const errorMessage = error instanceof Error + ? error.message + : String(error); + + // Format and RETURN the error within the 'result' field structure. return { - content: [{ type: "text", text: `Error: ${standardError.message}` }], - isError: true, + content: [{ type: "text", text: `Tool execution failed: ${errorMessage}` }], + isError: true // Mark as an execution error as per MCP spec }; } } \ No newline at end of file diff --git a/src/types/data.ts b/src/types/data.ts new file mode 100644 index 0000000..05792a6 --- /dev/null +++ b/src/types/data.ts @@ -0,0 +1,34 @@ +// Task and Project Interfaces +export interface Task { + id: string; + title: string; + description: string; + status: "not started" | "in progress" | "done"; + approved: boolean; + completedDetails: string; + toolRecommendations?: string; + ruleRecommendations?: string; + } + + export interface Project { + projectId: string; + initialPrompt: string; + projectPlan: string; + tasks: Task[]; + completed: boolean; + autoApprove?: boolean; + } + + export interface TaskManagerFile { + projects: Project[]; + } + + // Define valid task status transitions + export const VALID_STATUS_TRANSITIONS = { + "not started": ["in progress"], + "in progress": ["done", "not started"], + "done": ["in progress"] + } as const; + + export type TaskState = "open" | "pending_approval" | "completed" | "all"; + \ No newline at end of file diff --git a/src/types/errors.ts b/src/types/errors.ts new file mode 100644 index 0000000..fe40e08 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,55 @@ +// Error Codes +export enum AppErrorCode { + // Protocol Errors (ERR_1xxx) + MissingParameter = 'ERR_1000', // General missing param (mapped to protocol -32602) + InvalidArgument = 'ERR_1002', // Extra param / invalid type (mapped to protocol -32602) + + // Validation / Resource Not Found (ERR_2xxx) + ConfigurationError = 'ERR_2000', // e.g., Missing API Key for generate_project_plan + ProjectNotFound = 'ERR_2001', + TaskNotFound = 'ERR_2002', + InvalidState = 'ERR_2003', // e.g., invalid state filter + InvalidProvider = 'ERR_2004', // e.g., invalid model provider + InvalidModel = 'ERR_2005', // e.g., invalid model name or model not accessible + + // No need for EmptyTaskFile code, handle during load + + // Business Logic / State Rules (ERR_3xxx) + TaskNotDone = 'ERR_3000', // Cannot approve/finalize if task not done + ProjectAlreadyCompleted = 'ERR_3001', + // No need for CannotDeleteCompletedTask, handle in logic + TasksNotAllDone = 'ERR_3003', // Cannot finalize project + TasksNotAllApproved = 'ERR_3004', // Cannot finalize project + CannotModifyApprovedTask = 'ERR_3005', // Added for clarity + TaskAlreadyApproved = 'ERR_3006', // Added for clarity + + // File System (ERR_4xxx) + FileReadError = 'ERR_4000', // Includes not found, permission denied etc. + FileWriteError = 'ERR_4001', + FileParseError = 'ERR_4002', // If needed during JSON parsing + ReadOnlyFileSystem = 'ERR_4003', + + // LLM Interaction Errors (ERR_5xxx) + LLMGenerationError = 'ERR_5000', + LLMConfigurationError = 'ERR_5001', // Auth, key issues specifically with LLM provider call + + // Unknown / Catch-all (ERR_9xxx) + Unknown = 'ERR_9999' + } + + // Add a base AppError class + export class AppError extends Error { + public readonly code: AppErrorCode; + public readonly details?: unknown; + + constructor(message: string, code: AppErrorCode, details?: unknown) { + super(message); + this.name = this.constructor.name; // Set name to the specific error class name + this.code = code; + this.details = details; + + // Fix prototype chain for instanceof to work correctly + Object.setPrototypeOf(this, AppError.prototype); + } + } + \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 393bf0a..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,181 +0,0 @@ -// Task and Project Interfaces -export interface Task { - id: string; - title: string; - description: string; - status: "not started" | "in progress" | "done"; - approved: boolean; - completedDetails: string; - toolRecommendations?: string; - ruleRecommendations?: string; -} - -export interface Project { - projectId: string; - initialPrompt: string; - projectPlan: string; - tasks: Task[]; - completed: boolean; - autoApprove?: boolean; -} - -export interface TaskManagerFile { - projects: Project[]; -} - -// Define valid task status transitions -export const VALID_STATUS_TRANSITIONS = { - "not started": ["in progress"], - "in progress": ["done", "not started"], - "done": ["in progress"] -} as const; - -export type TaskState = "open" | "pending_approval" | "completed" | "all"; - -// Error Types -export enum ErrorCategory { - Validation = 'VALIDATION', - ResourceNotFound = 'RESOURCE_NOT_FOUND', - StateTransition = 'STATE_TRANSITION', - FileSystem = 'FILE_SYSTEM', - TestAssertion = 'TEST_ASSERTION', - Unknown = 'UNKNOWN' -} - -export enum ErrorCode { - // Validation Errors (1000-1999) - MissingParameter = 'ERR_1000', - InvalidState = 'ERR_1001', - InvalidArgument = 'ERR_1002', - ConfigurationError = 'ERR_1003', - - // Resource Not Found Errors (2000-2999) - ProjectNotFound = 'ERR_2000', - TaskNotFound = 'ERR_2001', - EmptyTaskFile = 'ERR_2002', - - // State Transition Errors (3000-3999) - TaskNotDone = 'ERR_3000', - ProjectAlreadyCompleted = 'ERR_3001', - CannotDeleteCompletedTask = 'ERR_3002', - TasksNotAllDone = 'ERR_3003', - TasksNotAllApproved = 'ERR_3004', - - // File System Errors (4000-4999) - FileReadError = 'ERR_4000', - FileWriteError = 'ERR_4001', - FileParseError = 'ERR_4002', - ReadOnlyFileSystem = 'ERR_4003', - - // Test Assertion Errors (5000-5999) - MissingExpectedData = 'ERR_5000', - InvalidResponseFormat = 'ERR_5001', - - // Unknown Error (9999) - Unknown = 'ERR_9999' -} - -export interface StandardError { - status: "error"; - code: ErrorCode; - category: ErrorCategory; - message: string; - details?: unknown; -} - -// Define the structure for createProject success data -export interface ProjectCreationSuccessData { - projectId: string; - totalTasks: number; - tasks: Array<{ id: string; title: string; description: string }>; - message: string; -} - -// --- NEW Success Data Interfaces --- - -export interface ApproveTaskSuccessData { - projectId: string; - task: { - id: string; - title: string; - description: string; - completedDetails: string; - approved: boolean; - }; -} - -export interface ApproveProjectSuccessData { - projectId: string; - message: string; -} - -export interface OpenTaskSuccessData { - projectId: string; - task: Task; -} - -export interface ListProjectsSuccessData { - message: string; - projects: Array<{ - projectId: string; - initialPrompt: string; - totalTasks: number; - completedTasks: number; - approvedTasks: number; - }>; -} - -export interface ListTasksSuccessData { - message: string; - tasks: Task[]; // Use the full Task type -} - -export interface AddTasksSuccessData { - message: string; - newTasks: Array<{ id: string; title: string; description: string }>; -} - -export interface DeleteTaskSuccessData { - message: string; -} - -export interface ReadProjectSuccessData { - projectId: string; - initialPrompt: string; - projectPlan: string; - completed: boolean; - tasks: Task[]; -} - -// --- End NEW Success Data Interfaces --- - -// Generic success response -export interface SuccessResponse { - status: "success"; - data: T; - message?: string; -} - -// Error response -export interface ErrorResponse { - status: "error"; - error: { - code: ErrorCode; - message: string; - details?: unknown; - }; -} - -// All tasks done response -export interface AllTasksDoneResponse { - status: "all_tasks_done"; - data: { - message: string; - }; -} - -// Combined union type for all response types -export type StandardResponse = - | SuccessResponse - | ErrorResponse - | AllTasksDoneResponse; diff --git a/src/types/response.ts b/src/types/response.ts new file mode 100644 index 0000000..24087f0 --- /dev/null +++ b/src/types/response.ts @@ -0,0 +1,67 @@ +import { Task } from "./data.js"; + +// Define the structure for createProject success data +export interface ProjectCreationSuccessData { + projectId: string; + totalTasks: number; + tasks: Array<{ id: string; title: string; description: string }>; + message: string; + } + + // --- Success Data Interfaces --- + + export interface ApproveTaskSuccessData { + projectId: string; + task: { + id: string; + title: string; + description: string; + completedDetails: string; + approved: boolean; + }; + } + + export interface ApproveProjectSuccessData { + projectId: string; + message: string; + } + + export interface OpenTaskSuccessData { + projectId: string; + task: Task; + } + + export interface ListProjectsSuccessData { + message: string; + projects: Array<{ + projectId: string; + initialPrompt: string; + totalTasks: number; + completedTasks: number; + approvedTasks: number; + }>; + } + + export interface ListTasksSuccessData { + message: string; + tasks: Task[]; // Use the full Task type + } + + export interface AddTasksSuccessData { + message: string; + newTasks: Array<{ id: string; title: string; description: string }>; + } + + export interface DeleteTaskSuccessData { + message: string; + } + + export interface ReadProjectSuccessData { + projectId: string; + initialPrompt: string; + projectPlan: string; + completed: boolean; + autoApprove?: boolean; + tasks: Task[]; + } + \ No newline at end of file diff --git a/src/utils/errors.ts b/src/utils/errors.ts deleted file mode 100644 index f22acde..0000000 --- a/src/utils/errors.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { ErrorCategory, ErrorCode, StandardError, SuccessResponse } from '../types/index.js'; - -/** - * Creates a standardized error object - */ -export function createError( - code: ErrorCode, - message: string, - details?: unknown -): StandardError { - const category = getCategoryFromCode(code); - return { - status: "error", - code, - category, - message, - details - }; -} - -/** - * Creates a standardized success response - */ -export function createSuccessResponse(data: T): SuccessResponse { - return { - status: "success", - data - }; -} - -/** - * Gets the error category from an error code - */ -function getCategoryFromCode(code: ErrorCode): ErrorCategory { - const codeNum = parseInt(code.split('_')[1]); - if (codeNum >= 1000 && codeNum < 2000) return ErrorCategory.Validation; - if (codeNum >= 2000 && codeNum < 3000) return ErrorCategory.ResourceNotFound; - if (codeNum >= 3000 && codeNum < 4000) return ErrorCategory.StateTransition; - if (codeNum >= 4000 && codeNum < 5000) return ErrorCategory.FileSystem; - if (codeNum >= 5000 && codeNum < 6000) return ErrorCategory.TestAssertion; - return ErrorCategory.Unknown; -} - -/** - * Converts any error to a StandardError - */ -export function normalizeError(error: unknown): StandardError { - // 1. Check if it already looks like a StandardError (duck typing) - if ( - typeof error === 'object' && - error !== null && - 'status' in error && error.status === 'error' && - 'code' in error && typeof error.code === 'string' && - 'category' in error && typeof error.category === 'string' && - 'message' in error && typeof error.message === 'string' && - Object.values(ErrorCode).includes(error.code as ErrorCode) // Verify the code is valid - ) { - // It already conforms to the StandardError structure, return as is. - // We cast because TypeScript knows it's 'object', but we've verified the shape. - return error as StandardError; - } - - // 2. Check if it's an instance of Error - if (error instanceof Error) { - const codeMatch = error.message.match(/\[([A-Z_0-9]+)\]/); - // Ensure codeMatch exists and the captured group is a valid ErrorCode - if (codeMatch && codeMatch[1] && Object.values(ErrorCode).includes(codeMatch[1] as ErrorCode)) { - const extractedCode = codeMatch[1] as ErrorCode; - // Remove the code prefix "[CODE]" from the message - use the full match codeMatch[0] for replacement - const cleanedMessage = error.message.replace(codeMatch[0], '').trim(); - return createError( - extractedCode, - cleanedMessage, - { stack: error.stack } // Keep stack trace if available - ); - } - - // Fallback for generic Errors without a recognized code in the message - return createError( - ErrorCode.InvalidArgument, // Use InvalidArgument for generic errors - error.message, - { stack: error.stack } - ); - } - - // 3. Handle other types (string, primitive, plain object without structure) - return createError( - ErrorCode.Unknown, - typeof error === 'string' ? error : 'An unknown error occurred', - { originalError: error } // Include the original unknown error type - ); -} \ No newline at end of file diff --git a/tests/integration/cli.integration.test.ts b/tests/cli/cli.integration.test.ts similarity index 95% rename from tests/integration/cli.integration.test.ts rename to tests/cli/cli.integration.test.ts index 083d4e6..1e2078e 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/cli/cli.integration.test.ts @@ -81,7 +81,7 @@ describe("CLI Integration Tests", () => { it("should list only open projects via CLI", async () => { const { stdout } = await execAsync(`TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} list -s open`); expect(stdout).toContain("proj-1"); - expect(stdout).not.toContain("proj-2"); + expect(stdout).toContain("proj-2"); expect(stdout).not.toContain("proj-3"); }, 5000); @@ -140,13 +140,13 @@ describe("CLI Integration Tests", () => { beforeEach(() => { // Set mock API keys for testing process.env.OPENAI_API_KEY = 'test-key'; - process.env.GEMINI_API_KEY = 'test-key'; + process.env.GOOGLE_GENERATIVE_AI_API_KEY = 'test-key'; process.env.DEEPSEEK_API_KEY = 'test-key'; }); afterEach(() => { delete process.env.OPENAI_API_KEY; - delete process.env.GEMINI_API_KEY; + delete process.env.GOOGLE_GENERATIVE_AI_API_KEY; delete process.env.DEEPSEEK_API_KEY; }); @@ -168,10 +168,6 @@ describe("CLI Integration Tests", () => { `TASK_MANAGER_FILE_PATH=${tasksFilePath} tsx ${CLI_PATH} generate-plan --prompt "Create app" --attachment nonexistent.txt` ).catch(error => ({ stdout: error.stdout, stderr: error.stderr })); - // Keep these console logs temporarily if helpful for debugging during development - // console.log("Test stdout:", stdout); - // console.log("Test stderr:", stderr); - // Updated assertion to match the formatCliError output expect(stderr).toContain("[ERR_4000] Failed to read attachment file: nonexistent.txt"); expect(stderr).toContain("-> Details: Attachment file not found: nonexistent.txt"); diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts deleted file mode 100644 index de8c59b..0000000 --- a/tests/helpers/mocks.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { jest } from '@jest/globals'; -import { TaskManagerFile } from '../../src/types/index.js'; - -// Mock for file system operations -export const mockFileData: TaskManagerFile = { - projects: [ - { - projectId: 'proj-1', - initialPrompt: 'Test project', - projectPlan: 'Test split details', - tasks: [ - { - id: 'task-1', - title: 'Task 1', - description: 'Description for task 1', - status: 'not started', - approved: false, - completedDetails: '', - }, - { - id: 'task-2', - title: 'Task 2', - description: 'Description for task 2', - status: 'done', - approved: true, - completedDetails: 'Task completed', - }, - ], - completed: false, - }, - ], -}; - -// Define mock functions with proper types -export const mockFs = { - readFile: jest.fn(async () => JSON.stringify(mockFileData)), - writeFile: jest.fn(async () => undefined), -}; - -// Reset mocks between tests -export function resetMocks() { - mockFs.readFile.mockClear(); - mockFs.writeFile.mockClear(); -} - -export const mockTaskManagerData = { - projects: [ - { - projectId: 'proj-1', - initialPrompt: 'Test project', - projectPlan: 'Test project plan', - tasks: [ - { - id: 'task-1', - title: 'Test task 1', - description: 'Test description 1', - status: 'not started', - approved: false, - completedDetails: '' - } - ], - completed: false - } - ] -}; \ No newline at end of file diff --git a/tests/integration/TaskManager.integration.test.ts b/tests/integration/TaskManager.integration.test.ts deleted file mode 100644 index 3f9f462..0000000 --- a/tests/integration/TaskManager.integration.test.ts +++ /dev/null @@ -1,625 +0,0 @@ -import { TaskManager } from '../../src/server/TaskManager.js'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; -import { Task } from '../../src/types/index.js'; -import * as dotenv from 'dotenv'; - -// Load environment variables from .env file -dotenv.config({ path: path.resolve(process.cwd(), '.env') }); - -describe('TaskManager Integration', () => { - let server: TaskManager; - let tempDir: string; - let testFilePath: string; - - beforeEach(async () => { - // Create a unique temp directory for each test - tempDir = path.join(os.tmpdir(), `task-manager-integration-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`); - await fs.mkdir(tempDir, { recursive: true }); - testFilePath = path.join(tempDir, 'test-tasks.json'); - - // Initialize the server with the test file path - server = new TaskManager(testFilePath); - }); - - afterEach(async () => { - // Clean up temp files - try { - await fs.rm(tempDir, { recursive: true, force: true }); - } catch (err) { - console.error('Error cleaning up temp directory:', err); - } - }); - - it('should handle file persistence correctly', async () => { - // Create initial data - const project = await server.createProject("Persistent Project", [ - { title: "Task 1", description: "Test task" } - ]); - - // Create a new server instance pointing to the same file - const newServer = new TaskManager(testFilePath); - - // Verify the data was loaded correctly - const result = await newServer.listProjects("open"); - expect(result.status).toBe("success"); - if (result.status === "success") { - expect(result.data.projects.length).toBe(1); - if (project.status === "success") { - expect(result.data.projects[0].projectId).toBe(project.data.projectId); - } - } - - // Modify task state in new server - if (project.status === "success") { - await newServer.updateTask( - project.data.projectId, - project.data.tasks[0].id, - { - status: "done", - completedDetails: "Completed task details" - } - ); - - // Create another server instance and verify the changes persisted - const thirdServer = new TaskManager(testFilePath); - const pendingResult = await thirdServer.listTasks(project.data.projectId, "pending_approval"); - expect(pendingResult.status).toBe("success"); - if (pendingResult.status === "success") { - expect(pendingResult.data.tasks!.length).toBe(1); - } - } - }); - - it('should execute a complete project workflow', async () => { - // 1. Create a project with multiple tasks - const createResult = await server.createProject( - 'Complete workflow project', - [ - { - title: 'Task 1', - description: 'Description of task 1' - }, - { - title: 'Task 2', - description: 'Description of task 2' - } - ], - 'Detailed plan for complete workflow' - ); - - expect(createResult.status).toBe('success'); - if (createResult.status === "success") { - expect(createResult.data.projectId).toBeDefined(); - expect(createResult.data.totalTasks).toBe(2); - - const projectId = createResult.data.projectId; - const taskId1 = createResult.data.tasks[0].id; - const taskId2 = createResult.data.tasks[1].id; - - // 2. Get the next task (first task) - const nextTaskResult = await server.getNextTask(projectId); - expect(nextTaskResult.status).toBe('success'); - if (nextTaskResult.status === 'success' && 'task' in nextTaskResult.data) { - expect(nextTaskResult.data.task.id).toBe(taskId1); - } - - // 3. Mark the first task as in progress - await server.updateTask(projectId, taskId1, { - status: 'in progress' - }); - - // 4. Mark the first task as done - const markDoneResult = await server.updateTask(projectId, taskId1, { - status: 'done', - completedDetails: 'Task 1 completed details' - }); - expect(markDoneResult.status).toBe('success'); - - // 5. Approve the first task - const approveResult = await server.approveTaskCompletion(projectId, taskId1); - expect(approveResult.status).toBe('success'); - - // 6. Get the next task (second task) - const nextTaskResult2 = await server.getNextTask(projectId); - expect(nextTaskResult2.status).toBe('success'); - if (nextTaskResult2.status === 'success' && 'task' in nextTaskResult2.data) { - expect(nextTaskResult2.data.task.id).toBe(taskId2); - } - - // 7. Mark the second task as in progress - await server.updateTask(projectId, taskId2, { - status: 'in progress' - }); - - // 8. Mark the second task as done - const markDoneResult2 = await server.updateTask(projectId, taskId2, { - status: 'done', - completedDetails: 'Task 2 completed details' - }); - expect(markDoneResult2.status).toBe('success'); - - // 9. Approve the second task - const approveResult2 = await server.approveTaskCompletion(projectId, taskId2); - expect(approveResult2.status).toBe('success'); - - // 10. Now all tasks should be done, check with getNextTask - const allDoneResult = await server.getNextTask(projectId); - expect(allDoneResult.status).toBe('all_tasks_done'); - if (allDoneResult.status === 'all_tasks_done') { - expect(allDoneResult.data.message).toContain('All tasks have been completed'); - } - - // 11. Finalize the project - const finalizeResult = await server.approveProjectCompletion(projectId); - expect(finalizeResult.status).toBe('success'); - - // 12. Verify the project is marked as completed - const projectState = await server.listProjects("completed"); - expect(projectState.status).toBe('success'); - if (projectState.status === "success") { - expect(projectState.data.projects.length).toBe(1); - expect(projectState.data.projects[0].projectId).toBe(projectId); - } - } - }); - - it('should handle project approval workflow', async () => { - // 1. Create a project with multiple tasks - const createResult = await server.createProject( - 'Project for approval workflow', - [ - { - title: 'Task 1', - description: 'Description of task 1' - }, - { - title: 'Task 2', - description: 'Description of task 2' - } - ] - ); - - expect(createResult.status).toBe('success'); - if (createResult.status === "success") { - const projectId = createResult.data.projectId; - const taskId1 = createResult.data.tasks[0].id; - const taskId2 = createResult.data.tasks[1].id; - - // 2. Try to approve project before tasks are done (should fail) - await expect(server.approveProjectCompletion(projectId)).rejects.toMatchObject({ - code: 'ERR_3003', - message: 'Not all tasks are done' - }); - - // 3. Mark tasks as done - await server.updateTask(projectId, taskId1, { status: 'done', completedDetails: 'Task 1 completed details' }); - await server.updateTask(projectId, taskId2, { status: 'done', completedDetails: 'Task 2 completed details' }); - - // 4. Try to approve project before tasks are approved (should fail) - await expect(server.approveProjectCompletion(projectId)).rejects.toMatchObject({ - code: 'ERR_3004', - message: 'Not all done tasks are approved' - }); - - // 5. Approve tasks - await server.approveTaskCompletion(projectId, taskId1); - await server.approveTaskCompletion(projectId, taskId2); - - // 6. Now approve the project (should succeed) - const approvalResult = await server.approveProjectCompletion(projectId); - expect(approvalResult.status).toBe('success'); - - // 7. Verify project state - const projectAfterApproval = await server.listProjects("completed"); - expect(projectAfterApproval.status).toBe('success'); - if (projectAfterApproval.status === "success") { - const completedProject = projectAfterApproval.data.projects.find(p => p.projectId === projectId); - expect(completedProject).toBeDefined(); - } - - // 8. Try to approve again (should fail) - await expect(server.approveProjectCompletion(projectId)).rejects.toMatchObject({ - code: 'ERR_3001', - message: 'Project is already completed' - }); - } - }); - - it("should handle complex project and task state transitions", async () => { - // Create a project with multiple tasks - const project = await server.createProject("Complex Project", [ - { title: "Task 1", description: "First task" }, - { title: "Task 2", description: "Second task" }, - { title: "Task 3", description: "Third task" } - ]); - - expect(project.status).toBe('success'); - - if (project.status === "success") { - const projectId = project.data.projectId; - const taskId1 = project.data.tasks[0].id; - const taskId2 = project.data.tasks[1].id; - - // Initially all tasks should be open - const initialOpenTasks = await server.listTasks(projectId, "open"); - expect(initialOpenTasks.status).toBe('success'); - if (initialOpenTasks.status === "success") { - expect(initialOpenTasks.data.tasks!.length).toBe(3); - } - - // Mark first task as done and approved - await server.updateTask(projectId, taskId1, { - status: 'done', - completedDetails: 'Task 1 completed' - }); - await server.approveTaskCompletion(projectId, taskId1); - - // Should now have 2 open tasks and 1 completed - const openTasks = await server.listTasks(projectId, "open"); - expect(openTasks.status).toBe('success'); - if (openTasks.status === "success") { - expect(openTasks.data.tasks!.length).toBe(2); - } - - const completedTasks = await server.listTasks(projectId, "completed"); - expect(completedTasks.status).toBe('success'); - if (completedTasks.status === "success") { - expect(completedTasks.data.tasks!.length).toBe(1); - } - - // Mark second task as done but not approved - await server.updateTask(projectId, taskId2, { - status: 'done', - completedDetails: 'Task 2 completed' - }); - - // Should now have 1 open task, 1 pending approval, and 1 completed - const finalOpenTasks = await server.listTasks(projectId, "open"); - expect(finalOpenTasks.status).toBe('success'); - if (finalOpenTasks.status === "success") { - expect(finalOpenTasks.data.tasks!.length).toBe(1); - } - - const pendingTasks = await server.listTasks(projectId, "pending_approval"); - expect(pendingTasks.status).toBe('success'); - if (pendingTasks.status === "success") { - expect(pendingTasks.data.tasks!.length).toBe(1); - } - - const finalCompletedTasks = await server.listTasks(projectId, "completed"); - expect(finalCompletedTasks.status).toBe('success'); - if (finalCompletedTasks.status === "success") { - expect(finalCompletedTasks.data.tasks!.length).toBe(1); - } - } - }); - - it("should handle tool/rule recommendations end-to-end", async () => { - // Create a project with tasks that have recommendations - const response = await server.createProject("Test Project", [ - { - title: "Task with Recommendations", - description: "Test Description", - toolRecommendations: "Use tool A", - ruleRecommendations: "Review rule B" - }, - { - title: "Task without Recommendations", - description: "Another task" - } - ]); - - expect(response.status).toBe('success'); - if (response.status === "success") { - const { projectId } = response.data; - - // Verify initial state - const tasksResponse = await server.listTasks(projectId); - expect(tasksResponse.status).toBe('success'); - if (tasksResponse.status === "success") { - const tasks = tasksResponse.data.tasks as Task[]; - - const taskWithRecs = tasks.find(t => t.title === "Task with Recommendations"); - const taskWithoutRecs = tasks.find(t => t.title === "Task without Recommendations"); - - expect(taskWithRecs).toBeDefined(); - expect(taskWithoutRecs).toBeDefined(); - - if (taskWithRecs) { - expect(taskWithRecs.toolRecommendations).toBe("Use tool A"); - expect(taskWithRecs.ruleRecommendations).toBe("Review rule B"); - } - - if (taskWithoutRecs) { - expect(taskWithoutRecs.toolRecommendations).toBeUndefined(); - expect(taskWithoutRecs.ruleRecommendations).toBeUndefined(); - } - - // Update task recommendations - if (taskWithoutRecs) { - const updateResponse = await server.updateTask(projectId, taskWithoutRecs.id, { - toolRecommendations: "Use tool X", - ruleRecommendations: "Review rule Y" - }); - - expect(updateResponse.status).toBe('success'); - if (updateResponse.status === "success") { - expect(updateResponse.data.toolRecommendations).toBe("Use tool X"); - expect(updateResponse.data.ruleRecommendations).toBe("Review rule Y"); - } - - // Verify the update persisted - const updatedTasksResponse = await server.listTasks(projectId); - expect(updatedTasksResponse.status).toBe('success'); - if (updatedTasksResponse.status === "success") { - const updatedTasks = updatedTasksResponse.data.tasks as Task[]; - const verifyTask = updatedTasks.find(t => t.id === taskWithoutRecs.id); - expect(verifyTask).toBeDefined(); - if (verifyTask) { - expect(verifyTask.toolRecommendations).toBe("Use tool X"); - expect(verifyTask.ruleRecommendations).toBe("Review rule Y"); - } - } - } - } - - // Add new tasks with recommendations - const addResponse = await server.addTasksToProject(projectId, [ - { - title: "New Task", - description: "With recommendations", - toolRecommendations: "Use tool C", - ruleRecommendations: "Review rule D" - } - ]); - - expect(addResponse.status).toBe('success'); - - const finalTasksResponse = await server.listTasks(projectId); - expect(finalTasksResponse.status).toBe('success'); - if (finalTasksResponse.status === "success") { - const finalTasks = finalTasksResponse.data.tasks as Task[]; - const newTask = finalTasks.find(t => t.title === "New Task"); - expect(newTask).toBeDefined(); - if (newTask) { - expect(newTask.toolRecommendations).toBe("Use tool C"); - expect(newTask.ruleRecommendations).toBe("Review rule D"); - } - } - } - }); - - it("should handle auto-approval in end-to-end workflow", async () => { - // Create a project with autoApprove enabled - const projectResponse = await server.createProject( - "Auto-approval Project", - [ - { title: "Task 1", description: "First auto-approved task" }, - { title: "Task 2", description: "Second auto-approved task" } - ], - "Auto approval plan", - true // Enable auto-approval - ); - - expect(projectResponse.status).toBe('success'); - if (projectResponse.status === "success") { - const project = projectResponse.data; - - // Mark tasks as done - they should be auto-approved - await server.updateTask(project.projectId, project.tasks[0].id, { - status: 'done', - completedDetails: 'Task 1 completed' - }); - - await server.updateTask(project.projectId, project.tasks[1].id, { - status: 'done', - completedDetails: 'Task 2 completed' - }); - - // Verify tasks are approved - const tasksResponse = await server.listTasks(project.projectId); - expect(tasksResponse.status).toBe('success'); - if (tasksResponse.status === "success") { - const tasks = tasksResponse.data.tasks as Task[]; - expect(tasks[0].approved).toBe(true); - expect(tasks[1].approved).toBe(true); - } - - // Project should be able to be completed without explicit task approval - const completionResult = await server.approveProjectCompletion(project.projectId); - expect(completionResult.status).toBe('success'); - - // Create a new server instance and verify persistence - const newServer = new TaskManager(testFilePath); - const projectState = await newServer.listProjects("completed"); - expect(projectState.status).toBe('success'); - if (projectState.status === "success") { - expect(projectState.data.projects.find(p => p.projectId === project.projectId)).toBeDefined(); - } - } - }); - - it("multiple concurrent server instances should synchronize data", async () => { - // Create a unique file path just for this test - const uniqueTestFilePath = path.join(tempDir, `concurrent-test-${Date.now()}.json`); - - // Create two server instances that would typically be in different processes - const server1 = new TaskManager(uniqueTestFilePath); - const server2 = new TaskManager(uniqueTestFilePath); - - // Ensure both servers are fully initialized - await server1["initialized"]; - await server2["initialized"]; - - // Create a project with server1 - const projectResponse = await server1.createProject( - "Concurrent Test Project", - [{ title: "Test Task", description: "Description" }] - ); - - expect(projectResponse.status).toBe('success'); - if (projectResponse.status === "success") { - const project = projectResponse.data; - - // Update the task with server2 - await server2.updateTask(project.projectId, project.tasks[0].id, { - status: 'in progress' - }); - - // Verify the update with server1 - const taskDetails = await server1.openTaskDetails(project.tasks[0].id); - expect(taskDetails.status).toBe('success'); - if (taskDetails.status === "success") { - expect(taskDetails.data.task.status).toBe('in progress'); - } - - // Complete and approve the task with server1 - await server1.updateTask(project.projectId, project.tasks[0].id, { - status: 'done', - completedDetails: 'Task completed' - }); - await server1.approveTaskCompletion(project.projectId, project.tasks[0].id); - - // Verify completion with server2 (it should automatically reload latest data) - const completedTasks = await server2.listTasks(project.projectId, "completed"); - expect(completedTasks.status).toBe('success'); - if (completedTasks.status === "success") { - expect(completedTasks.data.tasks!.length).toBe(1); - } - - // Complete the project with server2 - const completionResult = await server2.approveProjectCompletion(project.projectId); - expect(completionResult.status).toBe('success'); - - // Verify with server1 (it should automatically reload latest data) - const projectState = await server1.listProjects("completed"); - expect(projectState.status).toBe('success'); - if (projectState.status === "success") { - expect(projectState.data.projects.find(p => p.projectId === project.projectId)).toBeDefined(); - } - } - }); - - // --- NEW API TEST --- - // Skip this test by default, as it requires live API keys and makes external calls. - // Remove '.skip' and ensure OPENAI_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY are in .env to run. - it.skip("should generate a project plan using live APIs", async () => { - const testPrompt = "Create a plan for a simple web server using Node.js and Express."; - const attachments: string[] = []; // Add mock attachment content if needed - - // --- Test OpenAI --- - if (process.env.OPENAI_API_KEY) { - console.log("Testing OpenAI API..."); - try { - const openaiResult = await server.generateProjectPlan({ - prompt: testPrompt, - provider: "openai", - model: "gpt-4o-mini", - attachments, - }); - expect(openaiResult.status).toBe("success"); - if (openaiResult.status === "success") { - expect(openaiResult.data.projectId).toMatch(/^proj-\d+$/); - expect(openaiResult.data.tasks.length).toBeGreaterThan(0); - expect(openaiResult.data.tasks[0].title).toBeDefined(); - expect(typeof openaiResult.data.tasks[0].description).toBe('string'); - expect(openaiResult.data.tasks[0].description).not.toBe(''); - expect(openaiResult.data.message).toContain("Project proj-"); - console.log(`OpenAI generated project: ${openaiResult.data.projectId}`); - - // Fetch the project to verify the plan - const projectData = await server.readProject(openaiResult.data.projectId); - expect(projectData.status).toBe('success'); - if (projectData.status === 'success') { - expect(typeof projectData.data.projectPlan).toBe('string'); - expect(projectData.data.projectPlan).not.toBe(''); - } - } - } catch (error: any) { - console.error("OpenAI API test failed:", error.message); - expect(error).toBeNull(); - } - } else { - console.warn("Skipping OpenAI test: OPENAI_API_KEY not found in environment."); - } - - // --- Test Google --- - if (process.env.GEMINI_API_KEY) { - console.log("Testing Google Gemini API..."); - try { - const googleResult = await server.generateProjectPlan({ - prompt: testPrompt, - provider: "google", - model: "gemini-2.0-flash-001", - attachments, - }); - expect(googleResult.status).toBe("success"); - if (googleResult.status === "success") { - expect(googleResult.data.projectId).toMatch(/^proj-\d+$/); - expect(googleResult.data.tasks.length).toBeGreaterThan(0); - expect(googleResult.data.tasks[0].title).toBeDefined(); - expect(typeof googleResult.data.tasks[0].description).toBe('string'); - expect(googleResult.data.tasks[0].description).not.toBe(''); - expect(googleResult.data.message).toContain("Project proj-"); - console.log(`Google generated project: ${googleResult.data.projectId}`); - - // Fetch the project to verify the plan - const projectData = await server.readProject(googleResult.data.projectId); - expect(projectData.status).toBe('success'); - if (projectData.status === 'success') { - expect(typeof projectData.data.projectPlan).toBe('string'); - expect(projectData.data.projectPlan).not.toBe(''); - } - } - } catch (error: any) { - console.error("Google API test failed:", error.message); - expect(error).toBeNull(); - } - } else { - console.warn("Skipping Google test: GEMINI_API_KEY not found in environment."); - } - - // --- Test DeepSeek --- - if (process.env.DEEPSEEK_API_KEY) { - console.log("Testing DeepSeek API..."); - try { - const deepseekResult = await server.generateProjectPlan({ - prompt: testPrompt, - provider: "deepseek", - model: "deepseek-chat", - attachments, - }); - expect(deepseekResult.status).toBe("success"); - if (deepseekResult.status === "success") { - expect(deepseekResult.data.projectId).toMatch(/^proj-\d+$/); - expect(deepseekResult.data.tasks.length).toBeGreaterThan(0); - expect(deepseekResult.data.tasks[0].title).toBeDefined(); - expect(typeof deepseekResult.data.tasks[0].description).toBe('string'); - expect(deepseekResult.data.tasks[0].description).not.toBe(''); - expect(deepseekResult.data.message).toContain("Project proj-"); - console.log(`DeepSeek generated project: ${deepseekResult.data.projectId}`); - - // Fetch the project to verify the plan - const projectData = await server.readProject(deepseekResult.data.projectId); - expect(projectData.status).toBe('success'); - if (projectData.status === 'success') { - expect(typeof projectData.data.projectPlan).toBe('string'); - expect(projectData.data.projectPlan).not.toBe(''); - } - } - } catch (error: any) { - console.error("DeepSeek API test failed:", error.message); - expect(error).toBeNull(); - } - } else { - console.warn("Skipping DeepSeek test: DEEPSEEK_API_KEY not found in environment."); - } - - // Add a final assertion to ensure at least one API was tested if desired - // expect(console.warn).not.toHaveBeenCalledTimes(3); // Example - - }, 50000); // Increase timeout for API calls if needed - // --- END NEW API TEST --- -}); diff --git a/tests/integration/e2e.integration.test.ts b/tests/integration/e2e.integration.test.ts deleted file mode 100644 index 6043943..0000000 --- a/tests/integration/e2e.integration.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import * as fs from 'node:fs/promises'; -import process from 'node:process'; -import dotenv from 'dotenv'; - -// Load environment variables from .env file -dotenv.config(); - -interface ToolResponse { - isError: boolean; - content: Array<{ text: string }>; -} - -describe('MCP Client Integration', () => { - let client: Client; - let transport: StdioClientTransport; - let tempDir: string; - let testFilePath: string; - - beforeAll(async () => { - // Create a unique temp directory for test - tempDir = path.join(os.tmpdir(), `mcp-client-integration-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`); - await fs.mkdir(tempDir, { recursive: true }); - testFilePath = path.join(tempDir, 'test-tasks.json'); - - console.log('Setting up test with:'); - console.log('- Temp directory:', tempDir); - console.log('- Test file path:', testFilePath); - - // Set up the transport with environment variable for test file - transport = new StdioClientTransport({ - command: process.execPath, // Use full path to current Node.js executable - args: ["dist/index.js"], - env: { - TASK_MANAGER_FILE_PATH: testFilePath, - NODE_ENV: "test", - DEBUG: "mcp:*", // Enable MCP debug logging - // Pass API keys from the test runner's env to the child process env - OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '', - GEMINI_API_KEY: process.env.GEMINI_API_KEY ?? '' - } - }); - - console.log('Created transport with command:', process.execPath, 'dist/index.js'); - - // Set up the client - client = new Client( - { - name: "test-client", - version: "1.0.0" - }, - { - capabilities: { - tools: { - list: true, - call: true - } - } - } - ); - - try { - console.log('Attempting to connect to server...'); - // Connect to the server with a timeout - const connectPromise = client.connect(transport); - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Connection timeout')), 5000); - }); - - await Promise.race([connectPromise, timeoutPromise]); - console.log('Successfully connected to server'); - - // Small delay to ensure server is ready - await new Promise(resolve => setTimeout(resolve, 1000)); - } catch (error) { - console.error('Failed to connect to server:', error); - throw error; - } - }); - - afterAll(async () => { - try { - console.log('Cleaning up...'); - // Ensure transport is properly closed - if (transport) { - transport.close(); - console.log('Transport closed'); - } - } catch (err) { - console.error('Error closing transport:', err); - } - - // Clean up temp files - try { - await fs.rm(tempDir, { recursive: true, force: true }); - console.log('Temp directory cleaned up'); - } catch (err) { - console.error('Error cleaning up temp directory:', err); - } - }); - - it('should list available tools', async () => { - console.log('Testing tool listing...'); - const response = await client.listTools(); - expect(response).toBeDefined(); - expect(response).toHaveProperty('tools'); - expect(Array.isArray(response.tools)).toBe(true); - expect(response.tools.length).toBeGreaterThan(0); - - // Check for essential tools - const toolNames = response.tools.map(tool => tool.name); - console.log('Available tools:', toolNames); - expect(toolNames).toContain('list_projects'); - expect(toolNames).toContain('create_project'); - expect(toolNames).toContain('read_project'); - expect(toolNames).toContain('get_next_task'); - }); - - it('should create and manage a project lifecycle', async () => { - console.log('Testing project lifecycle...'); - // Create a new project - const createResult = await client.callTool({ - name: "create_project", - arguments: { - initialPrompt: "Test Project", - tasks: [ - { title: "Task 1", description: "First test task" }, - { title: "Task 2", description: "Second test task" } - ] - } - }) as ToolResponse; - expect(createResult.isError).toBeFalsy(); - - // Parse the project ID from the response - const responseData = JSON.parse((createResult.content[0] as { text: string }).text); - const projectId = responseData.data.projectId; - expect(projectId).toBeDefined(); - console.log('Created project with ID:', projectId); - - // List projects and verify our new project exists - const listResult = await client.callTool({ - name: "list_projects", - arguments: {} - }) as ToolResponse; - expect(listResult.isError).toBeFalsy(); - const projects = JSON.parse((listResult.content[0] as { text: string }).text); - expect(projects.data.projects.some((p: any) => p.projectId === projectId)).toBe(true); - console.log('Project verified in list'); - - // Get next task - const nextTaskResult = await client.callTool({ - name: "get_next_task", - arguments: { - projectId - } - }) as ToolResponse; - expect(nextTaskResult.isError).toBeFalsy(); - const nextTask = JSON.parse((nextTaskResult.content[0] as { text: string }).text); - expect(nextTask.status).toBe("success"); - expect(nextTask.data).toHaveProperty('task'); - const taskId = nextTask.data.task.id; - console.log('Got next task with ID:', taskId); - - // Mark task as done - const markDoneResult = await client.callTool({ - name: "update_task", - arguments: { - projectId, - taskId, - status: "done", - completedDetails: "Task completed in test" - } - }) as ToolResponse; - expect(markDoneResult.isError).toBeFalsy(); - console.log('Marked task as done'); - - // Approve the task - const approveResult = await client.callTool({ - name: "approve_task", - arguments: { - projectId, - taskId - } - }) as ToolResponse; - expect(approveResult.isError).toBeFalsy(); - console.log('Approved task'); - - // Delete the project - const deleteResult = await client.callTool({ - name: "delete_project", - arguments: { - projectId - } - }) as ToolResponse; - expect(deleteResult.isError).toBeFalsy(); - console.log('Deleted project'); - }); - - it('should have accurate version', async () => { - console.log('Testing server version...'); - const response = await client.getServerVersion(); - expect(response).toHaveProperty('version'); - // Should match package.json version - const packageJson = JSON.parse( - await fs.readFile(new URL('../../package.json', import.meta.url), 'utf8') - ); - expect(response?.version).toBe(packageJson.version); - }); - - it('should auto-approve tasks when autoApprove is enabled', async () => { - console.log('Testing autoApprove feature...'); - - // Create a project with autoApprove enabled - const createResult = await client.callTool({ - name: "create_project", - arguments: { - initialPrompt: "Auto-Approval Project", - tasks: [ - { title: "Auto Task", description: "This task should be auto-approved" } - ], - autoApprove: true - } - }) as ToolResponse; - expect(createResult.isError).toBeFalsy(); - - // Get the project ID - const responseData = JSON.parse((createResult.content[0] as { text: string }).text); - const projectId = responseData.data.projectId; - expect(projectId).toBeDefined(); - console.log('Created auto-approve project with ID:', projectId); - - // Get the task ID - const nextTaskResult = await client.callTool({ - name: "get_next_task", - arguments: { - projectId - } - }) as ToolResponse; - expect(nextTaskResult.isError).toBeFalsy(); - const nextTask = JSON.parse((nextTaskResult.content[0] as { text: string }).text); - expect(nextTask.status).toBe("success"); - expect(nextTask.data).toHaveProperty('task'); - const taskId = nextTask.data.task.id; - - // Mark task as done - we need to mark it as done using the update_task tool - const markDoneResult = await client.callTool({ - name: "update_task", - arguments: { - projectId, - taskId, - status: "done", - completedDetails: "Auto-approved task completed" - } - }) as ToolResponse; - expect(markDoneResult.isError).toBeFalsy(); - - // Now manually approve the task with approve_task - const approveResult = await client.callTool({ - name: "approve_task", - arguments: { - projectId, - taskId - } - }) as ToolResponse; - expect(approveResult.isError).toBeFalsy(); - - // Read the task and verify it was approved - const readTaskResult = await client.callTool({ - name: "read_task", - arguments: { - taskId - } - }) as ToolResponse; - expect(readTaskResult.isError).toBeFalsy(); - const taskDetails = JSON.parse((readTaskResult.content[0] as { text: string }).text); - expect(taskDetails.data.task.status).toBe("done"); - expect(taskDetails.data.task.approved).toBe(true); - console.log('Task was manually approved:', taskDetails.data.task.approved); - - // Verify we can finalize the project after explicit approval - const finalizeResult = await client.callTool({ - name: "finalize_project", - arguments: { - projectId - } - }) as ToolResponse; - expect(finalizeResult.isError).toBeFalsy(); - console.log('Project was successfully finalized after explicit task approval'); - }); - - // Skip by default as it requires OpenAI API key - it.skip('should generate a project plan using OpenAI', async () => { - console.log('Testing project plan generation...'); - - // Skip if no OpenAI API key is set - const openaiApiKey = process.env.OPENAI_API_KEY; - if (!openaiApiKey) { - console.log('Skipping test: OPENAI_API_KEY not set'); - return; - } - - // Create a temporary requirements file - const requirementsPath = path.join(tempDir, 'requirements.md'); - const requirements = `# Project Plan Requirements - -- This is a test of whether we are correctly attaching files to our prompt -- Return a JSON project plan with one task -- Task title must be 'AmazingTask' -- Task description must be AmazingDescription -- Project plan attribute should be AmazingPlan`; - - await fs.writeFile(requirementsPath, requirements, 'utf-8'); - - // Test prompt and context - const testPrompt = "Create a step-by-step project plan to build a simple TODO app with React"; - - // Generate project plan - const generateResult = await client.callTool({ - name: "generate_project_plan", - arguments: { - prompt: testPrompt, - provider: "openai", - model: "gpt-4-turbo", - attachments: [requirementsPath] - } - }) as ToolResponse; - - expect(generateResult.isError).toBeFalsy(); - const planData = JSON.parse((generateResult.content[0] as { text: string }).text); - - // Verify the generated plan structure - expect(planData).toHaveProperty('data'); - expect(planData.data).toHaveProperty('tasks'); - expect(Array.isArray(planData.data.tasks)).toBe(true); - expect(planData.data.tasks.length).toBeGreaterThan(0); - - // Verify task structure - const firstTask = planData.data.tasks[0]; - expect(firstTask).toHaveProperty('title'); - expect(firstTask).toHaveProperty('description'); - - // Verify that the generated task adheres to the requirements file context - expect(firstTask.title).toBe('AmazingTask'); - expect(firstTask.description).toBe('AmazingDescription'); - - // The temporary file will be cleaned up by the afterAll hook that removes tempDir - console.log('Successfully generated project plan with tasks'); - }); - - // Skip by default as it requires Google API key - it.skip('should generate a project plan using Google Gemini', async () => { - console.log('Testing project plan generation with Google Gemini...'); - - // Skip if no Google API key is set - const googleApiKey = process.env.GEMINI_API_KEY; - if (!googleApiKey) { - console.log('Skipping test: GEMINI_API_KEY not set'); - return; - } - - // Create a temporary requirements file - const requirementsPath = path.join(tempDir, 'google-requirements.md'); - const requirements = `# Project Plan Requirements (Google Test) - -- This is a test of whether we are correctly attaching files to our prompt for Google models -- Return a JSON project plan with one task -- Task title must be 'GeminiTask' -- Task description must be 'GeminiDescription' -- Project plan attribute should be 'GeminiPlan'`; - - await fs.writeFile(requirementsPath, requirements, 'utf-8'); - - // Test prompt and context - const testPrompt = "Create a step-by-step project plan to develop a cloud-native microservice using Go"; - - // Generate project plan using Google Gemini - const generateResult = await client.callTool({ - name: "generate_project_plan", - arguments: { - prompt: testPrompt, - provider: "google", - model: "gemini-1.5-flash-latest", // Using a generally available model, adjust if needed - attachments: [requirementsPath] - } - }) as ToolResponse; - - expect(generateResult.isError).toBeFalsy(); - const planData = JSON.parse((generateResult.content[0] as { text: string }).text); - - // Verify the generated plan structure - expect(planData).toHaveProperty('data'); - expect(planData.data).toHaveProperty('tasks'); - expect(Array.isArray(planData.data.tasks)).toBe(true); - expect(planData.data.tasks.length).toBeGreaterThan(0); - - // Verify task structure based on requirements file - const firstTask = planData.data.tasks[0]; - expect(firstTask).toHaveProperty('title'); - expect(firstTask).toHaveProperty('description'); - - // Verify that the generated task adheres to the requirements file context - expect(firstTask.title).toBe('GeminiTask'); - expect(firstTask.description).toBe('GeminiDescription'); - - // The temporary file will be cleaned up by the afterAll hook that removes tempDir - console.log('Successfully generated project plan with Google Gemini'); - }); -}); \ No newline at end of file diff --git a/tests/mcp/e2e.integration.test.ts b/tests/mcp/e2e.integration.test.ts new file mode 100644 index 0000000..0816529 --- /dev/null +++ b/tests/mcp/e2e.integration.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from '@jest/globals'; +import { setupTestContext, teardownTestContext } from './test-helpers.js'; +import type { TestContext } from './test-helpers.js'; + +describe('MCP Client Integration', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + it('should list available tools', async () => { + const response = await context.client.listTools(); + expect(response).toBeDefined(); + expect(response).toHaveProperty('tools'); + expect(Array.isArray(response.tools)).toBe(true); + expect(response.tools.length).toBeGreaterThan(0); + + // Check for essential tools + const toolNames = response.tools.map(tool => tool.name); + expect(toolNames).toContain('list_projects'); + expect(toolNames).toContain('create_project'); + expect(toolNames).toContain('read_project'); + expect(toolNames).toContain('get_next_task'); + }); +}); \ No newline at end of file diff --git a/tests/mcp/test-helpers.ts b/tests/mcp/test-helpers.ts new file mode 100644 index 0000000..fa96e01 --- /dev/null +++ b/tests/mcp/test-helpers.ts @@ -0,0 +1,301 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { Task, Project, TaskManagerFile } from "../../src/types/data.js"; +import { FileSystemService } from "../../src/server/FileSystemService.js"; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as fs from 'node:fs/promises'; +import process from 'node:process'; +import dotenv from 'dotenv'; + +// Load environment variables from .env file +dotenv.config(); + +export interface TestContext { + client: Client; + transport: StdioClientTransport; + tempDir: string; + testFilePath: string; + taskCounter: number; + fileService: FileSystemService; +} + +/** + * Sets up a test context with MCP client, transport, and temp directory + */ +export async function setupTestContext( + customFilePath?: string, + skipFileInit: boolean = false, + customEnv?: Record +): Promise { + // Create a unique temp directory for test + const tempDir = path.join(os.tmpdir(), `mcp-client-integration-test-${Date.now()}-${Math.floor(Math.random() * 10000)}`); + await fs.mkdir(tempDir, { recursive: true }); + const testFilePath = customFilePath || path.join(tempDir, 'test-tasks.json'); + + // Create FileSystemService instance + const fileService = new FileSystemService(testFilePath); + + // Initialize empty task manager file (skip for error testing) + if (!skipFileInit) { + await fileService.saveTasks({ projects: [] }); + } + + // Set up the transport with environment variable for test file + const transport = new StdioClientTransport({ + command: process.execPath, // Use full path to current Node.js executable + args: ["dist/src/server/index.js"], + env: { + TASK_MANAGER_FILE_PATH: testFilePath, + NODE_ENV: "test", + DEBUG: "mcp:*", // Enable MCP debug logging + // Use custom env if provided, otherwise use default API keys + ...(customEnv || { + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '', + GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '', + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? '' + }) + } + }); + + // Set up the client + const client = new Client( + { + name: "test-client", + version: "1.0.0" + }, + { + capabilities: { + tools: { + list: true, + call: true + } + } + } + ); + + try { + // Connect to the server with a timeout + const connectPromise = client.connect(transport); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Connection timeout')), 5000); + }); + + await Promise.race([connectPromise, timeoutPromise]); + + // Small delay to ensure server is ready + await new Promise(resolve => setTimeout(resolve, 1000)); + } catch (error) { + throw error; + } + + return { client, transport, tempDir, testFilePath, taskCounter: 0, fileService }; +} + +/** + * Cleans up test context by closing transport and removing temp directory + */ +export async function teardownTestContext(context: TestContext) { + try { + // Ensure transport is properly closed + if (context.transport) { + context.transport.close(); + } + } catch (err) { + console.error('Error closing transport:', err); + } + + // Clean up temp files + try { + await fs.rm(context.tempDir, { recursive: true, force: true }); + } catch (err) { + console.error('Error cleaning up temp directory:', err); + } +} + +/** + * Verifies that a tool response matches the MCP spec format + */ +export function verifyCallToolResult(response: CallToolResult) { + expect(response).toBeDefined(); + expect(response).toHaveProperty('content'); + expect(Array.isArray(response.content)).toBe(true); + expect(response.content.length).toBeGreaterThan(0); + + // Verify each content item matches MCP spec + response.content.forEach(item => { + expect(item).toHaveProperty('type'); + expect(item).toHaveProperty('text'); + expect(typeof item.type).toBe('string'); + expect(typeof item.text).toBe('string'); + }); + + // If it's an error response, verify error format + if (response.isError) { + expect(response.content[0].text).toMatch(/^(Error|Failed|Invalid|Tool execution failed)/); + } +} + +/** + * Verifies that a protocol error matches the MCP spec format + */ +export function verifyProtocolError(error: any, expectedCode: number, expectedMessagePattern: string) { + expect(error).toBeDefined(); + expect(error.code).toBe(expectedCode); + expect(error.message).toMatch(expectedMessagePattern); +} + +/** + * Verifies that a tool execution error matches the expected format + */ +export function verifyToolExecutionError(response: CallToolResult, expectedMessagePattern: string | RegExp) { + verifyCallToolResult(response); // Verify basic CallToolResult format + expect(response.isError).toBe(true); + const errorMessage = response.content[0]?.text; + expect(typeof errorMessage).toBe('string'); + expect(errorMessage).toMatch(expectedMessagePattern); +} + +/** + * Verifies that a successful tool response contains valid JSON data + */ +export function verifyToolSuccessResponse(response: CallToolResult): T { + verifyCallToolResult(response); + expect(response.isError).toBeFalsy(); + const jsonText = response.content[0]?.text; + expect(typeof jsonText).toBe('string'); + return JSON.parse(jsonText as string); +} + +/** + * Creates a test project and returns its ID + */ +export async function createTestProject(client: Client, options: { + initialPrompt?: string; + tasks?: Array<{ title: string; description: string }>; + autoApprove?: boolean; +} = {}): Promise { + const createResult = await client.callTool({ + name: "create_project", + arguments: { + initialPrompt: options.initialPrompt || "Test Project", + tasks: options.tasks || [ + { title: "Task 1", description: "First test task" } + ], + autoApprove: options.autoApprove + } + }) as CallToolResult; + + const responseData = verifyToolSuccessResponse<{ projectId: string }>(createResult); + return responseData.projectId; +} + +/** + * Gets the first task ID from a project + */ +export async function getFirstTaskId(client: Client, projectId: string): Promise { + const nextTaskResult = await client.callTool({ + name: "get_next_task", + arguments: { projectId } + }) as CallToolResult; + + const nextTask = verifyToolSuccessResponse<{ task: { id: string } }>(nextTaskResult); + return nextTask.task.id; +} + +/** + * Reads and parses the task manager file + */ +export async function readTaskManagerFile(filePath: string): Promise { + const fileService = new FileSystemService(filePath); + return fileService.reloadTasks(); +} + +/** + * Writes data to the task manager file + */ +export async function writeTaskManagerFile(filePath: string, data: TaskManagerFile): Promise { + const fileService = new FileSystemService(filePath); + await fileService.saveTasks(data); +} + +/** + * Verifies a project exists in the task manager file and matches expected data + */ +export async function verifyProjectInFile(filePath: string, projectId: string, expectedData: Partial): Promise { + const data = await readTaskManagerFile(filePath); + const project = data.projects.find(p => p.projectId === projectId); + + expect(project).toBeDefined(); + Object.entries(expectedData).forEach(([key, value]) => { + expect(project).toHaveProperty(key, value); + }); +} + +/** + * Verifies a task exists in a project and matches expected data + */ +export async function verifyTaskInFile(filePath: string, projectId: string, taskId: string, expectedData: Partial): Promise { + const data = await readTaskManagerFile(filePath); + const project = data.projects.find(p => p.projectId === projectId); + expect(project).toBeDefined(); + + const task = project?.tasks.find(t => t.id === taskId); + expect(task).toBeDefined(); + Object.entries(expectedData).forEach(([key, value]) => { + expect(task).toHaveProperty(key, value); + }); +} + +/** + * Creates a test project directly in the file (bypassing the tool) + */ +export async function createTestProjectInFile(filePath: string, project: Partial): Promise { + const data = await readTaskManagerFile(filePath); + const newProject: Project = { + projectId: `proj-${Date.now()}`, + initialPrompt: "Test Project", + projectPlan: "", + completed: false, + tasks: [], + ...project + }; + + data.projects.push(newProject); + await writeTaskManagerFile(filePath, data); + return newProject; +} + +/** + * Creates a test task directly in the file (bypassing the tool) + */ +export async function createTestTaskInFile(filePath: string, projectId: string, task: Partial): Promise { + const data = await readTaskManagerFile(filePath); + const project = data.projects.find(p => p.projectId === projectId); + if (!project) { + throw new Error(`Project ${projectId} not found`); + } + + // Find the highest task ID number in the file to ensure unique IDs + const maxTaskId = data.projects + .flatMap(p => p.tasks) + .map(t => parseInt(t.id.replace('task-', ''))) + .reduce((max, curr) => Math.max(max, curr), 0); + + const newTask: Task = { + id: `task-${maxTaskId + 1}`, // Use incrementing number instead of timestamp + title: "Test Task", + description: "Test Description", + status: "not started", + approved: false, + completedDetails: "", + toolRecommendations: "", + ruleRecommendations: "", + ...task + }; + + project.tasks.push(newTask); + await writeTaskManagerFile(filePath, data); + return newTask; +} \ No newline at end of file diff --git a/tests/mcp/tools/add-tasks-to-project.test.ts b/tests/mcp/tools/add-tasks-to-project.test.ts new file mode 100644 index 0000000..c5e82f0 --- /dev/null +++ b/tests/mcp/tools/add-tasks-to-project.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject, verifyCallToolResult, verifyTaskInFile, verifyToolExecutionError, verifyProtocolError } from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +describe('add_tasks_to_project Tool', () => { + let context: TestContext; + let projectId: string; + + beforeEach(async () => { + context = await setupTestContext(); + // Create a test project for each test case + projectId = await createTestProject(context.client); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should add a single task to project', async () => { + const result = await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId, + tasks: [ + { title: "New Task", description: "A task to add" } + ] + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toHaveProperty('message'); + expect(responseData).toHaveProperty('newTasks'); + expect(responseData.newTasks).toHaveLength(1); + const newTask = responseData.newTasks[0]; + + // Verify task was added to file + await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { + title: "New Task", + description: "A task to add", + status: "not started", + approved: false + }); + }); + + it('should add multiple tasks to project', async () => { + const tasks = [ + { title: "Task 1", description: "First task to add" }, + { title: "Task 2", description: "Second task to add" }, + { title: "Task 3", description: "Third task to add" } + ]; + + const result = await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId, + tasks + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.newTasks).toHaveLength(3); + + // Verify all tasks were added + for (let i = 0; i < tasks.length; i++) { + await verifyTaskInFile(context.testFilePath, projectId, responseData.newTasks[i].id, { + title: tasks[i].title, + description: tasks[i].description, + status: "not started" + }); + } + }); + + it('should add tasks with tool and rule recommendations', async () => { + const result = await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId, + tasks: [{ + title: "Task with Recommendations", + description: "Task with specific recommendations", + toolRecommendations: "Use tool A and B", + ruleRecommendations: "Follow rules X and Y" + }] + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const newTask = responseData.newTasks[0]; + + await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { + title: "Task with Recommendations", + description: "Task with specific recommendations", + toolRecommendations: "Use tool A and B", + ruleRecommendations: "Follow rules X and Y" + }); + }); + + it('should handle empty tasks array', async () => { + const result = await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId, + tasks: [] + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.newTasks).toHaveLength(0); + }); + }); + + describe('Error Cases', () => { + it('should return error for missing required parameters', async () => { + try { + await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId + // Missing tasks array + } + }); + expect(true).toBe(false); // This line should never be reached + } catch (error) { + verifyProtocolError(error, -32602, 'Invalid or missing required parameter'); + } + }); + + it('should return error for invalid project ID', async () => { + const result = await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId: "non-existent-project", + tasks: [{ title: "Test Task", description: "Test Description" }] + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project non-existent-project not found/); + }); + + it('should return error for task with empty title', async () => { + try { + await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId, + tasks: [{ title: "", description: "Test Description" }] + } + }); + expect(true).toBe(false); // This line should never be reached + } catch (error) { + verifyProtocolError(error, -32602, 'Invalid or missing required parameter: title'); + } + }); + + it('should return error for task with empty description', async () => { + try { + await context.client.callTool({ + name: "add_tasks_to_project", + arguments: { + projectId, + tasks: [{ title: "Test Task", description: "" }] + } + }); + expect(true).toBe(false); // This line should never be reached + } catch (error) { + verifyProtocolError(error, -32602, 'Invalid or missing required parameter: description'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/approve-task.test.ts b/tests/mcp/tools/approve-task.test.ts new file mode 100644 index 0000000..939d639 --- /dev/null +++ b/tests/mcp/tools/approve-task.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + createTestProjectInFile, + createTestTaskInFile, + verifyTaskInFile, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +describe('approve_task Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should approve a completed task', async () => { + // Create a project with a completed task + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + status: "done", + completedDetails: "Task completed in test" + }); + + // Approve the task + const result = await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + // Verify response + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Verify task was approved in file + await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { + approved: true, + status: "done" + }); + }); + + it('should handle auto-approved tasks', async () => { + // Create a project with auto-approve enabled + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Auto-approve Project", + autoApprove: true + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Auto Task", + status: "done", + completedDetails: "Auto-approved task completed" + }); + + // Try to approve an auto-approved task + const result = await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Verify task was auto-approved + await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { + approved: true, + status: "done" + }); + }); + + it('should allow approving multiple tasks in sequence', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Multi-task Project" + }); + + // Create tasks sequentially + const task1 = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 1", + status: "done", + completedDetails: "First task done" + }); + + const task2 = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 2", + status: "done", + completedDetails: "Second task done" + }); + + const tasks = [task1, task2]; + + // Approve tasks in sequence + for (const task of tasks) { + const result = await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { + approved: true + }); + } + }); + }); + + describe('Error Cases', () => { + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: "non_existent_project", + taskId: "task-1" + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Tool execution failed: Project non_existent_project not found'); + }); + + it('should return error for non-existent task', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const result = await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project.projectId, + taskId: "non_existent_task" + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Tool execution failed: Task non_existent_task not found'); + }); + + it('should return error when approving incomplete task', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Incomplete Task", + status: "in progress" + }); + + const result = await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Tool execution failed: Task not done yet'); + }); + + it('should return error when approving already approved task', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Approved Task", + status: "done", + approved: true, + completedDetails: "Already approved" + }); + + const result = await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Tool execution failed: Task is already approved'); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/create-project.test.ts b/tests/mcp/tools/create-project.test.ts new file mode 100644 index 0000000..9bca3e5 --- /dev/null +++ b/tests/mcp/tools/create-project.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + verifyProjectInFile, + verifyTaskInFile, + readTaskManagerFile, + TestContext +} from '../test-helpers.js'; +import { CallToolResult, McpError } from '@modelcontextprotocol/sdk/types.js'; + +describe('create_project Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should create a project with minimal parameters', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Test Project", + tasks: [ + { title: "Task 1", description: "First test task" } + ] + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toHaveProperty('projectId'); + const projectId = responseData.projectId; + + // Verify project was created in file + await verifyProjectInFile(context.testFilePath, projectId, { + initialPrompt: "Test Project", + completed: false + }); + + // Verify task was created + await verifyTaskInFile(context.testFilePath, projectId, responseData.tasks[0].id, { + title: "Task 1", + description: "First test task", + status: "not started", + approved: false + }); + }); + + it('should create a project with no tasks', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Project with No Tasks", + tasks: [] + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toHaveProperty('projectId'); + const projectId = responseData.projectId; + + // Verify project was created in file + await verifyProjectInFile(context.testFilePath, projectId, { + initialPrompt: "Project with No Tasks", + completed: false + }); + + // Verify no tasks were created + const data = await readTaskManagerFile(context.testFilePath); + const project = data.projects.find(p => p.projectId === projectId); + expect(project?.tasks).toHaveLength(0); + }); + + it('should create a project with multiple tasks', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Multi-task Project", + tasks: [ + { title: "Task 1", description: "First task" }, + { title: "Task 2", description: "Second task" }, + { title: "Task 3", description: "Third task" } + ] + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.projectId; + + // Verify all tasks were created + const data = await readTaskManagerFile(context.testFilePath); + const project = data.projects.find(p => p.projectId === projectId); + expect(project?.tasks).toHaveLength(3); + expect(project?.tasks.map(t => t.title)).toEqual([ + "Task 1", + "Task 2", + "Task 3" + ]); + }); + + it('should create a project with auto-approve enabled', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Auto-approve Project", + tasks: [ + { title: "Auto Task", description: "This task will be auto-approved" } + ], + autoApprove: true + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.projectId; + + // Verify project was created with auto-approve + const data = await readTaskManagerFile(context.testFilePath); + const project = data.projects.find(p => p.projectId === projectId); + expect(project).toHaveProperty('autoApprove', true); + }); + + it('should create a project with project plan', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Planned Project", + projectPlan: "Detailed plan for the project execution", + tasks: [ + { title: "Planned Task", description: "Task with a plan" } + ] + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.projectId; + + await verifyProjectInFile(context.testFilePath, projectId, { + initialPrompt: "Planned Project", + projectPlan: "Detailed plan for the project execution" + }); + }); + + it('should create tasks with tool and rule recommendations', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Project with Recommendations", + tasks: [{ + title: "Task with Recommendations", + description: "Task description", + toolRecommendations: "Use tool X and Y", + ruleRecommendations: "Follow rules A and B" + }] + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.projectId; + const taskId = responseData.tasks[0].id; + + await verifyTaskInFile(context.testFilePath, projectId, taskId, { + toolRecommendations: "Use tool X and Y", + ruleRecommendations: "Follow rules A and B" + }); + }); + }); + + describe('Error Cases', () => { + it('should return error for missing required parameters', async () => { + try { + await context.client.callTool({ + name: "create_project", + arguments: { + // Missing initialPrompt and tasks + } + }); + fail('Expected McpError to be thrown'); + } catch (error) { + expect(error instanceof McpError).toBe(true); + expect((error as McpError).message).toContain('Invalid or missing required parameter: initialPrompt'); + } + }); + + it('should return error for invalid task data', async () => { + try { + await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Invalid Task Project", + tasks: [ + { title: "Task 1" } // Missing required description + ] + } + }); + fail('Expected McpError to be thrown'); + } catch (error) { + expect(error instanceof McpError).toBe(true); + expect((error as McpError).message).toContain('Invalid or missing required parameter: description'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/create-task.test.ts b/tests/mcp/tools/create-task.test.ts new file mode 100644 index 0000000..6cc9a26 --- /dev/null +++ b/tests/mcp/tools/create-task.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject, verifyCallToolResult, verifyTaskInFile, verifyToolExecutionError, verifyProtocolError } from '../test-helpers.js'; +import { CallToolResult, McpError } from '@modelcontextprotocol/sdk/types.js'; + +describe('create_task Tool', () => { + let context: TestContext; + let projectId: string; + + beforeEach(async () => { + context = await setupTestContext(); + // Create a test project for each test case + projectId = await createTestProject(context.client); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should create a task with minimal parameters', async () => { + const result = await context.client.callTool({ + name: "create_task", + arguments: { + projectId, + title: "New Test Task", + description: "A simple test task" + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toHaveProperty('message'); + expect(responseData).toHaveProperty('newTasks'); + expect(responseData.newTasks).toHaveLength(1); + const newTask = responseData.newTasks[0]; + + // Verify task was created in file + await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { + title: "New Test Task", + description: "A simple test task", + status: "not started", + approved: false + }); + }); + + it('should create a task with tool and rule recommendations', async () => { + const result = await context.client.callTool({ + name: "create_task", + arguments: { + projectId, + title: "Task with Recommendations", + description: "Task with specific recommendations", + toolRecommendations: "Use tool A and B", + ruleRecommendations: "Follow rules X and Y" + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const newTask = responseData.newTasks[0]; + + await verifyTaskInFile(context.testFilePath, projectId, newTask.id, { + title: "Task with Recommendations", + description: "Task with specific recommendations", + toolRecommendations: "Use tool A and B", + ruleRecommendations: "Follow rules X and Y" + }); + }); + + it('should create multiple tasks in sequence', async () => { + const tasks = [ + { title: "First Task", description: "Task 1 description" }, + { title: "Second Task", description: "Task 2 description" }, + { title: "Third Task", description: "Task 3 description" } + ]; + + const taskIds = []; + + for (const task of tasks) { + const result = await context.client.callTool({ + name: "create_task", + arguments: { + projectId, + ...task + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + taskIds.push(responseData.newTasks[0].id); + } + + // Verify all tasks were created + for (let i = 0; i < tasks.length; i++) { + await verifyTaskInFile(context.testFilePath, projectId, taskIds[i], { + title: tasks[i].title, + description: tasks[i].description, + status: "not started" + }); + } + }); + }); + + describe('Error Cases', () => { + it('should return error for missing required parameters', async () => { + try { + await context.client.callTool({ + name: "create_task", + arguments: { + projectId + // Missing title and description + } + }); + expect(true).toBe(false); // This line should never be reached + } catch (error) { + verifyProtocolError(error, -32602, 'Invalid or missing required parameter'); + } + }); + + it('should return error for invalid project ID', async () => { + const result = await context.client.callTool({ + name: "create_task", + arguments: { + projectId: "non-existent-project", + title: "Test Task", + description: "Test Description" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project non-existent-project not found/); + }); + + it('should return error for empty title', async () => { + try { + await context.client.callTool({ + name: "create_task", + arguments: { + projectId, + title: "", + description: "Test Description" + } + }); + expect(true).toBe(false); // This line should never be reached + } catch (error) { + verifyProtocolError(error, -32602, 'Invalid or missing required parameter: title'); + } + }); + + it('should return error for empty description', async () => { + try { + await context.client.callTool({ + name: "create_task", + arguments: { + projectId, + title: "Test Task", + description: "" + } + }); + expect(true).toBe(false); // This line should never be reached + } catch (error) { + verifyProtocolError(error, -32602, 'Invalid or missing required parameter: description'); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/delete-project.test.ts b/tests/mcp/tools/delete-project.test.ts new file mode 100644 index 0000000..4c18503 --- /dev/null +++ b/tests/mcp/tools/delete-project.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolExecutionError, + verifyToolSuccessResponse, + createTestProject, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +describe('delete_project Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should successfully delete an empty project', async () => { + // Create a project using the actual create_project tool + const projectId = await createTestProject(context.client, { + initialPrompt: "Test Project", + tasks: [] // No tasks + }); + + const result = await context.client.callTool({ + name: "delete_project", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolSuccessResponse(result); + + // Verify project is deleted by attempting to read a task from it + const readResult = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); + }); + + it('should successfully delete a project with non-approved tasks', async () => { + // Create a project with non-approved tasks using the actual create_project tool + const projectId = await createTestProject(context.client, { + initialPrompt: "Test Project with Tasks", + tasks: [ + { title: "Task 1", description: "First task" }, + { title: "Task 2", description: "Second task" } + ] + }); + + const result = await context.client.callTool({ + name: "delete_project", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolSuccessResponse(result); + + // Verify project is deleted + const readResult = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); + }); + + it('should successfully delete a project with approved tasks', async () => { + // Create a project with tasks + const projectId = await createTestProject(context.client, { + initialPrompt: "Project with Tasks", + tasks: [ + { title: "Task to Approve", description: "This task will be approved" } + ] + }); + + // Get the task ID + const nextTaskResult = await context.client.callTool({ + name: "get_next_task", + arguments: { projectId } + }) as CallToolResult; + + const taskData = verifyToolSuccessResponse<{ task: { id: string } }>(nextTaskResult); + const taskId = taskData.task.id; + + // Mark task as done + await context.client.callTool({ + name: "update_task", + arguments: { + projectId, + taskId, + status: "done", + completedDetails: "Task completed" + } + }); + + // Approve the task + await context.client.callTool({ + name: "approve_task", + arguments: { + projectId, + taskId + } + }); + + const result = await context.client.callTool({ + name: "delete_project", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolSuccessResponse(result); + + // Verify project is deleted + const readResult = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); + }); + + it('should successfully delete a completed project', async () => { + // Create a project and complete all its tasks + const projectId = await createTestProject(context.client, { + initialPrompt: "Project to Complete", + tasks: [ + { title: "Task 1", description: "Task to complete" } + ] + }); + + // Get the task ID + const nextTaskResult = await context.client.callTool({ + name: "get_next_task", + arguments: { projectId } + }) as CallToolResult; + + const taskData = verifyToolSuccessResponse<{ task: { id: string } }>(nextTaskResult); + const taskId = taskData.task.id; + + // Mark task as done + await context.client.callTool({ + name: "update_task", + arguments: { + projectId, + taskId, + status: "done", + completedDetails: "Task completed" + } + }); + + // Approve the task + await context.client.callTool({ + name: "approve_task", + arguments: { + projectId, + taskId + } + }); + + // Mark project as completed + await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId + } + }); + + const result = await context.client.callTool({ + name: "delete_project", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolSuccessResponse(result); + + // Verify project is deleted + const readResult = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId + } + }) as CallToolResult; + + verifyToolExecutionError(readResult, /Tool execution failed: Project .* not found/); + }); + }); + + describe('Error Cases', () => { + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "delete_project", + arguments: { + projectId: "non_existent_project" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project not found: non_existent_project/); + }); + + it('should return error for invalid project ID format', async () => { + const result = await context.client.callTool({ + name: "delete_project", + arguments: { + projectId: "invalid-format" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project not found: invalid-format/); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/delete-task.test.ts b/tests/mcp/tools/delete-task.test.ts new file mode 100644 index 0000000..994acf2 --- /dev/null +++ b/tests/mcp/tools/delete-task.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolExecutionError, + verifyToolSuccessResponse, + createTestProjectInFile, + createTestTaskInFile, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +describe('delete_task Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should successfully delete an existing task', async () => { + // Create a project with a task + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + description: "Task to be deleted", + status: "not started" + }); + + const result = await context.client.callTool({ + name: "delete_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + verifyToolSuccessResponse(result); + + // Verify task is deleted by attempting to read it + const readResult = await context.client.callTool({ + name: "read_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + verifyToolExecutionError(readResult, /Tool execution failed: Task .* not found/); + }); + }); + + describe('Error Cases', () => { + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "delete_task", + arguments: { + projectId: "non_existent_project", + taskId: "task-1" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project non_existent_project not found/); + }); + + it('should return error for non-existent task in existing project', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const result = await context.client.callTool({ + name: "delete_task", + arguments: { + projectId: project.projectId, + taskId: "non-existent-task" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Task non-existent-task not found/); + }); + + it('should return error for invalid project ID format', async () => { + const result = await context.client.callTool({ + name: "delete_task", + arguments: { + projectId: "invalid-format", + taskId: "task-1" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project invalid-format not found/); + }); + + it('should return error for invalid task ID format', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const result = await context.client.callTool({ + name: "delete_task", + arguments: { + projectId: project.projectId, + taskId: "invalid-task-id" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Task invalid-task-id not found/); + }); + + it('should return error when trying to delete an approved task', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Project with Completed Task" + }); + + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Completed Task", + description: "A finished task to delete", + status: "done", + approved: true, + completedDetails: "Task was completed successfully" + }); + + const result = await context.client.callTool({ + name: "delete_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Cannot delete an approved task/); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/finalize-project.test.ts b/tests/mcp/tools/finalize-project.test.ts new file mode 100644 index 0000000..bbf28d4 --- /dev/null +++ b/tests/mcp/tools/finalize-project.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + createTestProjectInFile, + createTestTaskInFile, + verifyProjectInFile, + verifyToolExecutionError, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +describe('finalize_project Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should finalize a project with all tasks completed and approved', async () => { + // Create a project with completed and approved tasks + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project", + completed: false + }); + + // Add completed and approved tasks + await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 1", + description: "First task", + status: "done", + approved: true, + completedDetails: "Task 1 completed" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 2", + description: "Second task", + status: "done", + approved: true, + completedDetails: "Task 2 completed" + }) + ]); + + // Finalize the project + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + // Verify response + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Verify project state in file + await verifyProjectInFile(context.testFilePath, project.projectId, { + completed: true + }); + }); + + it('should finalize a project with auto-approved tasks', async () => { + // Create a project with auto-approve enabled + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Auto-approve Project", + autoApprove: true, + completed: false + }); + + // Add completed tasks (they should be auto-approved) + await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Auto Task 1", + description: "First auto-approved task", + status: "done", + approved: true, + completedDetails: "Auto task 1 completed" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Auto Task 2", + description: "Second auto-approved task", + status: "done", + approved: true, + completedDetails: "Auto task 2 completed" + }) + ]); + + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + await verifyProjectInFile(context.testFilePath, project.projectId, { + completed: true, + autoApprove: true + }); + }); + }); + + describe('Error Cases', () => { + it('should return error when project has incomplete tasks', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + projectId: "proj-1", + initialPrompt: "open project", + projectPlan: "test", + tasks: [{ + id: "task-1", + title: "open task", + description: "test", + status: "not started", + approved: false, + completedDetails: "" + }] + }); + + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Not all tasks are done/); + + // Verify project remains incomplete + await verifyProjectInFile(context.testFilePath, project.projectId, { + completed: false + }); + }); + + it('should return error when project has unapproved tasks', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + projectId: "proj-2", + initialPrompt: "pending approval project", + projectPlan: "test", + tasks: [{ + id: "task-2", + title: "pending approval task", + description: "test", + status: "done", + approved: false, + completedDetails: "completed" + }] + }); + + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Not all done tasks are approved/); + + await verifyProjectInFile(context.testFilePath, project.projectId, { + completed: false + }); + }); + + it('should return error when project is already completed', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + projectId: "proj-3", + initialPrompt: "completed project", + projectPlan: "test", + completed: true, + tasks: [{ + id: "task-3", + title: "completed task", + description: "test", + status: "done", + approved: true, + completedDetails: "completed" + }] + }); + + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project is already completed/); + }); + + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: "non_existent_project" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project non_existent_project not found/); + }); + + it('should return error for invalid project ID format', async () => { + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: "invalid-format" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project invalid-format not found/); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/generate-project-plan.test.ts b/tests/mcp/tools/generate-project-plan.test.ts new file mode 100644 index 0000000..9c3fa42 --- /dev/null +++ b/tests/mcp/tools/generate-project-plan.test.ts @@ -0,0 +1,338 @@ +import { describe, it, expect } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + verifyToolExecutionError, +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; + +describe('generate_project_plan Tool', () => { + describe('OpenAI Provider', () => { + // Skip by default as it requires OpenAI API key + it.skip('should generate a project plan using OpenAI', async () => { + // Create context with default API keys + const context = await setupTestContext(); + + try { + // Skip if no OpenAI API key is set + const openaiApiKey = process.env.OPENAI_API_KEY; + if (!openaiApiKey) { + console.error('Skipping test: OPENAI_API_KEY not set'); + return; + } + + // Create a temporary requirements file + const requirementsPath = path.join(context.tempDir, 'requirements.md'); + const requirements = `# Project Plan Requirements + +- This is a test of whether we are correctly attaching files to our prompt +- Return a JSON project plan with one task +- Task title must be 'AmazingTask' +- Task description must be AmazingDescription +- Project plan attribute should be AmazingPlan`; + + await fs.writeFile(requirementsPath, requirements, 'utf-8'); + + // Test prompt and context + const testPrompt = "Create a step-by-step project plan to build a simple TODO app with React"; + + // Generate project plan + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: testPrompt, + provider: "openai", + model: "gpt-4o-mini", + attachments: [requirementsPath] + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + const planData = JSON.parse((result.content[0] as { text: string }).text); + + // Verify the generated plan structure + expect(planData).toHaveProperty('tasks'); + expect(Array.isArray(planData.tasks)).toBe(true); + expect(planData.tasks.length).toBeGreaterThan(0); + + // Verify task structure + const firstTask = planData.tasks[0]; + expect(firstTask).toHaveProperty('title'); + expect(firstTask).toHaveProperty('description'); + + // Verify that the generated task adheres to the requirements file context + expect(firstTask.title).toBe('AmazingTask'); + expect(firstTask.description).toBe('AmazingDescription'); + } finally { + await teardownTestContext(context); + } + }); + + it('should handle OpenAI API errors gracefully', async () => { + // Create a new context without the OpenAI API key + const context = await setupTestContext(undefined, false, { + OPENAI_API_KEY: '', + GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '' + }); + + try { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "openai", + model: "gpt-4o-mini", + // Invalid/missing API key should cause an error + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Missing API key environment variable required for openai/); + } finally { + await teardownTestContext(context); + } + }); + }); + + describe('Google Provider', () => { + // Skip by default as it requires Google API key + it.skip('should generate a project plan using Google Gemini', async () => { + // Create context with default API keys + const context = await setupTestContext(); + + try { + // Skip if no Google API key is set + const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; + if (!googleApiKey) { + console.error('Skipping test: GOOGLE_GENERATIVE_AI_API_KEY not set'); + return; + } + + // Create a temporary requirements file + const requirementsPath = path.join(context.tempDir, 'google-requirements.md'); + const requirements = `# Project Plan Requirements (Google Test) + +- This is a test of whether we are correctly attaching files to our prompt for Google models +- Return a JSON project plan with one task +- Task title must be 'GeminiTask' +- Task description must be 'GeminiDescription' +- Project plan attribute should be 'GeminiPlan'`; + + await fs.writeFile(requirementsPath, requirements, 'utf-8'); + + // Test prompt and context + const testPrompt = "Create a step-by-step project plan to develop a cloud-native microservice using Go"; + + // Generate project plan using Google Gemini + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: testPrompt, + provider: "google", + model: "gemini-2.0-flash-001", + attachments: [requirementsPath] + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + const planData = JSON.parse((result.content[0] as { text: string }).text); + + // Verify the generated plan structure + expect(planData).toHaveProperty('tasks'); + expect(Array.isArray(planData.tasks)).toBe(true); + expect(planData.tasks.length).toBeGreaterThan(0); + + // Verify task structure + const firstTask = planData.tasks[0]; + expect(firstTask).toHaveProperty('title'); + expect(firstTask).toHaveProperty('description'); + + // Verify that the generated task adheres to the requirements file context + expect(firstTask.title).toBe('GeminiTask'); + expect(firstTask.description).toBe('GeminiDescription'); + } finally { + await teardownTestContext(context); + } + }); + + it('should handle Google API errors gracefully', async () => { + // Create a new context without the Google API key + const context = await setupTestContext(undefined, false, { + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '', + GOOGLE_GENERATIVE_AI_API_KEY: '' + }); + + try { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "google", + model: "gemini-1.5-flash-latest", + // Invalid/missing API key should cause an error + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Missing API key environment variable required for google/); + } finally { + await teardownTestContext(context); + } + }); + }); + + describe('Deepseek Provider', () => { + // Skip by default as it requires Deepseek API key + it.skip('should generate a project plan using Deepseek', async () => { + // Create context with default API keys + const context = await setupTestContext(); + + try { + // Skip if no Deepseek API key is set + const deepseekApiKey = process.env.DEEPSEEK_API_KEY; + if (!deepseekApiKey) { + console.error('Skipping test: DEEPSEEK_API_KEY not set'); + return; + } + + // Create a temporary requirements file + const requirementsPath = path.join(context.tempDir, 'deepseek-requirements.md'); + const requirements = `# Project Plan Requirements (Deepseek Test) + +- This is a test of whether we are correctly attaching files to our prompt for Deepseek models +- Return a JSON project plan with one task +- Task title must be 'DeepseekTask' +- Task description must be 'DeepseekDescription' +- Project plan attribute should be 'DeepseekPlan'`; + + await fs.writeFile(requirementsPath, requirements, 'utf-8'); + + // Test prompt and context + const testPrompt = "Create a step-by-step project plan to build a machine learning pipeline"; + + // Generate project plan using Deepseek + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: testPrompt, + provider: "deepseek", + model: "deepseek-chat", + attachments: [requirementsPath] + } + }) as CallToolResult; + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + const planData = JSON.parse((result.content[0] as { text: string }).text); + + // Verify the generated plan structure + expect(planData).toHaveProperty('data'); + expect(planData).toHaveProperty('tasks'); + expect(Array.isArray(planData.tasks)).toBe(true); + expect(planData.tasks.length).toBeGreaterThan(0); + + // Verify task structure + const firstTask = planData.tasks[0]; + expect(firstTask).toHaveProperty('title'); + expect(firstTask).toHaveProperty('description'); + + // Verify that the generated task adheres to the requirements file context + expect(firstTask.title).toBe('DeepseekTask'); + expect(firstTask.description).toBe('DeepseekDescription'); + } finally { + await teardownTestContext(context); + } + }); + + it('should handle Deepseek API errors gracefully', async () => { + // Create a new context without the Deepseek API key + const context = await setupTestContext(undefined, false, { + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? '', + GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '', + DEEPSEEK_API_KEY: '' + }); + + try { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "deepseek", + model: "deepseek-chat", + // Invalid/missing API key should cause an error + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Missing API key environment variable required for deepseek/); + } finally { + await teardownTestContext(context); + } + }); + }); + + describe('Error Cases', () => { + it('should return error for invalid provider', async () => { + const context = await setupTestContext(); + + try { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "invalid_provider", + model: "some-model" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Invalid provider: invalid_provider/); + } finally { + await teardownTestContext(context); + } + }); + + // Skip by default as it requires OpenAI API key + it.skip('should return error for invalid model', async () => { + const context = await setupTestContext(); + + try { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "openai", + model: "invalid-model" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Invalid model: invalid-model is not available for openai/); + } finally { + await teardownTestContext(context); + } + }); + + it('should return error for non-existent attachment file', async () => { + const context = await setupTestContext(); + + try { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "openai", + model: "gpt-4o-mini", + attachments: ["/non/existent/file.md"] + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Failed to read attachment file/); + } finally { + await teardownTestContext(context); + } + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/get-next-task.test.ts b/tests/mcp/tools/get-next-task.test.ts new file mode 100644 index 0000000..3390982 --- /dev/null +++ b/tests/mcp/tools/get-next-task.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolExecutionError, + verifyToolSuccessResponse, + createTestProjectInFile, + createTestTaskInFile, + readTaskManagerFile, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Task } from "../../../src/types/data.js"; + +interface GetNextTaskResponse { + task: Task; + projectId: string; +} + +describe('get_next_task Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should get first task when no tasks are started', async () => { + // Create a project with multiple unstarted tasks + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + // Create tasks sequentially to ensure order + const task1 = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 1", + description: "First task", + status: "not started" + }); + const task2 = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 2", + description: "Second task", + status: "not started" + }); + const tasks = [task1, task2]; + + // Verify tasks are in expected order in the file + const fileData = await readTaskManagerFile(context.testFilePath); + const projectInFile = fileData.projects.find((p: { projectId: string }) => p.projectId === project.projectId); + expect(projectInFile?.tasks[0].title).toBe("Task 1"); + expect(projectInFile?.tasks[1].title).toBe("Task 2"); + + // Get next task + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + const responseData = verifyToolSuccessResponse(result); + expect(responseData.task).toMatchObject({ + id: tasks[0].id, + title: "Task 1", + status: "not started" + }); + }); + + it('should get next incomplete task after completed tasks', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Sequential Tasks" + }); + + // Create tasks with first one completed + await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Done Task", + description: "Already completed", + status: "done", + approved: true, + completedDetails: "Completed first" + }); + const nextTask = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Next Task", + description: "Should be next", + status: "not started" + }); + + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + const responseData = verifyToolSuccessResponse(result); + expect(responseData.task).toMatchObject({ + id: nextTask.id, + title: "Next Task", + status: "not started" + }); + }); + + it('should get in-progress task if one exists', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Project with In-progress Task" + }); + + // Create multiple tasks with one in progress + await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Done Task", + description: "Already completed", + status: "done", + approved: true, + completedDetails: "Completed" + }); + const inProgressTask = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Current Task", + description: "In progress", + status: "in progress" + }); + await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Future Task", + description: "Not started yet", + status: "not started" + }); + + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + const responseData = verifyToolSuccessResponse(result); + expect(responseData.task).toMatchObject({ + id: inProgressTask.id, + title: "Current Task", + status: "in progress" + }); + }); + + it('should return error when all tasks are completed', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Completed Project", + completed: true + }); + + // Create only completed tasks + await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 1", + description: "First done", + status: "done", + approved: true, + completedDetails: "Done" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 2", + description: "Second done", + status: "done", + approved: true, + completedDetails: "Done" + }) + ]); + + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project is already completed/); + }); + }); + + describe('Error Cases', () => { + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: "non_existent_project" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project non_existent_project not found/); + }); + + it('should return error for invalid project ID format', async () => { + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: "invalid-format" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project invalid-format not found/); + }); + + it('should return error for project with no tasks', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Empty Project", + tasks: [] + }); + + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project has no tasks/); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/list-projects.test.ts b/tests/mcp/tools/list-projects.test.ts new file mode 100644 index 0000000..6ddfea5 --- /dev/null +++ b/tests/mcp/tools/list-projects.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + verifyToolExecutionError, + createTestProject, + getFirstTaskId, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import path from 'path'; +import os from 'os'; + +describe('list_projects Tool', () => { + describe('Success Cases', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + it('should list projects with no filters', async () => { + // Create a test project first + const projectId = await createTestProject(context.client); + + // Test list_projects + const result = await context.client.callTool({ + name: "list_projects", + arguments: {} + }) as CallToolResult; + + // Verify response format + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response data + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toHaveProperty('message'); + expect(responseData).toHaveProperty('projects'); + expect(Array.isArray(responseData.projects)).toBe(true); + + // Verify our test project is in the list + const projects = responseData.projects; + const testProject = projects.find((p: any) => p.projectId === projectId); + expect(testProject).toBeDefined(); + expect(testProject).toHaveProperty('initialPrompt'); + expect(testProject).toHaveProperty('totalTasks'); + expect(testProject).toHaveProperty('completedTasks'); + expect(testProject).toHaveProperty('approvedTasks'); + }); + + it('should filter projects by state', async () => { + // Create two projects with different states + const openProjectId = await createTestProject(context.client, { + initialPrompt: "Open Project", + tasks: [{ title: "Open Task", description: "This task will remain open" }] + }); + + const completedProjectId = await createTestProject(context.client, { + initialPrompt: "Completed Project", + tasks: [{ title: "Done Task", description: "This task will be completed" }], + autoApprove: true + }); + + // Complete the second project's task + const taskId = await getFirstTaskId(context.client, completedProjectId); + await context.client.callTool({ + name: "update_task", + arguments: { + projectId: completedProjectId, + taskId, + status: "done", + completedDetails: "Task completed in test" + } + }); + + // Approve and finalize the project + await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: completedProjectId, + taskId + } + }); + + await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: completedProjectId + } + }); + + // Test filtering by 'open' state + const openResult = await context.client.callTool({ + name: "list_projects", + arguments: { state: "open" } + }) as CallToolResult; + + verifyCallToolResult(openResult); + const openData = JSON.parse((openResult.content[0] as { text: string }).text); + const openProjects = openData.projects; + expect(openProjects.some((p: any) => p.projectId === openProjectId)).toBe(true); + expect(openProjects.some((p: any) => p.projectId === completedProjectId)).toBe(false); + + // Test filtering by 'completed' state + const completedResult = await context.client.callTool({ + name: "list_projects", + arguments: { state: "completed" } + }) as CallToolResult; + + verifyCallToolResult(completedResult); + const completedData = JSON.parse((completedResult.content[0] as { text: string }).text); + const completedProjects = completedData.projects; + expect(completedProjects.some((p: any) => p.projectId === completedProjectId)).toBe(true); + expect(completedProjects.some((p: any) => p.projectId === openProjectId)).toBe(false); + }); + }); + + describe('Error Cases', () => { + describe('Validation Errors', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + it('should handle invalid state parameter', async () => { + const result = await context.client.callTool({ + name: "list_projects", + arguments: { state: "invalid_state" } + }) as CallToolResult; + + verifyToolExecutionError(result, /Invalid state parameter. Must be one of: open, pending_approval, completed, all/); + }); + }); + + describe('File System Errors', () => { + let errorContext: TestContext; + const invalidPathDir = path.join(os.tmpdir(), 'nonexistent-dir'); + const invalidFilePath = path.join(invalidPathDir, 'invalid-file.json'); + + beforeAll(async () => { + // Set up test context with invalid file path, skipping file initialization + errorContext = await setupTestContext(invalidFilePath, true); + }); + + afterAll(async () => { + await teardownTestContext(errorContext); + }); + + it('should handle server errors gracefully', async () => { + const result = await errorContext.client.callTool({ + name: "list_projects", + arguments: {} + }) as CallToolResult; + + verifyToolExecutionError(result, /Failed to reload tasks from disk/); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/list-tasks.test.ts b/tests/mcp/tools/list-tasks.test.ts new file mode 100644 index 0000000..85427a2 --- /dev/null +++ b/tests/mcp/tools/list-tasks.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + verifyToolExecutionError, + createTestProject, + getFirstTaskId, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import path from 'path'; +import os from 'os'; + +describe('list_tasks Tool', () => { + describe('Success Cases', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + it('should list all tasks with no filters', async () => { + // Create a test project with tasks + const projectId = await createTestProject(context.client, { + initialPrompt: "Test Project", + tasks: [ + { title: "Task 1", description: "First test task" }, + { title: "Task 2", description: "Second test task" } + ] + }); + + // Test list_tasks with no filters + const result = await context.client.callTool({ + name: "list_tasks", + arguments: {} + }) as CallToolResult; + + // Verify response format + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response data + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toHaveProperty('message'); + expect(responseData).toHaveProperty('tasks'); + expect(Array.isArray(responseData.tasks)).toBe(true); + expect(responseData.tasks.length).toBe(2); + + // Verify task properties + const tasks = responseData.tasks; + tasks.forEach((task: any) => { + expect(task).toHaveProperty('id'); + expect(task).toHaveProperty('title'); + expect(task).toHaveProperty('description'); + expect(task).toHaveProperty('status'); + expect(task).toHaveProperty('approved'); + }); + }); + + it('should filter tasks by project ID', async () => { + // Create two projects with different tasks + const project1Id = await createTestProject(context.client, { + initialPrompt: "Project 1", + tasks: [{ title: "P1 Task", description: "Project 1 task" }] + }); + + const project2Id = await createTestProject(context.client, { + initialPrompt: "Project 2", + tasks: [{ title: "P2 Task", description: "Project 2 task" }] + }); + + // Test filtering by project1 + const result1 = await context.client.callTool({ + name: "list_tasks", + arguments: { projectId: project1Id } + }) as CallToolResult; + + verifyCallToolResult(result1); + const data1 = JSON.parse((result1.content[0] as { text: string }).text); + expect(data1.tasks.length).toBe(1); + expect(data1.tasks[0].title).toBe("P1 Task"); + + // Test filtering by project2 + const result2 = await context.client.callTool({ + name: "list_tasks", + arguments: { projectId: project2Id } + }) as CallToolResult; + + verifyCallToolResult(result2); + const data2 = JSON.parse((result2.content[0] as { text: string }).text); + expect(data2.tasks.length).toBe(1); + expect(data2.tasks[0].title).toBe("P2 Task"); + }); + + it('should filter tasks by state', async () => { + // Create a project with tasks in different states + const projectId = await createTestProject(context.client, { + initialPrompt: "Mixed States Project", + tasks: [ + { title: "Not Started Task", description: "This task will remain not started" }, + { title: "Done But Not Approved Task", description: "This task will be done but not approved" }, + { title: "Completed And Approved Task", description: "This task will be completed and approved" } + ] + }); + + // Get task IDs for each task + const tasks = (await context.client.callTool({ + name: "list_tasks", + arguments: { projectId } + }) as CallToolResult); + const [notStartedTaskId, doneNotApprovedTaskId, completedTaskId] = JSON.parse((tasks.content[0] as { text: string }).text) + .tasks.map((t: any) => t.id); + + // Set up task states: + // 1. Leave first task as is (not started) + // 2. Mark second task as done (but not approved) + await context.client.callTool({ + name: "update_task", + arguments: { + projectId, + taskId: doneNotApprovedTaskId, + status: "done", + completedDetails: "Task completed in test" + } + }); + + // 3. Mark third task as done and approved + await context.client.callTool({ + name: "update_task", + arguments: { + projectId, + taskId: completedTaskId, + status: "done", + completedDetails: "Task completed in test" + } + }); + + await context.client.callTool({ + name: "approve_task", + arguments: { + projectId, + taskId: completedTaskId + } + }); + + // Test filtering by 'open' state - should include both not started and done-but-not-approved tasks + const openResult = await context.client.callTool({ + name: "list_tasks", + arguments: { + projectId, + state: "open" + } + }) as CallToolResult; + + verifyCallToolResult(openResult); + const openData = JSON.parse((openResult.content[0] as { text: string }).text); + expect(openData.tasks.some((t: any) => t.title === "Not Started Task")).toBe(true); + expect(openData.tasks.some((t: any) => t.title === "Done But Not Approved Task")).toBe(true); + expect(openData.tasks.some((t: any) => t.title === "Completed And Approved Task")).toBe(false); + expect(openData.tasks.length).toBe(2); // Should have both non-approved tasks + + // Test filtering by 'pending_approval' state + const pendingResult = await context.client.callTool({ + name: "list_tasks", + arguments: { + projectId, + state: "pending_approval" + } + }) as CallToolResult; + + verifyCallToolResult(pendingResult); + const pendingData = JSON.parse((pendingResult.content[0] as { text: string }).text); + expect(pendingData.tasks.some((t: any) => t.title === "Done But Not Approved Task")).toBe(true); + expect(pendingData.tasks.some((t: any) => t.title === "Not Started Task")).toBe(false); + expect(pendingData.tasks.some((t: any) => t.title === "Completed And Approved Task")).toBe(false); + expect(pendingData.tasks.length).toBe(1); // Should only have the done-but-not-approved task + + // Test filtering by 'completed' state + const completedResult = await context.client.callTool({ + name: "list_tasks", + arguments: { + projectId, + state: "completed" + } + }) as CallToolResult; + + verifyCallToolResult(completedResult); + const completedData = JSON.parse((completedResult.content[0] as { text: string }).text); + expect(completedData.tasks.some((t: any) => t.title === "Completed And Approved Task")).toBe(true); + expect(completedData.tasks.some((t: any) => t.title === "Not Started Task")).toBe(false); + expect(completedData.tasks.some((t: any) => t.title === "Done But Not Approved Task")).toBe(false); + expect(completedData.tasks.length).toBe(1); // Should only have the completed and approved task + }); + + it('should combine project ID and state filters', async () => { + // Create two projects with tasks in different states + const project1Id = await createTestProject(context.client, { + initialPrompt: "Project 1", + tasks: [ + { title: "P1 Not Started Task", description: "Project 1 not started task" }, + { title: "P1 Completed Task", description: "Project 1 completed task" } + ] + }); + + const project2Id = await createTestProject(context.client, { + initialPrompt: "Project 2", + tasks: [ + { title: "P2 Not Started Task", description: "Project 2 not started task" }, + { title: "P2 Completed Task", description: "Project 2 completed task" } + ] + }); + + // Get task IDs for each project + const p1Tasks = (await context.client.callTool({ + name: "list_tasks", + arguments: { projectId: project1Id } + }) as CallToolResult); + const [p1OpenTaskId, p1CompletedTaskId] = JSON.parse((p1Tasks.content[0] as { text: string }).text) + .tasks.map((t: any) => t.id); + + const p2Tasks = (await context.client.callTool({ + name: "list_tasks", + arguments: { projectId: project2Id } + }) as CallToolResult); + const [p2OpenTaskId, p2CompletedTaskId] = JSON.parse((p2Tasks.content[0] as { text: string }).text) + .tasks.map((t: any) => t.id); + + // Complete and approve one task in each project + await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project1Id, + taskId: p1CompletedTaskId, + status: "done", + completedDetails: "Task completed in test" + } + }); + + await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project1Id, + taskId: p1CompletedTaskId + } + }); + + await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project2Id, + taskId: p2CompletedTaskId, + status: "done", + completedDetails: "Task completed in test" + } + }); + + await context.client.callTool({ + name: "approve_task", + arguments: { + projectId: project2Id, + taskId: p2CompletedTaskId + } + }); + + // Test combined filtering - should only show non-approved tasks from project1 + const result = await context.client.callTool({ + name: "list_tasks", + arguments: { + projectId: project1Id, + state: "open" + } + }) as CallToolResult; + + verifyCallToolResult(result); + const data = JSON.parse((result.content[0] as { text: string }).text); + expect(data.tasks.length).toBe(1); + expect(data.tasks[0].title).toBe("P1 Not Started Task"); + }); + }); + + describe('Error Cases', () => { + describe('Validation Errors', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + it('should handle invalid state parameter', async () => { + const result = await context.client.callTool({ + name: "list_tasks", + arguments: { state: "invalid_state" } + }) as CallToolResult; + + verifyToolExecutionError(result, /Invalid state parameter. Must be one of: open, pending_approval, completed, all/); + }); + + it('should handle invalid project ID', async () => { + const result = await context.client.callTool({ + name: "list_tasks", + arguments: { projectId: "non-existent-project" } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project non-existent-project not found/); + }); + }); + + describe('File System Errors', () => { + let errorContext: TestContext; + const invalidPathDir = path.join(os.tmpdir(), 'nonexistent-dir'); + const invalidFilePath = path.join(invalidPathDir, 'invalid-file.json'); + + beforeAll(async () => { + // Set up test context with invalid file path, skipping file initialization + errorContext = await setupTestContext(invalidFilePath, true); + }); + + afterAll(async () => { + await teardownTestContext(errorContext); + }); + + it('should handle server errors gracefully', async () => { + const result = await errorContext.client.callTool({ + name: "list_tasks", + arguments: {} + }) as CallToolResult; + + verifyToolExecutionError(result, /Failed to reload tasks from disk/); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/read-project.test.ts b/tests/mcp/tools/read-project.test.ts new file mode 100644 index 0000000..19d56d6 --- /dev/null +++ b/tests/mcp/tools/read-project.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + createTestProjectInFile, + createTestTaskInFile, + TestContext, + verifyToolExecutionError +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; + +describe('read_project Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should read a project with minimal data', async () => { + // Create a test project + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project", + projectPlan: "", + completed: false + }); + await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + description: "Test Description" + }); + + // Read the project + const result = await context.client.callTool({ + name: "read_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + // Verify response + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Verify project data + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toMatchObject({ + projectId: project.projectId, + initialPrompt: "Test Project", + completed: false, + tasks: [{ + title: "Test Task", + description: "Test Description", + status: "not started", + approved: false + }] + }); + }); + + it('should read a project with all optional fields', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Full Project", + projectPlan: "Detailed project plan", + completed: false, + autoApprove: true + }); + await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Full Task", + description: "Task with all fields", + status: "done", + approved: true, + completedDetails: "Task completed", + toolRecommendations: "Use these tools", + ruleRecommendations: "Follow these rules" + }); + + const result = await context.client.callTool({ + name: "read_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toMatchObject({ + projectId: project.projectId, + initialPrompt: "Full Project", + projectPlan: "Detailed project plan", + completed: false, + autoApprove: true, + tasks: [{ + title: "Full Task", + description: "Task with all fields", + status: "done", + approved: true, + completedDetails: "Task completed", + toolRecommendations: "Use these tools", + ruleRecommendations: "Follow these rules" + }] + }); + }); + + it('should read a completed project', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Completed Project", + completed: true + }); + await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Completed Task", + description: "This task is done", + status: "done", + approved: true, + completedDetails: "Task completed" + }); + + const result = await context.client.callTool({ + name: "read_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toMatchObject({ + projectId: project.projectId, + completed: true, + tasks: [{ + status: "done", + approved: true + }] + }); + }); + + it('should read a project with multiple tasks', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Multi-task Project" + }); + + // Create tasks in different states + await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 1", + description: "Not started", + status: "not started" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 2", + description: "In progress", + status: "in progress" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 3", + description: "Completed", + status: "done", + approved: true, + completedDetails: "Done and approved" + }) + ]); + + const result = await context.client.callTool({ + name: "read_project", + arguments: { + projectId: project.projectId + } + }) as CallToolResult; + + verifyCallToolResult(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.tasks).toHaveLength(3); + expect(responseData.tasks.map((t: any) => t.status)).toEqual([ + "not started", + "in progress", + "done" + ]); + }); + }); + + describe('Error Cases', () => { + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "read_project", + arguments: { + projectId: "non_existent_project" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project non_existent_project not found/); + }); + + it('should return error for invalid project ID format', async () => { + const result = await context.client.callTool({ + name: "read_project", + arguments: { + projectId: "invalid-format" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project invalid-format not found/); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/read-task.test.ts b/tests/mcp/tools/read-task.test.ts new file mode 100644 index 0000000..233e932 --- /dev/null +++ b/tests/mcp/tools/read-task.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolExecutionError, + verifyToolSuccessResponse, + createTestProjectInFile, + createTestTaskInFile, + TestContext +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Task } from "../../../src/types/data.js"; + +describe('read_task Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should successfully read an existing task', async () => { + // Create a project with a task + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + description: "Task description", + status: "not started" + }); + + const result = await context.client.callTool({ + name: "read_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + const responseData = verifyToolSuccessResponse<{ task: Task }>(result); + expect(responseData.task).toMatchObject({ + id: task.id, + title: "Test Task", + description: "Task description", + status: "not started" + }); + }); + + it('should read a completed task with all details', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Project with Completed Task" + }); + + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Completed Task", + description: "A finished task", + status: "done", + approved: true, + completedDetails: "Task was completed successfully", + toolRecommendations: "Used tool X and Y", + ruleRecommendations: "Applied rule Z" + }); + + const result = await context.client.callTool({ + name: "read_task", + arguments: { + projectId: project.projectId, + taskId: task.id + } + }) as CallToolResult; + + const responseData = verifyToolSuccessResponse<{ task: Task }>(result); + expect(responseData.task).toMatchObject({ + id: task.id, + title: "Completed Task", + description: "A finished task", + status: "done", + approved: true, + completedDetails: "Task was completed successfully", + toolRecommendations: "Used tool X and Y", + ruleRecommendations: "Applied rule Z" + }); + }); + }); + + describe('Error Cases', () => { + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "read_task", + arguments: { + projectId: "non_existent_project", + taskId: "task-1" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project non_existent_project not found/); + }); + + it('should return error for non-existent task in existing project', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const result = await context.client.callTool({ + name: "read_task", + arguments: { + projectId: project.projectId, + taskId: "non-existent-task" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Task non-existent-task not found/); + }); + + it('should return error for invalid project ID format', async () => { + const result = await context.client.callTool({ + name: "read_task", + arguments: { + projectId: "invalid-format", + taskId: "task-1" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Project invalid-format not found/); + }); + + it('should return error for invalid task ID format', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const result = await context.client.callTool({ + name: "read_task", + arguments: { + projectId: project.projectId, + taskId: "invalid-task-id" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Tool execution failed: Task invalid-task-id not found/); + }); + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/update-task.test.ts b/tests/mcp/tools/update-task.test.ts new file mode 100644 index 0000000..ddd42d0 --- /dev/null +++ b/tests/mcp/tools/update-task.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyCallToolResult, + createTestProjectInFile, + createTestTaskInFile, + verifyTaskInFile, + TestContext, + verifyProtocolError +} from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { verifyToolExecutionError } from '../test-helpers.js'; + +describe('update_task Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + it('should update task status to in progress', async () => { + // Create test data directly in file + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + status: "not started" + }); + + // Update task status + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + status: "in progress" + } + }) as CallToolResult; + + // Verify response + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + // Verify file was updated + await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { + status: "in progress" + }); + }); + + it('should update task to done with completedDetails', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + status: "in progress" + }); + + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + status: "done", + completedDetails: "Task completed in test" + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { + status: "done", + completedDetails: "Task completed in test" + }); + }); + + it('should update task title and description', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Original Title", + description: "Original Description" + }); + + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + title: "Updated Title", + description: "Updated Description" + } + }) as CallToolResult; + + verifyCallToolResult(result); + expect(result.isError).toBeFalsy(); + + await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { + title: "Updated Title", + description: "Updated Description" + }); + }); + }); + + describe('Error Cases', () => { + it('should return error for invalid status value', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task" + }); + + try { + await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + status: "invalid_status" // Invalid status value + } + }); + fail('Expected error was not thrown'); + } catch (error) { + verifyProtocolError(error, -32602, "Invalid status: must be one of 'not started', 'in progress', 'done'"); + } + }); + + it('should return error when marking task as done without completedDetails', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + status: "in progress" + }); + + try { + await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + status: "done" + // Missing required completedDetails + } + }); + fail('Expected error was not thrown'); + } catch (error) { + verifyProtocolError(error, -32602, "Invalid or missing required parameter: completedDetails (required when status = 'done') (Expected string)"); + } + }); + + it('should return error for non-existent project', async () => { + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: "non_existent_project", + taskId: "task-1", + status: "in progress" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Project non_existent_project not found/); + }); + + it('should return error for non-existent task', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: "non_existent_task", + status: "in progress" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Task non_existent_task not found/); + }); + + it('should return error when updating approved task', async () => { + const project = await createTestProjectInFile(context.testFilePath, { + initialPrompt: "Test Project" + }); + const task = await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Test Task", + status: "done", + approved: true, + completedDetails: "Already completed" + }); + + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + title: "New Title" + } + }) as CallToolResult; + + verifyToolExecutionError(result, /Cannot modify an approved task/); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/FileSystemService.test.ts b/tests/unit/FileSystemService.test.ts deleted file mode 100644 index 34beb55..0000000 --- a/tests/unit/FileSystemService.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -// tests/unit/FileSystemService.test.ts - -import { describe, it, expect, jest, beforeEach, beforeAll } from '@jest/globals'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import { TaskManagerFile } from '../../src/types/index.js'; -import type { FileSystemService as FileSystemServiceType } from '../../src/server/FileSystemService.js'; // Import type only -import type * as FSPromises from 'node:fs/promises'; // Import type only - -// Set up mocks before importing fs/promises -jest.unstable_mockModule('node:fs/promises', () => ({ - __esModule: true, - // Use jest.fn() directly, specific implementations will be set in tests or beforeEach - readFile: jest.fn(), - writeFile: jest.fn(), - mkdir: jest.fn(), -})); - -// Declare variables for dynamically imported modules and mocks -let FileSystemService: typeof FileSystemServiceType; -let readFile: jest.MockedFunction; -let writeFile: jest.MockedFunction; -let mkdir: jest.MockedFunction; - -describe('FileSystemService', () => { - let fileSystemService: FileSystemServiceType; - let tempDir: string; - let tasksFilePath: string; - - // Use beforeAll for dynamic imports - beforeAll(async () => { - // Dynamically import the mocked functions - const fsPromisesMock = await import('node:fs/promises'); - readFile = fsPromisesMock.readFile as jest.MockedFunction; - writeFile = fsPromisesMock.writeFile as jest.MockedFunction; - mkdir = fsPromisesMock.mkdir as jest.MockedFunction; - - // Dynamically import the class under test AFTER mocks are set up - const serviceModule = await import('../../src/server/FileSystemService.js'); - FileSystemService = serviceModule.FileSystemService; - }); - - - beforeEach(() => { - // Reset mocks before each test - jest.clearAllMocks(); - - // Set default mock implementations (can be overridden in tests) - // Default to empty file for readFile unless specified otherwise - readFile.mockResolvedValue(''); - writeFile.mockResolvedValue(undefined); // Default successful write - mkdir.mockResolvedValue(undefined); // Default successful mkdir - - // Keep temp path generation logic - tempDir = path.join(os.tmpdir(), `file-system-service-test-${Date.now()}`); - tasksFilePath = path.join(tempDir, "test-tasks.json"); - - // Instantiate the service for each test using the dynamically imported class - fileSystemService = new FileSystemService(tasksFilePath); - }); - - describe('loadAndInitializeTasks', () => { - it('should initialize with empty data when file does not exist', async () => { - // Simulate "file not found" by rejecting - jest.mocked(readFile).mockRejectedValueOnce(new Error('ENOENT')); - - const result = await fileSystemService.loadAndInitializeTasks(); - expect(result.data).toEqual({ projects: [] }); - expect(result.maxProjectId).toBe(0); - expect(result.maxTaskId).toBe(0); - }); - - it('should load existing data and calculate correct max IDs', async () => { - const mockData: TaskManagerFile = { - projects: [ - { - projectId: 'proj-2', - initialPrompt: 'test', - projectPlan: 'test', - tasks: [ - { id: 'task-3', title: 'Task 1', description: 'Test', status: 'not started', approved: false, completedDetails: '' }, - { id: 'task-1', title: 'Task 2', description: 'Test', status: 'not started', approved: false, completedDetails: '' } - ], - completed: false, - autoApprove: false - }, - { - projectId: 'proj-1', - initialPrompt: 'test', - projectPlan: 'test', - tasks: [ - { id: 'task-2', title: 'Task 3', description: 'Test', status: 'not started', approved: false, completedDetails: '' } - ], - completed: false, - autoApprove: false - } - ] - }; - jest.mocked(readFile).mockResolvedValueOnce(JSON.stringify(mockData)); - - const result = await fileSystemService.loadAndInitializeTasks(); - expect(result.data).toEqual(mockData); - expect(result.maxProjectId).toBe(2); - expect(result.maxTaskId).toBe(3); - }); - - it('should handle invalid project and task IDs', async () => { - const mockData: TaskManagerFile = { - projects: [ - { - projectId: 'proj-invalid', - initialPrompt: 'test', - projectPlan: 'test', - tasks: [ - { id: 'task-invalid', title: 'Task 1', description: 'Test', status: 'not started', approved: false, completedDetails: '' } - ], - completed: false, - autoApprove: false - } - ] - }; - - jest.mocked(readFile).mockResolvedValueOnce(JSON.stringify(mockData)); - - const result = await fileSystemService.loadAndInitializeTasks(); - - expect(result.data).toEqual(mockData); - expect(result.maxProjectId).toBe(0); - expect(result.maxTaskId).toBe(0); - }); - }); - - describe('saveTasks', () => { - it('should create directory and save tasks', async () => { - const mockData: TaskManagerFile = { - projects: [] - }; - await fileSystemService.saveTasks(mockData); - - // Now we can check our mock calls - expect(mkdir).toHaveBeenCalledWith(path.dirname(tasksFilePath), { recursive: true }); - expect(writeFile).toHaveBeenCalledWith( - tasksFilePath, - JSON.stringify(mockData, null, 2), - 'utf-8' - ); - }); - - it('should handle read-only filesystem error', async () => { - jest.mocked(writeFile).mockRejectedValueOnce(new Error('EROFS: read-only file system')); - await expect(fileSystemService.saveTasks({ projects: [] })).rejects.toMatchObject({ - code: 'ERR_4003', - message: 'Cannot save tasks: read-only file system' - }); - }); - - it('should handle general file write error', async () => { - jest.mocked(writeFile).mockRejectedValueOnce(new Error('Some other error')); - await expect(fileSystemService.saveTasks({ projects: [] })).rejects.toMatchObject({ - code: 'ERR_4001', - message: 'Failed to save tasks file' - }); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/StateTransitionRules.test.ts b/tests/unit/StateTransitionRules.test.ts deleted file mode 100644 index e685e48..0000000 --- a/tests/unit/StateTransitionRules.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -// tests/unit/StateTransitionRules.test.ts -import { describe, it, expect } from '@jest/globals'; -import { VALID_STATUS_TRANSITIONS } from '../../src/types/index.js'; - -describe('Task Status Transition Rules', () => { - // Test the status transition validation logic - describe('Valid transitions', () => { - it('should define that tasks in "not started" status can only transition to "in progress"', () => { - const validTransitions = VALID_STATUS_TRANSITIONS['not started']; - expect(validTransitions).toContain('in progress'); - expect(validTransitions.length).toBe(1); - }); - - it('should define that tasks in "in progress" status can transition to "done" or back to "not started"', () => { - const validTransitions = VALID_STATUS_TRANSITIONS['in progress']; - expect(validTransitions).toContain('done'); - expect(validTransitions).toContain('not started'); - expect(validTransitions.length).toBe(2); - }); - - it('should define that tasks in "done" status can only transition back to "in progress"', () => { - const validTransitions = VALID_STATUS_TRANSITIONS['done']; - expect(validTransitions).toContain('in progress'); - expect(validTransitions.length).toBe(1); - }); - }); - - describe('Invalid transitions', () => { - it('should not allow direct transition from "not started" to "done"', () => { - const validTransitions = VALID_STATUS_TRANSITIONS['not started']; - expect(validTransitions).not.toContain('done'); - }); - - it('should not allow direct transition from "done" to "not started"', () => { - const validTransitions = VALID_STATUS_TRANSITIONS['done']; - expect(validTransitions).not.toContain('not started'); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/TaskManager.test.ts b/tests/unit/TaskManager.test.ts deleted file mode 100644 index dc615a1..0000000 --- a/tests/unit/TaskManager.test.ts +++ /dev/null @@ -1,1090 +0,0 @@ -import { describe, it, expect, jest, beforeEach, beforeAll } from '@jest/globals'; -import { ALL_TOOLS } from '../../src/server/tools.js'; -import { VALID_STATUS_TRANSITIONS, Task, StandardResponse, TaskManagerFile } from '../../src/types/index.js'; -import type { TaskManager as TaskManagerType } from '../../src/server/TaskManager.js'; -import type { FileSystemService as FileSystemServiceType } from '../../src/server/FileSystemService.js'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; -import type { generateObject as GenerateObjectType, jsonSchema as JsonSchemaType } from 'ai'; - -jest.unstable_mockModule('ai', () => ({ - __esModule: true, - generateObject: jest.fn(), - jsonSchema: jest.fn(), -})); - -jest.unstable_mockModule('@ai-sdk/openai', () => ({ - __esModule: true, - openai: jest.fn(), -})); - -jest.unstable_mockModule('@ai-sdk/google', () => ({ - __esModule: true, - google: jest.fn(), -})); - -jest.unstable_mockModule('@ai-sdk/deepseek', () => ({ - __esModule: true, - deepseek: jest.fn(), -})); - -// Create mock functions for FileSystemService instance methods -const mockLoadAndInitializeTasks = jest.fn() as jest.MockedFunction; -const mockSaveTasks = jest.fn() as jest.MockedFunction; -const mockCalculateMaxIds = jest.fn() as jest.MockedFunction; -const mockLoadTasks = jest.fn() as jest.MockedFunction; -const mockReloadTasks = jest.fn() as jest.MockedFunction; -const mockReadAttachmentFile = jest.fn() as jest.MockedFunction; - -// Create mock functions for FileSystemService static methods -const mockGetAppDataDir = jest.fn() as jest.MockedFunction; - -jest.unstable_mockModule('../../src/server/FileSystemService.js', () => { - class MockFileSystemService { - constructor() {} - loadAndInitializeTasks = mockLoadAndInitializeTasks; - saveTasks = mockSaveTasks; - calculateMaxIds = mockCalculateMaxIds; - loadTasks = mockLoadTasks; - reloadTasks = mockReloadTasks; - readAttachmentFile = mockReadAttachmentFile; - static getAppDataDir = mockGetAppDataDir; - } - - return { - __esModule: true, - FileSystemService: MockFileSystemService, - }; -}); - -// Variables for dynamically imported modules -let TaskManager: typeof TaskManagerType; -let FileSystemService: jest.MockedClass; -let generateObject: jest.MockedFunction; -let jsonSchema: jest.MockedFunction; - -// Import modules after mocks are registered -beforeAll(async () => { - const aiModule = await import('ai'); - generateObject = aiModule.generateObject as jest.MockedFunction; - jsonSchema = aiModule.jsonSchema as jest.MockedFunction; -}); - -describe('TaskManager', () => { - let taskManager: InstanceType; - let tempDir: string; - let tasksFilePath: string; - - // --- Stateful Mock Data --- - let currentMockData: TaskManagerFile; - let currentMaxProjectId: number; - let currentMaxTaskId: number; - - // Helper to mimic calculateMaxIds logic (since we can't easily access the real one here) - const calculateMockMaxIds = (data: TaskManagerFile): { maxProjectId: number; maxTaskId: number } => { - let maxProj = 0; - let maxTask = 0; - for (const proj of data.projects) { - const projNum = parseInt(proj.projectId.split('-')[1] ?? '0', 10); - if (!isNaN(projNum) && projNum > maxProj) maxProj = projNum; - for (const task of proj.tasks) { - const taskNum = parseInt(task.id.split('-')[1] ?? '0', 10); - if (!isNaN(taskNum) && taskNum > maxTask) maxTask = taskNum; - } - } - return { maxProjectId: maxProj, maxTaskId: maxTask }; - }; - - beforeEach(async () => { - // Reset all mocks - jest.clearAllMocks(); - - // Reset mock data - this is key to prevent data from persisting between tests - currentMockData = { projects: [] }; - currentMaxProjectId = 0; - currentMaxTaskId = 0; - - // Initial load returns current (empty) state and calculated IDs - mockLoadAndInitializeTasks.mockImplementation(async () => { - const maxIds = calculateMockMaxIds(currentMockData); - currentMaxProjectId = maxIds.maxProjectId; - currentMaxTaskId = maxIds.maxTaskId; - return { data: JSON.parse(JSON.stringify(currentMockData)), maxProjectId: currentMaxProjectId, maxTaskId: currentMaxTaskId }; - }); - - // Save updates the state and recalculates max IDs - mockSaveTasks.mockImplementation(async (dataToSave: TaskManagerFile) => { - currentMockData = JSON.parse(JSON.stringify(dataToSave)); // Store a deep copy - const maxIds = calculateMockMaxIds(currentMockData); - currentMaxProjectId = maxIds.maxProjectId; - currentMaxTaskId = maxIds.maxTaskId; - return undefined; - }); - - // Reload returns the current state (deep copy) - mockReloadTasks.mockImplementation(async () => { - return JSON.parse(JSON.stringify(currentMockData)); - }); - - // Mock readAttachmentFile to return the filename as content for testing - mockReadAttachmentFile.mockImplementation(async (filename: string) => { - return filename; - }); - - // CalculateMaxIds uses the helper logic on potentially provided data - // Note: TaskManager might rely on its *internal* maxId counters more than calling this directly after init - mockCalculateMaxIds.mockImplementation((data: TaskManagerFile) => { - const result = calculateMockMaxIds(data || currentMockData); // Use provided data or current state - return result; - }); - - // Static method mock - mockGetAppDataDir.mockReturnValue('/mock/app/data/dir'); - - // Import modules after mocks are registered and implemented - const taskManagerModule = await import('../../src/server/TaskManager.js'); - TaskManager = taskManagerModule.TaskManager; - - const fileSystemModule = await import('../../src/server/FileSystemService.js'); - FileSystemService = fileSystemModule.FileSystemService as jest.MockedClass; - - // Create temporary directory for test files - tempDir = path.join(os.tmpdir(), `task-manager-test-${Date.now()}`); - tasksFilePath = path.join(tempDir, "test-tasks.json"); - - // Create a new TaskManager instance for each test - taskManager = new TaskManager(tasksFilePath); - - // This is important - we need to make sure the instance has properly initialized - // before running tests - await taskManager["initialized"]; - }); - - afterEach(async () => { - await fs.rm(tempDir, { recursive: true, force: true }); - }); - - describe('Configuration and Constants', () => { - describe('Tools Configuration', () => { - it('should have the required tools', () => { - const toolNames = ALL_TOOLS.map(tool => tool.name); - expect(toolNames).toContain('list_projects'); - expect(toolNames).toContain('create_project'); - expect(toolNames).toContain('delete_project'); - expect(toolNames).toContain('add_tasks_to_project'); - expect(toolNames).toContain('finalize_project'); - expect(toolNames).toContain('read_project'); - - expect(toolNames).toContain('read_task'); - expect(toolNames).toContain('update_task'); - expect(toolNames).toContain('delete_task'); - expect(toolNames).toContain('approve_task'); - expect(toolNames).toContain('get_next_task'); - }); - - it('should have proper tool schemas', () => { - ALL_TOOLS.forEach(tool => { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('inputSchema'); - expect(tool.inputSchema).toHaveProperty('type', 'object'); - }); - }); - }); - - describe('Status Transition Rules', () => { - it('should define valid transitions from not started status', () => { - expect(VALID_STATUS_TRANSITIONS['not started']).toEqual(['in progress']); - }); - - it('should define valid transitions from in progress status', () => { - expect(VALID_STATUS_TRANSITIONS['in progress']).toContain('done'); - expect(VALID_STATUS_TRANSITIONS['in progress']).toContain('not started'); - expect(VALID_STATUS_TRANSITIONS['in progress'].length).toBe(2); - }); - - it('should define valid transitions from done status', () => { - expect(VALID_STATUS_TRANSITIONS['done']).toEqual(['in progress']); - }); - - it('should not allow direct transition from not started to done', () => { - const notStartedTransitions = VALID_STATUS_TRANSITIONS['not started']; - expect(notStartedTransitions).not.toContain('done'); - }); - }); - }); - - describe('Basic Project Operations', () => { - it('should handle project creation', async () => { - const result = await taskManager.createProject( - 'Test project', - [ - { - title: 'Test task', - description: 'Test description' - } - ], - 'Test plan' - ); - - expect(result.status).toBe('success'); - if (result.status === 'success') { - expect(result.data.projectId).toBeDefined(); - expect(result.data.totalTasks).toBe(1); - - // Verify mock state was updated (optional, but good for debugging mocks) - expect(currentMockData.projects).toHaveLength(1); - expect(currentMockData.projects[0].projectId).toBe(result.data.projectId); - expect(currentMaxProjectId).toBe(1); // Assuming it starts at 1 - expect(currentMaxTaskId).toBe(1); - } - }); - - it('should handle project listing', async () => { - // Create a project first - const createResult = await taskManager.createProject( - 'Test project', - [ - { - title: 'Test task', - description: 'Test description' - } - ], - 'Test plan' - ); - - const result = await taskManager.listProjects(); - expect(result.status).toBe('success'); - if (result.status === 'success') { - expect(result.data.projects).toHaveLength(1); - } - }); - - it('should handle project deletion', async () => { - // Create a project first - const createResult = await taskManager.createProject( - 'Test project', - [ - { - title: 'Test task', - description: 'Test description' - } - ], - 'Test plan' - ); - - if (createResult.status === 'success') { - // Delete the project directly using data model access - const projectIndex = taskManager["data"].projects.findIndex((p: { projectId: string }) => p.projectId === createResult.data.projectId); - taskManager["data"].projects.splice(projectIndex, 1); - await taskManager["saveTasks"](); - } - - // Verify deletion - const listResult = await taskManager.listProjects(); - if (listResult.status === 'success') { - expect(listResult.data.projects).toHaveLength(0); - } - }); - }); - - describe('Basic Task Operations', () => { - it('should handle task operations', async () => { - // Create a project first - const createResult = await taskManager.createProject( - 'Test project', - [ - { - title: 'Test task', - description: 'Test description' - } - ], - 'Test plan' - ); - - if (createResult.status === 'success') { - const projectId = createResult.data.projectId; - const taskId = createResult.data.tasks[0].id; - - // Test task reading - const readResult = await taskManager.openTaskDetails(taskId); - expect(readResult.status).toBe('success'); - if (readResult.status === 'success') { - // Ensure task exists before checking id - expect(readResult.data.task).toBeDefined(); - if (readResult.data.task) { - expect(readResult.data.task.id).toBe(taskId); - } - } - - // Test task updating - const updatedTask = await taskManager.updateTask(projectId, taskId, { - title: "Updated task", - description: "Updated description" - }); - expect(updatedTask.status).toBe('success'); - if (updatedTask.status === 'success') { - expect(updatedTask.data.title).toBe("Updated task"); - expect(updatedTask.data.description).toBe("Updated description"); - expect(updatedTask.data.status).toBe("not started"); - } - - // Test status update - const updatedStatusTask = await taskManager.updateTask(projectId, taskId, { - status: 'in progress' - }); - expect(updatedStatusTask.status).toBe('success'); - if (updatedStatusTask.status === 'success') { - expect(updatedStatusTask.data.status).toBe('in progress'); - } - - // Test task deletion - const deleteResult = await taskManager.deleteTask( - projectId, - taskId - ); - expect(deleteResult.status).toBe('success'); - } - }); - - it('should get the next task', async () => { - // Create a project with multiple tasks - const createResult = await taskManager.createProject( - 'Test project with multiple tasks', - [ - { - title: 'Task 1', - description: 'Description 1' - }, - { - title: 'Task 2', - description: 'Description 2' - } - ] - ); - - if (createResult.status === 'success') { - const projectId = createResult.data.projectId; - - // Get the next task - const nextTaskResult = await taskManager.getNextTask(projectId); - - expect(nextTaskResult.status).toBe('success'); - if (nextTaskResult.status === 'success' && 'task' in nextTaskResult.data) { - expect(nextTaskResult.data.task.id).toBe(createResult.data.tasks[0].id); - } - } - }); - }); - - describe('Project Approval', () => { - let projectId: string; - let taskId1: string; - let taskId2: string; - - beforeEach(async () => { - // Create a project with two tasks for each test in this group - const createResult = await taskManager.createProject( - 'Test project for approval', - [ - { - title: 'Task 1', - description: 'Description for task 1' - }, - { - title: 'Task 2', - description: 'Description for task 2' - } - ] - ); - - if (createResult.status === 'success') { - projectId = createResult.data.projectId; - taskId1 = createResult.data.tasks[0].id; - taskId2 = createResult.data.tasks[1].id; - } - }); - - it('should not approve project if tasks are not done', async () => { - await expect(taskManager.approveProjectCompletion(projectId)).rejects.toMatchObject({ - code: 'ERR_3003', - message: 'Not all tasks are done' - }); - }); - - it('should not approve project if tasks are done but not approved', async () => { - // Mark both tasks as done - await taskManager.updateTask(projectId, taskId1, { - status: 'done', - completedDetails: 'Task 1 completed details' - }); - await taskManager.updateTask(projectId, taskId2, { - status: 'done', - completedDetails: 'Task 2 completed details' - }); - - await expect(taskManager.approveProjectCompletion(projectId)).rejects.toMatchObject({ - code: 'ERR_3004', - message: 'Not all done tasks are approved' - }); - }); - - it('should approve project when all tasks are done and approved', async () => { - // Mark both tasks as done and approved - await taskManager.updateTask(projectId, taskId1, { - status: 'done', - completedDetails: 'Task 1 completed details' - }); - await taskManager.updateTask(projectId, taskId2, { - status: 'done', - completedDetails: 'Task 2 completed details' - }); - - // Approve tasks - await taskManager.approveTaskCompletion(projectId, taskId1); - await taskManager.approveTaskCompletion(projectId, taskId2); - - const result = await taskManager.approveProjectCompletion(projectId); - expect(result.status).toBe('success'); - - // Verify project is marked as completed - const project = taskManager["data"].projects.find((p: { projectId: string }) => p.projectId === projectId); - expect(project?.completed).toBe(true); - }); - - it('should not allow approving an already completed project', async () => { - // First approve the project - await taskManager.updateTask(projectId, taskId1, { - status: 'done', - completedDetails: 'Task 1 completed details' - }); - await taskManager.updateTask(projectId, taskId2, { - status: 'done', - completedDetails: 'Task 2 completed details' - }); - await taskManager.approveTaskCompletion(projectId, taskId1); - await taskManager.approveTaskCompletion(projectId, taskId2); - - await taskManager.approveProjectCompletion(projectId); - - // Try to approve again - await expect(taskManager.approveProjectCompletion(projectId)).rejects.toMatchObject({ - code: 'ERR_3001', - message: 'Project is already completed' - }); - }); - }); - - describe('Task and Project Filtering', () => { - describe('listProjects', () => { - it('should list only open projects', async () => { - // Create some projects. One open and one complete - const project1 = await taskManager.createProject("Open Project", [{ title: "Task 1", description: "Desc" }]); - const project2 = await taskManager.createProject("Completed project", [{ title: "Task 2", description: "Desc" }]); - - // Ensure both projects were created successfully before proceeding - if (project1.status === 'success' && project2.status === 'success') { - const project1Data = project1.data; // Assign data - const project2Data = project2.data; // Assign data - - const proj1Id = project1Data.projectId; - const proj2Id = project2Data.projectId; - - // Mark task and project as done and approved - await taskManager.updateTask(proj2Id, project2Data.tasks[0].id, { status: 'done' }); - await taskManager.approveTaskCompletion(proj2Id, project2Data.tasks[0].id); - await taskManager.approveProjectCompletion(proj2Id); - // Project 2 is now completed - - const result = await taskManager.listProjects("open"); - expect(result.status).toBe('success'); - // Add type guard for result - if (result.status === 'success') { - expect(result.data.projects.length).toBe(1); - expect(result.data.projects[0].projectId).toBe(proj1Id); - } - } - }); - - it('should list only pending approval projects', async () => { - // Create some projects with different states - const project1 = await taskManager.createProject("Pending Project", [{ title: "Task 1", description: "Desc" }]); - const project2 = await taskManager.createProject("Open Project", [{ title: "Task 2", description: "Desc" }]); - const project3 = await taskManager.createProject("In Progress Project", [{ title: "Task 3", description: "Desc" }]); - - // Ensure projects were created successfully - if (project1.status === 'success' && project2.status === 'success') { - const project1Data = project1.data; // Assign data - const project2Data = project2.data; // Assign data - - // Mark task1 as done but not approved - await taskManager.updateTask(project1Data.projectId, project1Data.tasks[0].id, { - status: 'done' - }); - // Don't approve it, project1 should be pending_approval - - // Mark task2 as in progress - await taskManager.updateTask(project2Data.projectId, project2Data.tasks[0].id, { - status: 'in progress' - }); - // project2 should remain open - - const result = await taskManager.listProjects("pending_approval"); - expect(result.status).toBe('success'); - // Add type guard for result - if (result.status === 'success') { - expect(result.data.projects.length).toBe(1); - expect(result.data.projects[0].projectId).toBe(project1Data.projectId); - } - } - }); - - it('should list only completed projects', async () => { - // Create projects - const project1 = await taskManager.createProject("Open Project", [{ title: "Task 1", description: "Desc" }]); - const project2 = await taskManager.createProject("Completed Project", [{ title: "Task 2", description: "Desc" }]); - - // Ensure projects were created successfully - if (project1.status === 'success' && project2.status === 'success') { - const project1Data = project1.data; // Assign data - const project2Data = project2.data; // Assign data - - // Complete project 1 fully - await taskManager.updateTask(project1Data.projectId, project1Data.tasks[0].id, { - status: 'done' - }); - await taskManager.approveTaskCompletion(project1Data.projectId, project1Data.tasks[0].id); - await taskManager.approveProjectCompletion(project1Data.projectId); - - // Mark project 2 task as done but don't approve - await taskManager.updateTask(project2Data.projectId, project2Data.tasks[0].id, { - status: 'done' - }); - - const result = await taskManager.listProjects("completed"); - expect(result.status).toBe('success'); - // Add type guard for result - if (result.status === 'success') { - expect(result.data.projects.length).toBe(1); - expect(result.data.projects[0].projectId).toBe(project1Data.projectId); - } - } - }); - - it('should list all projects when state is \'all\'', async () => { - // Create projects with different states - const project1 = await taskManager.createProject("Open Project", [{ title: "Task 1", description: "Desc" }]); - const project2 = await taskManager.createProject("Completed project", [{ title: "Task 2", description: "Desc" }]); - const project3 = await taskManager.createProject("Pending Project", [{ title: "Task 3", description: "Desc" }]); - - const result = await taskManager.listProjects("all"); - expect(result.status).toBe('success'); - if (result.status === 'success') { - expect(result.data.projects.length).toBe(3); - } - }); - - it('should handle empty project list', async () => { - const result = await taskManager.listProjects("open"); - expect(result.status).toBe('success'); - if (result.status === 'success') { - expect(result.data.projects.length).toBe(0); - } - }); - }); - - describe('listTasks', () => { - it('should list tasks across all projects filtered by state', async () => { - // Create two projects with tasks in different states - const project1 = await taskManager.createProject("Project 1", [ - { title: "Task 1", description: "Open task" }, - { title: "Task 2", description: "Done task" } - ]); - const project2 = await taskManager.createProject("Project 2", [ - { title: "Task 3", description: "Pending approval task" } - ]); - - // Add type guard for project creation results - if (project1.status === 'success' && project2.status === 'success') { - // Set task states - await taskManager.updateTask(project1.data.projectId, project1.data.tasks[1].id, { - status: 'done', - completedDetails: 'Task 2 completed details' - }); - await taskManager.approveTaskCompletion(project1.data.projectId, project1.data.tasks[1].id); - - await taskManager.updateTask(project2.data.projectId, project2.data.tasks[0].id, { - status: 'done', - completedDetails: 'Task 3 completed details' - }); - - // Test open tasks - const openResult = await taskManager.listTasks(undefined, "open"); - expect(openResult.status).toBe('success'); - if (openResult.status === 'success') { - expect(openResult.data.tasks).toBeDefined(); - expect(openResult.data.tasks!.length).toBe(1); - expect(openResult.data.tasks![0].title).toBe("Task 1"); - } - - // Test pending approval tasks - const pendingResult = await taskManager.listTasks(undefined, "pending_approval"); - expect(pendingResult.status).toBe('success'); - if (pendingResult.status === 'success') { - expect(pendingResult.data.tasks).toBeDefined(); - expect(pendingResult.data.tasks!.length).toBe(1); - expect(pendingResult.data.tasks![0].title).toBe("Task 3"); - } - - // Test completed tasks - const completedResult = await taskManager.listTasks(undefined, "completed"); - expect(completedResult.status).toBe('success'); - if (completedResult.status === 'success') { - expect(completedResult.data.tasks).toBeDefined(); - expect(completedResult.data.tasks!.length).toBe(1); - expect(completedResult.data.tasks![0].title).toBe("Task 2"); - } - } - }); - - it('should list tasks for specific project filtered by state', async () => { - // Create a project with multiple tasks - const project = await taskManager.createProject("Specific Project Tasks", [ - { title: "Task 1", description: "Desc 1" }, // open - { title: "Task 2", description: "Desc 2" }, // completed - { title: "Task 3", description: "Desc 3" } // pending approval - ]); - - // Ensure project was created successfully - if (project.status === 'success') { - const projectData = project.data; // Assign data - // Set task states - await taskManager.updateTask(projectData.projectId, projectData.tasks[1].id, { // Use projectData - status: 'done' - }); // Task 2 done - await taskManager.approveTaskCompletion(projectData.projectId, projectData.tasks[1].id); // Task 2 approved (completed) - - await taskManager.updateTask(projectData.projectId, projectData.tasks[2].id, { // Use projectData - status: 'done' - }); // Task 3 done (pending approval) - - // Test open tasks - const openResult = await taskManager.listTasks(projectData.projectId, "open"); // Use projectData - expect(openResult.status).toBe('success'); - // Add type guard for openResult - if (openResult.status === 'success') { - expect(openResult.data.tasks).toBeDefined(); - expect(openResult.data.tasks!.length).toBe(1); - expect(openResult.data.tasks![0].title).toBe("Task 1"); - } - - // Test pending approval tasks - const pendingResult = await taskManager.listTasks(projectData.projectId, "pending_approval"); // Use projectData - expect(pendingResult.status).toBe('success'); - // Add type guard for pendingResult - if (pendingResult.status === 'success') { - expect(pendingResult.data.tasks).toBeDefined(); - expect(pendingResult.data.tasks!.length).toBe(1); - expect(pendingResult.data.tasks![0].title).toBe("Task 3"); - } - - // Test completed tasks - const completedResult = await taskManager.listTasks(projectData.projectId, "completed"); // Use projectData - expect(completedResult.status).toBe('success'); - // Add type guard for completedResult - if (completedResult.status === 'success') { - expect(completedResult.data.tasks).toBeDefined(); - expect(completedResult.data.tasks!.length).toBe(1); - expect(completedResult.data.tasks![0].title).toBe("Task 2"); - } - } - }); - - it('should handle non-existent project ID', async () => { - await expect(taskManager.listTasks("non-existent-project", "open")).rejects.toMatchObject({ - code: 'ERR_2000', - message: 'Project non-existent-project not found' - }); - }); - - it('should handle empty task list', async () => { - const project = await taskManager.createProject("Empty Project", []); - // Add type guard for project creation - if (project.status === 'success') { - const projectData = project.data; // Assign data - const result = await taskManager.listTasks(projectData.projectId, "open"); // Use projectData - expect(result.status).toBe('success'); - // Add type guard for listTasks result - if (result.status === 'success') { - expect(result.data.tasks).toBeDefined(); - expect(result.data.tasks!.length).toBe(0); - } - } - }); - }); - }); - - describe('Task Recommendations', () => { - it("should handle tasks with tool and rule recommendations", async () => { - const createResult = await taskManager.createProject("Test Project", [ - { - title: "Test Task", - description: "Test Description", - toolRecommendations: "Use tool X", - ruleRecommendations: "Review rule Y" - }, - ]); - if (createResult.status === 'success') { - const projectId = createResult.data.projectId; - const tasksResponse = await taskManager.listTasks(projectId); - if (tasksResponse.status !== 'success' || !tasksResponse.data.tasks?.length) { - throw new Error('Expected tasks in response'); - } - const tasks = tasksResponse.data.tasks as Task[]; - const taskId = tasks[0].id; - - // Verify initial recommendations - expect(tasks[0].toolRecommendations).toBe("Use tool X"); - expect(tasks[0].ruleRecommendations).toBe("Review rule Y"); - - // Update recommendations - const updatedTask = await taskManager.updateTask(projectId, taskId, { - toolRecommendations: "Use tool Z", - ruleRecommendations: "Review rule W", - }); - - expect(updatedTask.status).toBe('success'); - if (updatedTask.status === 'success') { - expect(updatedTask.data.toolRecommendations).toBe("Use tool Z"); - expect(updatedTask.data.ruleRecommendations).toBe("Review rule W"); - } - - // Add new task with recommendations - await taskManager.addTasksToProject(projectId, [ - { - title: "Added Task", - description: "With recommendations", - toolRecommendations: "Tool A", - ruleRecommendations: "Rule B" - } - ]); - - const allTasksResponse = await taskManager.listTasks(projectId); - if (allTasksResponse.status !== 'success' || !allTasksResponse.data.tasks?.length) { - throw new Error('Expected tasks in response'); - } - const allTasks = allTasksResponse.data.tasks as Task[]; - const newTask = allTasks.find(t => t.title === "Added Task"); - expect(newTask).toBeDefined(); - if (newTask) { - expect(newTask.toolRecommendations).toBe("Tool A"); - expect(newTask.ruleRecommendations).toBe("Rule B"); - } - } - }); - - it("should handle tasks with no recommendations", async () => { - const createResult = await taskManager.createProject("Test Project", [ - { title: "Test Task", description: "Test Description" }, - ]); - if (createResult.status === 'success') { - const projectId = createResult.data.projectId; - const tasksResponse = await taskManager.listTasks(projectId); - if (tasksResponse.status !== 'success' || !tasksResponse.data.tasks?.length) { - throw new Error('Expected tasks in response'); - } - const tasks = tasksResponse.data.tasks as Task[]; - const taskId = tasks[0].id; - - // Verify no recommendations - expect(tasks[0].toolRecommendations).toBeUndefined(); - expect(tasks[0].ruleRecommendations).toBeUndefined(); - - // Add task without recommendations - await taskManager.addTasksToProject(projectId, [ - { title: "Added Task", description: "No recommendations" } - ]); - - const allTasksResponse = await taskManager.listTasks(projectId); - if (allTasksResponse.status !== 'success' || !allTasksResponse.data.tasks?.length) { - throw new Error('Expected tasks in response'); - } - const allTasks = allTasksResponse.data.tasks as Task[]; - const newTask = allTasks.find(t => t.title === "Added Task"); - expect(newTask).toBeDefined(); - if (newTask) { - expect(newTask.toolRecommendations).toBeUndefined(); - expect(newTask.ruleRecommendations).toBeUndefined(); - } - } - }); - }); - - describe('Auto-approval of tasks', () => { - it('should auto-approve tasks when updating status to done and autoApprove is enabled', async () => { - // Create a project with autoApprove enabled - const createResult = await taskManager.createProject( - 'Auto-approval for updateTask', - [ - { - title: 'Task to update', - description: 'This task should be auto-approved when status is updated to done' - } - ], - 'Test plan', - true // autoApprove parameter - ); - - if (createResult.status === 'success') { - const projectId = createResult.data.projectId; - const taskId = createResult.data.tasks[0].id; - - // Update the task status to done - const updatedTask = await taskManager.updateTask(projectId, taskId, { - status: 'done', - completedDetails: 'Task completed via updateTask' - }); - - // The task should be automatically approved - expect(updatedTask.status).toBe('success'); - if (updatedTask.status === 'success') { - expect(updatedTask.data.status).toBe('done'); - expect(updatedTask.data.approved).toBe(true); - } - } - }); - - it('should not auto-approve tasks when updating status to done and autoApprove is disabled', async () => { - // Create a project with autoApprove disabled - const createResult = await taskManager.createProject( - 'Manual-approval for updateTask', - [ - { - title: 'Task to update manually', - description: 'This task should not be auto-approved when status is updated to done' - } - ], - 'Test plan', - false // autoApprove parameter - ); - - if (createResult.status === 'success') { - const projectId = createResult.data.projectId; - const taskId = createResult.data.tasks[0].id; - - // Update the task status to done - const updatedTask = await taskManager.updateTask(projectId, taskId, { - status: 'done', - completedDetails: 'Task completed via updateTask' - }); - - // The task should not be automatically approved - expect(updatedTask.status).toBe('success'); - if (updatedTask.status === 'success') { - expect(updatedTask.data.status).toBe('done'); - expect(updatedTask.data.approved).toBe(false); - } - } - }); - - it('should make autoApprove false by default if not specified', async () => { - // Create a project without specifying autoApprove - const createResult = await taskManager.createProject( - 'Default-approval Project', - [ - { - title: 'Default-approved task', - description: 'This task should follow the default approval behavior' - } - ] - ); - - if (createResult.status === 'success') { - const projectId = createResult.data.projectId; - const taskId = createResult.data.tasks[0].id; - - // Update the task status to done - const updatedTask = await taskManager.updateTask(projectId, taskId, { - status: 'done', - completedDetails: 'Task completed via updateTask' - }); - - // The task should not be automatically approved by default - expect(updatedTask.status).toBe('success'); - if (updatedTask.status === 'success') { - expect(updatedTask.data.status).toBe('done'); - expect(updatedTask.data.approved).toBe(false); - } - } - }); - }); - - describe('Project Plan Generation', () => { - const mockLLMResponse = { - projectPlan: "Test project plan", - tasks: [ - { - title: "Task 1", - description: "Description 1", - toolRecommendations: "Use tool X", - ruleRecommendations: "Follow rule Y" - }, - { - title: "Task 2", - description: "Description 2" - } - ] - }; - - beforeEach(() => { - // Reset mock implementations using the directly imported name - (generateObject as jest.Mock).mockClear(); - (generateObject as jest.Mock).mockImplementation(() => Promise.resolve({ object: mockLLMResponse })); - // If jsonSchema is used in these tests, reset it too - (jsonSchema as jest.Mock).mockClear(); - }); - - it('should generate a project plan with OpenAI provider', async () => { - const result = await taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "openai", - model: "gpt-4-turbo", - attachments: [] - }) as StandardResponse<{ - projectId: string; - totalTasks: number; - tasks: Array<{ id: string; title: string; description: string }>; - }>; - - const { openai } = await import('@ai-sdk/openai'); - expect(openai).toHaveBeenCalledWith("gpt-4-turbo"); - expect(result.status).toBe('success'); - if (result.status === 'success') { - expect(result.data.projectId).toBeDefined(); - expect(result.data.totalTasks).toBe(2); - expect(result.data.tasks[0].title).toBe("Task 1"); - expect(result.data.tasks[1].title).toBe("Task 2"); - } - }); - - it('should generate a project plan with Google provider', async () => { - const result = await taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "google", - model: "gemini-1.5-pro", - attachments: [] - }); - - const { google } = await import('@ai-sdk/google'); - expect(google).toHaveBeenCalledWith("gemini-1.5-pro"); - expect(result.status).toBe('success'); - }); - - it('should generate a project plan with Deepseek provider', async () => { - const result = await taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "deepseek", - model: "deepseek-coder", - attachments: [] - }); - - const { deepseek } = await import('@ai-sdk/deepseek'); - expect(deepseek).toHaveBeenCalledWith("deepseek-coder"); - expect(result.status).toBe('success'); - }); - - it('should handle attachments correctly', async () => { - const result = await taskManager.generateProjectPlan({ - prompt: "Create based on spec", - provider: "openai", - model: "gpt-4-turbo", - attachments: ["Spec content 1", "Spec content 2"] - }); - - const { prompt } = generateObject.mock.calls[0][0] as { prompt: string }; - expect(prompt).toContain("Create based on spec"); - expect(prompt).toContain("Spec content 1"); - expect(prompt).toContain("Spec content 2"); - expect(result.status).toBe('success'); - }); - - it('should handle NoObjectGeneratedError', async () => { - const error = new Error(); - error.name = 'NoObjectGeneratedError'; - // Set mock implementation via the imported name - (generateObject as jest.Mock).mockImplementation(() => Promise.reject(error)); - - await expect(taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "openai", - model: "gpt-4-turbo", - attachments: [] - })).rejects.toMatchObject({ - code: 'ERR_5001', - message: "The LLM failed to generate a valid project plan. Please try again with a clearer prompt." - }); - }); - - it('should handle InvalidJSONError', async () => { - const error = new Error(); - error.name = 'InvalidJSONError'; - // Set mock implementation via the imported name - (generateObject as jest.Mock).mockImplementation(() => Promise.reject(error)); - - await expect(taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "openai", - model: "gpt-4-turbo", - attachments: [] - })).rejects.toMatchObject({ - code: 'ERR_5001', - message: "The LLM generated invalid JSON. Please try again." - }); - }); - - it('should handle rate limit errors', async () => { - // Set mock implementation via the imported name - (generateObject as jest.Mock).mockImplementation(() => Promise.reject(new Error('rate limit exceeded'))); - - await expect(taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "openai", - model: "gpt-4-turbo", - attachments: [] - })).rejects.toMatchObject({ - code: 'ERR_1003', - message: "Rate limit or quota exceeded for the LLM provider. Please try again later." - }); - }); - - it('should handle authentication errors', async () => { - // Set mock implementation via the imported name - (generateObject as jest.Mock).mockImplementation(() => Promise.reject(new Error('authentication failed'))); - - await expect(taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "openai", - model: "gpt-4-turbo", - attachments: [] - })).rejects.toMatchObject({ - code: 'ERR_1003', - message: "Authentication failed with the LLM provider. Please check your credentials." - }); - }); - - it('should handle invalid provider', async () => { - await expect(taskManager.generateProjectPlan({ - prompt: "Create a test project", - provider: "invalid", - model: "gpt-4-turbo", - attachments: [] - })).rejects.toMatchObject({ - code: 'ERR_1002', - message: "Invalid provider: invalid" - }); - // Ensure generateObject wasn't called for invalid provider - expect(generateObject).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts deleted file mode 100644 index cb3dc2c..0000000 --- a/tests/unit/cli.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it, expect, jest, beforeEach, beforeAll } from '@jest/globals'; -import type { TaskManager as TaskManagerType } from '../../src/server/TaskManager.js'; -import type { StandardResponse, ProjectCreationSuccessData } from '../../src/types/index.js'; -import type { readFile as ReadFileType } from 'node:fs/promises'; - -// --- Mock Dependencies --- - -// Mock TaskManager -const mockGenerateProjectPlan = jest.fn() as jest.MockedFunction; -const mockReadProject = jest.fn() as jest.MockedFunction; -const mockListProjects = jest.fn() as jest.MockedFunction; - -jest.unstable_mockModule('../../src/server/TaskManager.js', () => ({ - TaskManager: jest.fn().mockImplementation(() => ({ - generateProjectPlan: mockGenerateProjectPlan, - readProject: mockReadProject, // Include in mock - listProjects: mockListProjects, // Include in mock - // Add mocks for other methods used by other commands if testing them later - approveTaskCompletion: jest.fn(), - approveProjectCompletion: jest.fn(), - listTasks: jest.fn(), - // ... other methods - })), -})); - -// Mock fs/promises -const mockReadFile = jest.fn(); -jest.unstable_mockModule('node:fs/promises', () => ({ - readFile: mockReadFile, - default: { readFile: mockReadFile } // Handle default export if needed -})); - -// Mock chalk - disable color codes -jest.unstable_mockModule('chalk', () => ({ - default: { - blue: (str: string) => str, - red: (str: string) => str, - green: (str: string) => str, - yellow: (str: string) => str, - cyan: (str: string) => str, - bold: (str: string) => str, - gray: (str: string) => str, - }, -})); - -// --- Setup & Teardown --- - -let program: any; // To hold the imported commander program -let consoleLogSpy: ReturnType; // Use inferred type -let consoleErrorSpy: ReturnType; // Use inferred type -let processExitSpy: ReturnType; // Use inferred type -let TaskManager: typeof TaskManagerType; -let readFile: jest.MockedFunction; - -beforeAll(async () => { - // Dynamically import the CLI module *after* mocks are set up - const cliModule = await import('../../src/client/cli.js'); - program = cliModule.program; // Assuming program is exported - - // Import mocked types/modules - const TmModule = await import('../../src/server/TaskManager.js'); - TaskManager = TmModule.TaskManager; - const fsPromisesMock = await import('node:fs/promises'); - readFile = fsPromisesMock.readFile as jest.MockedFunction; -}); - -beforeEach(() => { - // Reset mocks and spies before each test - jest.clearAllMocks(); - mockGenerateProjectPlan.mockReset(); - mockReadFile.mockReset(); - mockReadProject.mockReset(); // Reset new mock - mockListProjects.mockReset(); // Reset new mock - - // Spy on console and process.exit - consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - // Prevent tests from exiting and throw instead - processExitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined): never => { // Correct signature - throw new Error(`process.exit called with code ${code ?? 'undefined'}`); - }); -}); - -afterEach(() => { - // Restore spies - consoleLogSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - processExitSpy.mockRestore(); -}); - -// --- Test Suites --- - -describe('CLI Commands', () => { - describe('generate-plan', () => { - it('should call TaskManager.generateProjectPlan with correct arguments and log success', async () => { - // Arrange: Mock TaskManager response - const mockSuccessResponse: StandardResponse = { - status: 'success', - data: { - projectId: 'proj-123', - totalTasks: 2, - tasks: [ - { id: 'task-1', title: 'Task 1', description: 'Desc 1' }, - { id: 'task-2', title: 'Task 2', description: 'Desc 2' }, - ], - message: 'Project proj-123 created.', - }, - }; - mockGenerateProjectPlan.mockResolvedValue(mockSuccessResponse); - - const testPrompt = 'Create a test plan'; - const testProvider = 'openai'; - const testModel = 'gpt-4o-mini'; - - // Act: Simulate running the CLI command - // Arguments: command, options... - await program.parseAsync( - [ - 'generate-plan', - '--prompt', - testPrompt, - '--provider', - testProvider, - '--model', - testModel, - ], - { from: 'user' } // Important: indicates these are user-provided args - ); - - // Assert - // 1. TaskManager initialization (implicitly tested by mock setup) - // Ensure TaskManager constructor was called (likely once due to preAction hook) - expect(TaskManager).toHaveBeenCalledTimes(1); - - // 2. generateProjectPlan call - expect(mockGenerateProjectPlan).toHaveBeenCalledTimes(1); - expect(mockGenerateProjectPlan).toHaveBeenCalledWith({ - prompt: testPrompt, - provider: testProvider, - model: testModel, - attachments: [], // No attachments in this test - }); - - // 3. Console output - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Generating project plan from prompt...') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('✅ Project plan generated successfully!') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Project ID: proj-123') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Total Tasks: 2') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('task-1:') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Title: Task 1') - ); - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Description: Desc 1') - ); - // Check for the TaskManager message as well - expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Project proj-123 created.') - ); - - - // 4. No errors or exits - expect(consoleErrorSpy).not.toHaveBeenCalled(); - expect(processExitSpy).not.toHaveBeenCalled(); - }); - }); - - // Add describe blocks for other commands (approve, finalize, list) later -}); diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts deleted file mode 100644 index 2f54142..0000000 --- a/tests/unit/errors.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { normalizeError, createError } from '../../src/utils/errors.js'; -import { StandardError, ErrorCode, ErrorCategory } from '../../src/types/index.js'; - -describe('normalizeError', () => { - it('should return the same StandardError object if passed a StandardError', () => { - const standardError: StandardError = { - status: 'error', - code: ErrorCode.ProjectNotFound, - category: ErrorCategory.ResourceNotFound, - message: 'Project not found', - }; - - const result = normalizeError(standardError); - // Use 'toBe' to check for referential equality (same object) - expect(result).toBe(standardError); - // Also check deep equality just in case - expect(result).toEqual(standardError); - }); - - it('should correctly parse a StandardError from an Error with a valid code in the message', () => { - const originalError = new Error('[ERR_1000] Missing required parameter: userId'); - const expectedError: StandardError = { - status: 'error', - code: ErrorCode.MissingParameter, - category: ErrorCategory.Validation, - message: 'Missing required parameter: userId', - details: { stack: originalError.stack }, - }; - - const result = normalizeError(originalError); - expect(result).toEqual(expectedError); - }); - - it('should create a StandardError with InvalidArgument code for a generic Error without a code', () => { - const originalError = new Error('Something went wrong'); - const expectedError: StandardError = { - status: 'error', - code: ErrorCode.InvalidArgument, // Current fallback behavior - category: ErrorCategory.Validation, // Derived from InvalidArgument - message: 'Something went wrong', - details: { stack: originalError.stack }, - }; - - const result = normalizeError(originalError); - expect(result).toEqual(expectedError); - }); - - it('should create a StandardError with Unknown code for a string input', () => { - const errorString = 'A string error message'; - const expectedError: StandardError = { - status: 'error', - code: ErrorCode.Unknown, - category: ErrorCategory.Unknown, - message: errorString, - details: { originalError: errorString }, - }; - - const result = normalizeError(errorString); - expect(result).toEqual(expectedError); - }); - - it('should create a StandardError with Unknown code for an object input', () => { - const errorObject = { detail: 'Some custom error object' }; - const expectedError: StandardError = { - status: 'error', - code: ErrorCode.Unknown, - category: ErrorCategory.Unknown, - message: 'An unknown error occurred', - details: { originalError: errorObject }, - }; - - const result = normalizeError(errorObject); - expect(result).toEqual(expectedError); - }); - - it('should handle errors created with createError correctly', () => { - const createdError = createError(ErrorCode.FileReadError, "Could not read file", { path: "/tmp/file" }); - // When createError is used, it doesn't embed the code in the message. - // normalizeError currently relies on finding the code *in the message* for standard Errors. - // Let's test how normalizeError handles an error *object* that looks like a StandardError but isn't one instanceof Error. - - // If we pass the *object* created by createError: - const resultFromObject = normalizeError(createdError); - expect(resultFromObject).toBe(createdError); // Should pass through if it's already the right shape. - - // If we simulate throwing it and catching it (which might wrap it): - // This is more complex to simulate accurately without more context on *how* it might be thrown/caught. - // The main point is covered by the first test: if the caught object *is* a StandardError, it's passed through. - }); - -}); diff --git a/tests/unit/taskFormattingUtils.test.ts b/tests/unit/taskFormattingUtils.test.ts deleted file mode 100644 index 3b97385..0000000 --- a/tests/unit/taskFormattingUtils.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, it, expect } from '@jest/globals'; -// Note: We might need strip-ansi if chalk colors interfere with snapshot testing, but basic string checks should be okay. -import { formatTaskProgressTable, formatProjectsList } from '../../src/client/taskFormattingUtils.js'; -import { Project, Task, ListProjectsSuccessData } from '../../src/types/index.js'; - -describe('taskFormattingUtils', () => { - - describe('formatTaskProgressTable', () => { - const baseProject: Project = { - projectId: 'proj-1', - initialPrompt: 'Test prompt', - projectPlan: 'Test plan', - completed: false, - autoApprove: false, - tasks: [], - }; - - it('should return "Project not found" if project is undefined', () => { - expect(formatTaskProgressTable(undefined)).toBe('Project not found'); - }); - - it('should format an empty task list correctly', () => { - const project: Project = { ...baseProject, tasks: [] }; - const result = formatTaskProgressTable(project); - // Use toMatch with .* to handle potential ANSI codes from chalk.bold() - expect(result).toMatch(/📋 Project .*proj-1.* details:/); - expect(result).toContain('No tasks in this project.'); - expect(result).toContain('ID'); - }); - - it('should format a single task correctly (not started)', () => { - const task: Task = { id: 'task-1', title: 'Task One', description: 'Desc One', status: 'not started', approved: false, completedDetails: '' }; - const project: Project = { ...baseProject, tasks: [task] }; - const result = formatTaskProgressTable(project); - // Use toMatch with .* to handle potential ANSI codes from chalk.bold() - expect(result).toMatch(/📋 Project .*proj-1.* details:/); - expect(result).toContain('task-1'); - expect(result).toContain('Task One'); - expect(result).toContain('Desc One'); - expect(result).toContain('Pending'); // Status text - expect(result).toContain('No'); // Approved text - expect(result).toContain('[-]'); // Tools/Rules text - }); - - it('should format a task in progress with recommendations', () => { - const task: Task = { - id: 'task-2', - title: 'Task Two', - description: 'Desc Two', - status: 'in progress', - approved: false, - completedDetails: '', - toolRecommendations: 'Tool A', - ruleRecommendations: 'Rule B' - }; - const project: Project = { ...baseProject, tasks: [task] }; - const result = formatTaskProgressTable(project); - expect(result).toContain('task-2'); - expect(result).toContain('In Prog'); // Status text - expect(result).toContain('No'); // Approved text - expect(result).toContain('[+]'); // Tools/Rules text - }); - - it('should format a completed and approved task', () => { - const task: Task = { id: 'task-3', title: 'Task Three', description: 'Desc Three', status: 'done', approved: true, completedDetails: 'Done details' }; - const project: Project = { ...baseProject, tasks: [task] }; - const result = formatTaskProgressTable(project); - expect(result).toContain('task-3'); - expect(result).toContain('Done'); // Status text - expect(result).toContain('Yes'); // Approved text - expect(result).toContain('[-]'); // Tools/Rules text - }); - - it('should format a completed but not approved task', () => { - const task: Task = { id: 'task-4', title: 'Task Four', description: 'Desc Four', status: 'done', approved: false, completedDetails: 'Done details' }; - const project: Project = { ...baseProject, tasks: [task] }; - const result = formatTaskProgressTable(project); - expect(result).toContain('task-4'); - expect(result).toContain('Done'); // Status text - expect(result).toContain('No'); // Approved text - expect(result).toContain('[-]'); // Tools/Rules text - }); - - it('should handle long descriptions with word wrap', () => { - // No longer testing manual truncation, just presence of the text - const longDescription = 'This is a very long description that definitely exceeds the forty character width set for the description column and should wrap.'; - const task: Task = { id: 'task-5', title: 'Long Desc Task', description: longDescription, status: 'not started', approved: false, completedDetails: '' }; - const project: Project = { ...baseProject, tasks: [task] }; - const result = formatTaskProgressTable(project); - expect(result).toContain('task-5'); - expect(result).toContain('Long Desc Task'); - // Check for the start of the long description, acknowledging it will be wrapped by the library - expect(result).toContain('This is a very long description that'); - // Removed the check for 'column and should wrap.' as wrapping can make specific substring checks fragile. - expect(result).toContain('Pending'); - }); - - it('should format multiple tasks', () => { - const task1: Task = { id: 'task-1', title: 'Task One', description: 'Desc One', status: 'not started', approved: false, completedDetails: '' }; - const task2: Task = { id: 'task-2', title: 'Task Two', description: 'Desc Two', status: 'done', approved: true, completedDetails: '' }; - const project: Project = { ...baseProject, tasks: [task1, task2] }; - const result = formatTaskProgressTable(project); - // Check for elements of both tasks - expect(result).toContain('task-1'); - expect(result).toContain('Task One'); - expect(result).toContain('Pending'); - expect(result).toContain('No'); - expect(result).toContain('[-]'); - - expect(result).toContain('task-2'); - expect(result).toContain('Task Two'); - expect(result).toContain('Done'); - expect(result).toContain('Yes'); - expect(result).toContain('[-]'); - }); - }); - - describe('formatProjectsList', () => { - type ProjectSummary = ListProjectsSuccessData["projects"][0]; - - it('should format an empty project list correctly', () => { - const projects: ProjectSummary[] = []; - const result = formatProjectsList(projects); - // Check for the main header and the empty message within the table structure - expect(result).toContain('Projects List:'); - expect(result).toContain('No projects found.'); // Use simpler text check - expect(result).toContain('Project ID'); // Check if header is present - }); - - it('should format a single project correctly', () => { - const projectSummary: ProjectSummary = { - projectId: 'proj-1', initialPrompt: 'Short prompt', totalTasks: 2, completedTasks: 1, approvedTasks: 1 - }; - const result = formatProjectsList([projectSummary]); - // Check for key data points within the formatted row - expect(result).toContain('proj-1'); - expect(result).toContain('Short prompt'); - expect(result).toContain(' 2 '); // Check for counts with padding - expect(result).toContain(' 1 '); - expect(result).toContain(' 1 '); // Need trailing space if aligned right/center - }); - - it('should format multiple projects', () => { - const project1: ProjectSummary = { - projectId: 'proj-1', initialPrompt: 'Prompt 1', totalTasks: 1, completedTasks: 0, approvedTasks: 0 - }; - const project2: ProjectSummary = { - projectId: 'proj-2', initialPrompt: 'Prompt 2', totalTasks: 3, completedTasks: 2, approvedTasks: 1 - }; - const result = formatProjectsList([project1, project2]); - // Check for elements of both projects - expect(result).toContain('proj-1'); - expect(result).toContain('Prompt 1'); - expect(result).toContain(' 1 '); - expect(result).toContain(' 0 '); - - expect(result).toContain('proj-2'); - expect(result).toContain('Prompt 2'); - expect(result).toContain(' 3 '); - expect(result).toContain(' 2 '); - expect(result).toContain(' 1 '); // Approved count for proj-2 - }); - - it('should truncate long initial prompts', () => { - // This test remains similar as we kept manual truncation for prompts - const longPrompt = 'This is a very long initial prompt that should be truncated based on the substring logic in the function.'; - // Correct the expected start - const truncatedStart = 'This is a very long initial prompt'; - const ellipsis = '...'; // Check for the ellipsis separately due to potential wrapping - const project: ProjectSummary = { - projectId: 'proj-long', initialPrompt: longPrompt, totalTasks: 1, completedTasks: 0, approvedTasks: 0 - }; - const result = formatProjectsList([project]); - expect(result).toContain('proj-long'); - expect(result).toContain(truncatedStart); // Check for the corrected start of the truncated string - expect(result).toContain(ellipsis); // Check for the ellipsis - expect(result).not.toContain('in the function.'); // Ensure the original end is cut off - }); - - it('should correctly display pre-calculated completed and approved tasks counts', () => { - const project: ProjectSummary = { - projectId: 'proj-counts', initialPrompt: 'Counts Test', totalTasks: 4, completedTasks: 2, approvedTasks: 1 - }; - const result = formatProjectsList([project]); - // Check for the specific counts formatted in the table - expect(result).toContain('proj-counts'); - expect(result).toContain('Counts Test'); - expect(result).toContain(' 4 '); - expect(result).toContain(' 2 '); - expect(result).toContain(' 1 '); - }); - }); -}); diff --git a/tests/unit/toolExecutors.test.ts b/tests/unit/toolExecutors.test.ts deleted file mode 100644 index 92eec81..0000000 --- a/tests/unit/toolExecutors.test.ts +++ /dev/null @@ -1,626 +0,0 @@ -import { jest, describe, it, expect } from '@jest/globals'; -import { TaskManager } from '../../src/server/TaskManager.js'; -import { toolExecutorMap } from '../../src/server/toolExecutors.js'; -import { ErrorCode } from '../../src/types/index.js'; -import { Task } from '../../src/types/index.js'; -import { ApproveTaskSuccessData } from '../../src/types/index.js'; - -// Mock TaskManager -jest.mock('../../src/server/TaskManager.js'); - -type SaveTasksFn = () => Promise; - -describe('Tool Executors', () => { - let taskManager: jest.Mocked; - - beforeEach(() => { - // Clear all mocks - jest.clearAllMocks(); - - // Create a new mock instance - taskManager = { - listProjects: jest.fn(), - createProject: jest.fn(), - getNextTask: jest.fn(), - updateTask: jest.fn(), - readProject: jest.fn(), - deleteProject: jest.fn(), - addTasksToProject: jest.fn(), - approveProjectCompletion: jest.fn(), - listTasks: jest.fn(), - openTaskDetails: jest.fn(), - deleteTask: jest.fn(), - approveTaskCompletion: jest.fn() - } as unknown as jest.Mocked; - }); - - // Utility Function Tests - describe('Utility Functions', () => { - describe('validateProjectId', () => { - it('should throw error for missing projectId', async () => { - const executor = toolExecutorMap.get('read_project')!; - await expect(executor.execute(taskManager, {})) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('projectId') - }); - }); - - it('should throw error for non-string projectId', async () => { - const executor = toolExecutorMap.get('read_project')!; - await expect(executor.execute(taskManager, { projectId: 123 })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('projectId') - }); - }); - }); - - describe('validateTaskId', () => { - it('should throw error for missing taskId', async () => { - const executor = toolExecutorMap.get('read_task')!; - await expect(executor.execute(taskManager, {})) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('taskId') - }); - }); - - it('should throw error for non-string taskId', async () => { - const executor = toolExecutorMap.get('read_task')!; - await expect(executor.execute(taskManager, { taskId: 123 })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('taskId') - }); - }); - }); - - describe('validateTaskList', () => { - it('should throw error for missing tasks', async () => { - const executor = toolExecutorMap.get('create_project')!; - await expect(executor.execute(taskManager, { initialPrompt: 'test' })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('tasks') - }); - }); - - it('should throw error for non-array tasks', async () => { - const executor = toolExecutorMap.get('create_project')!; - await expect(executor.execute(taskManager, { initialPrompt: 'test', tasks: 'not an array' })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('tasks') - }); - }); - }); - }); - - // Tool Executor Tests - describe('listProjects Tool Executor', () => { - it('should call taskManager.listProjects with no state', async () => { - const executor = toolExecutorMap.get('list_projects')!; - taskManager.listProjects.mockResolvedValue({ - status: 'success', - data: { - message: 'Projects listed successfully', - projects: [] - } - }); - - await executor.execute(taskManager, {}); - - expect(taskManager.listProjects).toHaveBeenCalledWith(undefined); - }); - - it('should call taskManager.listProjects with valid state', async () => { - const executor = toolExecutorMap.get('list_projects')!; - taskManager.listProjects.mockResolvedValue({ - status: 'success', - data: { - message: 'Projects listed successfully', - projects: [] - } - }); - - await executor.execute(taskManager, { state: 'open' }); - - expect(taskManager.listProjects).toHaveBeenCalledWith('open'); - }); - - it('should throw error for invalid state', async () => { - const executor = toolExecutorMap.get('list_projects')!; - - await expect(executor.execute(taskManager, { state: 'invalid' })) - .rejects - .toMatchObject({ - code: ErrorCode.InvalidArgument, - message: expect.stringContaining('state') - }); - }); - }); - - describe('createProject Tool Executor', () => { - const validTask = { - title: 'Test Task', - description: 'Test Description' - }; - - it('should create project with minimal valid input', async () => { - const executor = toolExecutorMap.get('create_project')!; - taskManager.createProject.mockResolvedValue({ - status: 'success', - data: { - projectId: 'test-proj', - totalTasks: 1, - tasks: [{ id: 'task-1', ...validTask }], - message: 'Project created successfully' - } - }); - - await executor.execute(taskManager, { - initialPrompt: 'Test Prompt', - tasks: [validTask] - }); - - expect(taskManager.createProject).toHaveBeenCalledWith( - 'Test Prompt', - [validTask], - undefined, - false - ); - }); - - it('should create project with all optional fields', async () => { - const executor = toolExecutorMap.get('create_project')!; - const taskWithRecommendations = { - ...validTask, - toolRecommendations: 'Use tool X', - ruleRecommendations: 'Follow rule Y' - }; - - taskManager.createProject.mockResolvedValue({ - status: 'success', - data: { - projectId: 'test-proj', - totalTasks: 1, - tasks: [{ id: 'task-1', ...taskWithRecommendations }], - message: 'Project created successfully' - } - }); - - await executor.execute(taskManager, { - initialPrompt: 'Test Prompt', - projectPlan: 'Test Plan', - tasks: [taskWithRecommendations] - }); - - expect(taskManager.createProject).toHaveBeenCalledWith( - 'Test Prompt', - [taskWithRecommendations], - 'Test Plan', - false - ); - }); - - it('should throw error for invalid task object', async () => { - const executor = toolExecutorMap.get('create_project')!; - - await expect(executor.execute(taskManager, { - initialPrompt: 'Test Prompt', - tasks: [{ title: 'Missing Description' }] - })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('description') - }); - }); - }); - - describe('getNextTask Tool Executor', () => { - it('should get next task successfully', async () => { - const executor = toolExecutorMap.get('get_next_task')!; - const mockTask: Task = { - id: 'task-1', - title: 'Test Task', - description: 'Test Description', - status: 'not started', - approved: false, - completedDetails: '' - }; - - taskManager.getNextTask.mockResolvedValue({ - status: 'success', - data: { - message: 'Next task retrieved successfully', - task: mockTask - } - }); - - const result = await executor.execute(taskManager, { projectId: 'proj-1' }); - - expect(taskManager.getNextTask).toHaveBeenCalledWith('proj-1'); - expect(result.content[0].text).toContain('task-1'); - }); - - it('should handle no next task', async () => { - const executor = toolExecutorMap.get('get_next_task')!; - taskManager.getNextTask.mockResolvedValue({ - status: 'all_tasks_done', - data: { message: 'All tasks completed' } - }); - - const result = await executor.execute(taskManager, { projectId: 'proj-1' }); - - expect(taskManager.getNextTask).toHaveBeenCalledWith('proj-1'); - expect(result.content[0].text).toContain('all_tasks_done'); - }); - }); - - describe('updateTask Tool Executor', () => { - const mockTask: Task = { - id: 'task-1', - title: 'Test Task', - description: 'Test Description', - status: 'not started', - approved: false, - completedDetails: '' - }; - - it('should update task with valid status transition', async () => { - const executor = toolExecutorMap.get('update_task')!; - taskManager.updateTask.mockResolvedValue({ - status: 'success', - data: { ...mockTask, status: 'in progress' } - }); - - await executor.execute(taskManager, { - projectId: 'proj-1', - taskId: 'task-1', - status: 'in progress' - }); - - expect(taskManager.updateTask).toHaveBeenCalledWith('proj-1', 'task-1', { - status: 'in progress' - }); - }); - - it('should require completedDetails when status is done', async () => { - const executor = toolExecutorMap.get('update_task')!; - - await expect(executor.execute(taskManager, { - projectId: 'proj-1', - taskId: 'task-1', - status: 'done' - })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('completedDetails') - }); - }); - - it('should update task with all optional fields', async () => { - const executor = toolExecutorMap.get('update_task')!; - taskManager.updateTask.mockResolvedValue({ - status: 'success', - data: { - ...mockTask, - title: 'New Title', - description: 'New Description', - toolRecommendations: 'New Tools', - ruleRecommendations: 'New Rules' - } - }); - - await executor.execute(taskManager, { - projectId: 'proj-1', - taskId: 'task-1', - title: 'New Title', - description: 'New Description', - toolRecommendations: 'New Tools', - ruleRecommendations: 'New Rules' - }); - - expect(taskManager.updateTask).toHaveBeenCalledWith('proj-1', 'task-1', { - title: 'New Title', - description: 'New Description', - toolRecommendations: 'New Tools', - ruleRecommendations: 'New Rules' - }); - }); - }); - - describe('readProject Tool Executor', () => { - it('should read project successfully', async () => { - const executor = toolExecutorMap.get('read_project')!; - const mockProject = { - projectId: 'proj-1', - initialPrompt: 'Test Project', - projectPlan: '', - completed: false, - tasks: [] as Task[] - }; - - taskManager.readProject.mockResolvedValue({ - status: 'success', - data: mockProject - }); - - const result = await executor.execute(taskManager, { projectId: 'proj-1' }); - - expect(taskManager.readProject).toHaveBeenCalledWith('proj-1'); - expect(result.content[0].text).toContain('proj-1'); - }); - }); - - describe('deleteProject Tool Executor', () => { - it('should delete project successfully', async () => { - const executor = toolExecutorMap.get('delete_project')!; - taskManager['data'] = { - projects: [{ - projectId: 'proj-1', - initialPrompt: 'Test Project', - projectPlan: '', - completed: false, - tasks: [] - }] - }; - taskManager['saveTasks'] = jest.fn(async () => Promise.resolve()); - - const result = await executor.execute(taskManager, { projectId: 'proj-1' }); - - expect(taskManager['saveTasks']).toHaveBeenCalled(); - expect(result.content[0].text).toContain('project_deleted'); - }); - - it('should handle non-existent project', async () => { - const executor = toolExecutorMap.get('delete_project')!; - taskManager['data'] = { - projects: [] - }; - - const result = await executor.execute(taskManager, { projectId: 'non-existent' }); - - expect(result.content[0].text).toContain('Project not found'); - }); - }); - - describe('addTasksToProject Tool Executor', () => { - const validTasks = [ - { title: 'Task 1', description: 'Description 1' }, - { title: 'Task 2', description: 'Description 2', toolRecommendations: 'Tool X', ruleRecommendations: 'Rule Y' } - ]; - - it('should add tasks successfully', async () => { - const executor = toolExecutorMap.get('add_tasks_to_project')!; - taskManager.addTasksToProject.mockResolvedValue({ - status: 'success', - data: { - message: 'Tasks added successfully', - newTasks: [ - { id: 'task-1', title: 'Task 1', description: 'Description 1' } - ] - } - }); - - await executor.execute(taskManager, { - projectId: 'proj-1', - tasks: validTasks - }); - - expect(taskManager.addTasksToProject).toHaveBeenCalledWith('proj-1', validTasks); - }); - - it('should throw error for invalid task in array', async () => { - const executor = toolExecutorMap.get('add_tasks_to_project')!; - const invalidTasks = [ - { title: 'Task 1' } // missing description - ]; - - await expect(executor.execute(taskManager, { - projectId: 'proj-1', - tasks: invalidTasks - })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('description') - }); - }); - }); - - describe('finalizeProject Tool Executor', () => { - it('should finalize project successfully', async () => { - const executor = toolExecutorMap.get('finalize_project')!; - taskManager.approveProjectCompletion.mockResolvedValue({ - status: 'success', - data: { - projectId: 'proj-1', - message: 'Project finalized successfully' - } - }); - - await executor.execute(taskManager, { projectId: 'proj-1' }); - - expect(taskManager.approveProjectCompletion).toHaveBeenCalledWith('proj-1'); - }); - }); - - describe('listTasks Tool Executor', () => { - it('should list tasks with no filters', async () => { - const executor = toolExecutorMap.get('list_tasks')!; - taskManager.listTasks.mockResolvedValue({ - status: 'success', - data: { - message: 'Tasks listed successfully', - tasks: [] - } - }); - - await executor.execute(taskManager, {}); - - expect(taskManager.listTasks).toHaveBeenCalledWith(undefined, undefined); - }); - - it('should list tasks with projectId filter', async () => { - const executor = toolExecutorMap.get('list_tasks')!; - await executor.execute(taskManager, { projectId: 'proj-1' }); - expect(taskManager.listTasks).toHaveBeenCalledWith('proj-1', undefined); - }); - - it('should list tasks with state filter', async () => { - const executor = toolExecutorMap.get('list_tasks')!; - await executor.execute(taskManager, { state: 'open' }); - expect(taskManager.listTasks).toHaveBeenCalledWith(undefined, 'open'); - }); - - it('should throw error for invalid state', async () => { - const executor = toolExecutorMap.get('list_tasks')!; - await expect(executor.execute(taskManager, { state: 'invalid' })) - .rejects - .toMatchObject({ - code: ErrorCode.InvalidArgument, - message: expect.stringContaining('state') - }); - }); - }); - - describe('readTask Tool Executor', () => { - it('should read task successfully', async () => { - const executor = toolExecutorMap.get('read_task')!; - const mockTask = { - projectId: 'proj-1', - initialPrompt: 'Test Project', - projectPlan: '', - completed: false, - task: { - id: 'task-1', - title: 'Test Task', - description: 'Test Description', - status: 'not started' as const, - approved: false, - completedDetails: '' - } - }; - - taskManager.openTaskDetails.mockResolvedValue({ - status: 'success', - data: mockTask - }); - - const result = await executor.execute(taskManager, { taskId: 'task-1' }); - - expect(taskManager.openTaskDetails).toHaveBeenCalledWith('task-1'); - expect(result.content[0].text).toContain('task-1'); - }); - }); - - describe('createTask Tool Executor', () => { - it('should create task successfully', async () => { - const executor = toolExecutorMap.get('create_task')!; - const taskData = { - title: 'New Task', - description: 'Task Description', - toolRecommendations: 'Tool X', - ruleRecommendations: 'Rule Y' - }; - - taskManager.addTasksToProject.mockResolvedValue({ - status: 'success', - data: { - message: 'Task created successfully', - newTasks: [ - { id: 'task-1', title: 'New Task', description: 'Task Description' } - ] - } - }); - - await executor.execute(taskManager, { - projectId: 'proj-1', - ...taskData - }); - - expect(taskManager.addTasksToProject).toHaveBeenCalledWith('proj-1', [taskData]); - }); - - it('should throw error for missing title', async () => { - const executor = toolExecutorMap.get('create_task')!; - await expect(executor.execute(taskManager, { - projectId: 'proj-1', - description: 'Description' - })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('title') - }); - }); - - it('should throw error for missing description', async () => { - const executor = toolExecutorMap.get('create_task')!; - await expect(executor.execute(taskManager, { - projectId: 'proj-1', - title: 'Title' - })) - .rejects - .toMatchObject({ - code: ErrorCode.MissingParameter, - message: expect.stringContaining('description') - }); - }); - }); - - describe('deleteTask Tool Executor', () => { - it('should delete task successfully', async () => { - const executor = toolExecutorMap.get('delete_task')!; - taskManager.deleteTask.mockResolvedValue({ - status: 'success', - data: { message: 'Task deleted successfully' } - }); - - await executor.execute(taskManager, { - projectId: 'proj-1', - taskId: 'task-1' - }); - - expect(taskManager.deleteTask).toHaveBeenCalledWith('proj-1', 'task-1'); - }); - }); - - describe('approveTask Tool Executor', () => { - it('should approve task successfully', async () => { - const executor = toolExecutorMap.get('approve_task')!; - // Mock data matching ApproveTaskSuccessData interface - const mockSuccessData: ApproveTaskSuccessData = { - projectId: 'proj-1', - task: { - id: 'task-1', - title: 'Test Task', - description: 'Test Description', - completedDetails: 'Completed successfully', - approved: true - } - }; - taskManager.approveTaskCompletion.mockResolvedValue({ - status: 'success', - data: mockSuccessData - }); - - await executor.execute(taskManager, { - projectId: 'proj-1', - taskId: 'task-1' - }); - - expect(taskManager.approveTaskCompletion).toHaveBeenCalledWith('proj-1', 'task-1'); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/tools.test.ts b/tests/unit/tools.test.ts deleted file mode 100644 index 84c9187..0000000 --- a/tests/unit/tools.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, it, expect } from '@jest/globals'; -import { ALL_TOOLS } from '../../src/server/tools.js'; -import { Tool } from '@modelcontextprotocol/sdk/types.js'; - -interface SchemaProperty { - type: string; - enum?: string[]; - description?: string; -} - -interface ToolInputSchema { - type: string; - properties: Record; - required?: string[]; -} - -interface TasksInputSchema { - type: string; - properties: { - tasks: { - type: string; - description: string; - items: { - type: string; - properties: Record; - required: string[]; - }; - }; - }; -} - -describe('Tools', () => { - it('should have all required project tools', () => { - const toolNames = ALL_TOOLS.map(tool => tool.name); - expect(toolNames).toContain('list_projects'); - expect(toolNames).toContain('read_project'); - expect(toolNames).toContain('create_project'); - expect(toolNames).toContain('delete_project'); - expect(toolNames).toContain('add_tasks_to_project'); - expect(toolNames).toContain('finalize_project'); - }); - - it('should have all required task tools', () => { - const toolNames = ALL_TOOLS.map(tool => tool.name); - expect(toolNames).toContain('list_tasks'); - expect(toolNames).toContain('read_task'); - expect(toolNames).toContain('create_task'); - expect(toolNames).toContain('update_task'); - expect(toolNames).toContain('delete_task'); - expect(toolNames).toContain('approve_task'); - expect(toolNames).toContain('get_next_task'); - }); - - it('should have create_project tool with correct schema', () => { - const createProjectTool = ALL_TOOLS.find(tool => tool.name === 'create_project') as Tool; - expect(createProjectTool).toBeDefined(); - expect(createProjectTool.inputSchema.required).toContain('initialPrompt'); - expect(createProjectTool.inputSchema.required).toContain('tasks'); - - // Check that the tool schema has the expected properties - const props = createProjectTool.inputSchema.properties; - expect(props).toBeDefined(); - expect(props).toHaveProperty('initialPrompt'); - expect(props).toHaveProperty('projectPlan'); - expect(props).toHaveProperty('tasks'); - }); - - it('should have update_task tool with correct schema', () => { - const updateTaskTool = ALL_TOOLS.find(tool => tool.name === 'update_task') as Tool; - expect(updateTaskTool).toBeDefined(); - expect(updateTaskTool.inputSchema.required).toContain('projectId'); - expect(updateTaskTool.inputSchema.required).toContain('taskId'); - - // Check that the tool schema has the expected properties - const props = updateTaskTool.inputSchema.properties; - expect(props).toBeDefined(); - expect(props).toHaveProperty('projectId'); - expect(props).toHaveProperty('taskId'); - expect(props).toHaveProperty('title'); - expect(props).toHaveProperty('description'); - expect(props).toHaveProperty('status'); - expect(props).toHaveProperty('completedDetails'); - - // Check that status has the correct enum values - const statusProp = props?.status as SchemaProperty; - expect(statusProp).toBeDefined(); - expect(statusProp.enum).toContain('not started'); - expect(statusProp.enum).toContain('in progress'); - expect(statusProp.enum).toContain('done'); - - // Check that completedDetails is not in required fields - expect(updateTaskTool.inputSchema.required).not.toContain('completedDetails'); - }); - - it('should have get_next_task tool with correct schema', () => { - const getNextTaskTool = ALL_TOOLS.find(tool => tool.name === 'get_next_task') as Tool; - expect(getNextTaskTool).toBeDefined(); - expect(getNextTaskTool.inputSchema.required).toContain('projectId'); - - const props = getNextTaskTool.inputSchema.properties; - expect(props).toBeDefined(); - expect(props).toHaveProperty('projectId'); - }); - - // General checks for all tools - describe('All tools', () => { - ALL_TOOLS.forEach(tool => { - describe(`${tool.name} tool`, () => { - it('should have basic required properties', () => { - expect(tool).toHaveProperty('name'); - expect(tool).toHaveProperty('description'); - expect(tool).toHaveProperty('inputSchema'); - expect(typeof tool.name).toBe('string'); - expect(typeof tool.description).toBe('string'); - }); - - it('should have valid inputSchema', () => { - expect(tool.inputSchema.type).toBe('object'); - expect(Array.isArray(tool.inputSchema.required)).toBe(true); - }); - - it('should have descriptions for all properties', () => { - const props = tool.inputSchema.properties; - if (props) { - for (const propName in props) { - const prop = props[propName] as SchemaProperty; - expect(prop.description).toBeDefined(); - expect(typeof prop.description).toBe('string'); - } - } - }); - }); - }); - }); - - it('should enforce a consistent naming convention for tools', () => { - ALL_TOOLS.forEach(tool => { - expect(tool.name).toMatch(/^[a-z]+(_[a-z]+)*$/); - }); - }); - - describe("Tool Schemas", () => { - it("should include tool and rule recommendations in create_task tool", () => { - const createTaskTool = ALL_TOOLS.find((tool) => tool.name === "create_task"); - expect(createTaskTool).toBeDefined(); - - const schema = createTaskTool!.inputSchema as ToolInputSchema; - const properties = schema.properties; - - expect(properties).toHaveProperty("toolRecommendations"); - expect(properties.toolRecommendations.type).toBe("string"); - expect(properties.toolRecommendations.description).toContain("tools to use"); - - expect(properties).toHaveProperty("ruleRecommendations"); - expect(properties.ruleRecommendations.type).toBe("string"); - expect(properties.ruleRecommendations.description).toContain("rules to review"); - - expect(schema.required).not.toContain("toolRecommendations"); - expect(schema.required).not.toContain("ruleRecommendations"); - }); - - it("should include tool and rule recommendations in update_task tool", () => { - const updateTaskTool = ALL_TOOLS.find((tool) => tool.name === "update_task"); - expect(updateTaskTool).toBeDefined(); - - const schema = updateTaskTool!.inputSchema as ToolInputSchema; - const properties = schema.properties; - - expect(properties).toHaveProperty("toolRecommendations"); - expect(properties.toolRecommendations.type).toBe("string"); - expect(properties.toolRecommendations.description).toContain("tools to use"); - - expect(properties).toHaveProperty("ruleRecommendations"); - expect(properties.ruleRecommendations.type).toBe("string"); - expect(properties.ruleRecommendations.description).toContain("rules to review"); - - expect(schema.required).not.toContain("toolRecommendations"); - expect(schema.required).not.toContain("ruleRecommendations"); - }); - - it("should include tool and rule recommendations in task creation via create_project tool", () => { - const createProjectTool = ALL_TOOLS.find((tool) => tool.name === "create_project"); - expect(createProjectTool).toBeDefined(); - - const schema = createProjectTool!.inputSchema as unknown as TasksInputSchema; - const taskProperties = schema.properties.tasks.items.properties; - - expect(taskProperties).toHaveProperty("toolRecommendations"); - expect(taskProperties.toolRecommendations.type).toBe("string"); - expect(taskProperties.toolRecommendations.description).toContain("tools to use"); - - expect(taskProperties).toHaveProperty("ruleRecommendations"); - expect(taskProperties.ruleRecommendations.type).toBe("string"); - expect(taskProperties.ruleRecommendations.description).toContain("rules to review"); - - const required = schema.properties.tasks.items.required; - expect(required).not.toContain("toolRecommendations"); - expect(required).not.toContain("ruleRecommendations"); - }); - - it("should include tool and rule recommendations in task creation via add_tasks_to_project tool", () => { - const addTasksTool = ALL_TOOLS.find((tool) => tool.name === "add_tasks_to_project"); - expect(addTasksTool).toBeDefined(); - - const schema = addTasksTool!.inputSchema as unknown as TasksInputSchema; - const taskProperties = schema.properties.tasks.items.properties; - - expect(taskProperties).toHaveProperty("toolRecommendations"); - expect(taskProperties.toolRecommendations.type).toBe("string"); - expect(taskProperties.toolRecommendations.description).toContain("tools to use"); - - expect(taskProperties).toHaveProperty("ruleRecommendations"); - expect(taskProperties.ruleRecommendations.type).toBe("string"); - expect(taskProperties.ruleRecommendations.description).toContain("rules to review"); - - const required = schema.properties.tasks.items.required; - expect(required).not.toContain("toolRecommendations"); - expect(required).not.toContain("ruleRecommendations"); - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index cc8c905..da8d796 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", + "lib": ["ES2022", "DOM"], "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, @@ -11,6 +12,6 @@ "sourceMap": true, "types": ["jest", "node"] }, - "include": ["index.ts", "src/**/*", "tests/**/*"], + "include": ["src/server/index.ts", "src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] }