From f32e23419fc8e520e49fa9932261a55d5ba55f16 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 31 Mar 2025 13:42:22 -0400 Subject: [PATCH 1/8] Simplify error handling, switch to primarily integration tests --- .cursor/rules/general.mdc | 6 + babel.config.cjs | 6 + index.ts | 9 +- jest.config.cjs | 9 +- package-lock.json | 1628 ++++++++++++++++- package.json | 4 + src/client/cli.ts | 319 ++-- src/client/errors.ts | 39 +- src/server/FileSystemService.ts | 55 +- src/server/TaskManager.ts | 502 +++-- src/server/toolExecutors.ts | 250 ++- src/server/tools.ts | 61 +- src/types/index.ts | 54 +- src/utils/errors.ts | 126 +- .../cli.integration.test.ts | 0 tests/helpers/mocks.ts | 65 - .../TaskManager.integration.test.ts | 625 ------- tests/integration/e2e.integration.test.ts | 412 ----- tests/mcp/e2e.integration.test.ts | 117 ++ tests/mcp/test-helpers.ts | 287 +++ tests/mcp/tools/approve-task.test.ts | 205 +++ tests/mcp/tools/create-project.test.ts | 218 +++ tests/mcp/tools/finalize-project.test.ts | 251 +++ tests/mcp/tools/generate-project-plan.test.ts | 218 +++ tests/mcp/tools/get-next-task.test.ts | 220 +++ tests/mcp/tools/list-projects.test.ts | 138 ++ tests/mcp/tools/read-project.test.ts | 210 +++ tests/mcp/tools/update-task.test.ts | 218 +++ tests/unit/FileSystemService.test.ts | 165 -- tests/unit/StateTransitionRules.test.ts | 39 - tests/unit/TaskManager.test.ts | 1090 ----------- tests/unit/cli.test.ts | 179 -- tests/unit/errors.test.ts | 91 - tests/unit/taskFormattingUtils.test.ts | 193 -- tests/unit/toolExecutors.test.ts | 626 ------- tests/unit/tools.test.ts | 221 --- tsconfig.json | 3 +- 37 files changed, 4302 insertions(+), 4557 deletions(-) create mode 100644 .cursor/rules/general.mdc create mode 100644 babel.config.cjs rename tests/{integration => cli}/cli.integration.test.ts (100%) delete mode 100644 tests/helpers/mocks.ts delete mode 100644 tests/integration/TaskManager.integration.test.ts delete mode 100644 tests/integration/e2e.integration.test.ts create mode 100644 tests/mcp/e2e.integration.test.ts create mode 100644 tests/mcp/test-helpers.ts create mode 100644 tests/mcp/tools/approve-task.test.ts create mode 100644 tests/mcp/tools/create-project.test.ts create mode 100644 tests/mcp/tools/finalize-project.test.ts create mode 100644 tests/mcp/tools/generate-project-plan.test.ts create mode 100644 tests/mcp/tools/get-next-task.test.ts create mode 100644 tests/mcp/tools/list-projects.test.ts create mode 100644 tests/mcp/tools/read-project.test.ts create mode 100644 tests/mcp/tools/update-task.test.ts delete mode 100644 tests/unit/FileSystemService.test.ts delete mode 100644 tests/unit/StateTransitionRules.test.ts delete mode 100644 tests/unit/TaskManager.test.ts delete mode 100644 tests/unit/cli.test.ts delete mode 100644 tests/unit/errors.test.ts delete mode 100644 tests/unit/taskFormattingUtils.test.ts delete mode 100644 tests/unit/toolExecutors.test.ts delete mode 100644 tests/unit/tools.test.ts 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/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/index.ts b/index.ts index af1000e..dcef162 100644 --- a/index.ts +++ b/index.ts @@ -3,7 +3,7 @@ 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 { ALL_TOOLS, executeToolAndHandleErrors } from "./src/server/tools.js"; import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js"; // Create server with capabilities BEFORE setting up handlers @@ -39,11 +39,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/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..3c45a56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,10 +26,14 @@ "taskqueue-mcp": "dist/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", @@ -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", @@ -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..32cdab0 100644 --- a/package.json +++ b/package.json @@ -52,10 +52,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..d44d23a 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"; import { TaskManager } from "../server/TaskManager.js"; -import { createError, normalizeError } from "../utils/errors.js"; import { formatCliError } from "./errors.js"; import { formatProjectsList, formatTaskProgressTable } from "./taskFormattingUtils.js"; @@ -32,7 +30,18 @@ program.hook('preAction', (thisCommand, actionCommand) => { try { taskManager = new TaskManager(resolvedPath); } catch (error) { - console.error(chalk.red(`Failed to initialize TaskManager: ${formatCliError(normalizeError(error))}`)); + if (error instanceof Error) { + if (error.name === 'FileReadError') { + console.error(chalk.red(`Failed to initialize TaskManager: Could not read tasks file`)); + if (resolvedPath) { + console.error(chalk.yellow(`Please check if the file exists and you have permission to read: ${resolvedPath}`)); + } + } else { + console.error(chalk.red(`Failed to initialize TaskManager: ${formatCliError(error)}`)); + } + } else { + console.error(chalk.red(`Failed to initialize TaskManager: Unknown error occurred`)); + } process.exit(1); } }); @@ -51,15 +60,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,23 +72,21 @@ 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.')); + if (error instanceof Error) { + if (error.name === 'ProjectNotFound') { + console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); + // Optionally list available projects + const projects = await taskManager.listProjects(); + if (projects.projects.length > 0) { + console.log(chalk.yellow('Available projects:')); + projects.projects.forEach((p) => { + console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); + }); + } else { + console.log(chalk.yellow('No projects available.')); + } + process.exit(1); } - process.exit(1); } throw error; // Re-throw other errors } @@ -104,22 +104,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 +129,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 +150,11 @@ 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); + if (error instanceof Error) { + console.error(chalk.red(formatCliError(error))); + } else { + console.error(chalk.red('An unknown error occurred')); } - // Handle other errors generally - console.error(chalk.red(formatCliError(normalized))); process.exit(1); } }); @@ -185,34 +170,26 @@ 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.')); + if (error instanceof Error) { + if (error.name === 'ProjectNotFound') { + console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); + // Optionally list available projects + const projects = await taskManager.listProjects(); + if (projects.projects.length > 0) { + console.log(chalk.yellow('Available projects:')); + projects.projects.forEach((p) => { + console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); + }); + } else { + console.log(chalk.yellow('No projects available.')); + } + process.exit(1); } - process.exit(1); + throw error; // Re-throw other errors } - throw error; // Re-throw other errors + throw new Error('Unknown error occurred'); } // Pre-check project status @@ -243,21 +220,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 +250,11 @@ 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); + if (error instanceof Error) { + console.error(chalk.red(formatCliError(error))); + } else { + console.error(chalk.red('An unknown error occurred')); } - // Handle other errors generally - console.error(chalk.red(formatCliError(normalized))); process.exit(1); } }); @@ -325,74 +280,69 @@ 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})`)); - } + 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.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); + } catch (error) { + if (error instanceof Error) { + if (error.name === 'ProjectNotFound') { + console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); + // Optionally list available projects + const projects = await taskManager.listProjects(); + if (projects.projects.length > 0) { + console.log(chalk.yellow('Available projects:')); + projects.projects.forEach((p) => { + console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); + }); + } else { + console.log(chalk.yellow('No projects available.')); + } + process.exit(1); } + console.error(chalk.red(formatCliError(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 projectSummaries = projectsSummaryResponse.data.projects; + const projects = await taskManager.listProjects(filterState); - 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)))); + if (error instanceof Error) { + console.error(chalk.red(formatCliError(error))); + } else { + console.error(chalk.red('An unknown error occurred')); + } process.exit(1); } }); @@ -407,57 +357,48 @@ 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 (error) { + if (error instanceof Error) { + // Special handling for file system errors + if (error.name === 'FileReadError') { + console.error(chalk.red("Error: Could not read one or more attachment files")); + if (options.attachment.length > 0) { + console.error(chalk.yellow("Please check if these files exist and are readable:")); + options.attachment.forEach((file: string) => { + console.error(chalk.yellow(` - ${file}`)); + }); + } + } else { + console.error(`Error: ${chalk.red(formatCliError(error))}`); + } + } else { + console.error(chalk.red('An unknown error occurred')); } - } catch (err: unknown) { - const normalized = normalizeError(err); - console.error(`Error: ${chalk.red(formatCliError(normalized))}`); process.exit(1); } }); diff --git a/src/client/errors.ts b/src/client/errors.ts index da798d7..4b138e2 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -1,28 +1,21 @@ -import { StandardError } from "../types/index.js"; +import { ErrorCode } from "../types/index.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 & { code?: ErrorCode | number }): string { + // Handle our custom file system errors with user-friendly messages + if (error.name === 'ReadOnlyFileSystemError') { + return "Cannot save tasks: The file system is read-only. Please check your permissions."; + } + if (error.name === 'FileWriteError') { + return "Failed to save tasks: There was an error writing to the file."; + } + if (error.name === 'FileReadError') { + return "Failed to read file: The file could not be accessed or does not exist."; } - return message; + // For other errors, include the error code if available + const codePrefix = error.code ? `[${error.code}] ` : ''; + return `${codePrefix}${error.message}`; } \ No newline at end of file diff --git a/src/server/FileSystemService.ts b/src/server/FileSystemService.ts index 739d79a..fce14f9 100644 --- a/src/server/FileSystemService.ts +++ b/src/server/FileSystemService.ts @@ -2,7 +2,34 @@ 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"; + +// Custom error classes for FileSystemService +export class ReadOnlyFileSystemError extends Error { + constructor(originalError?: unknown) { + super('Cannot save tasks: read-only file system'); + this.name = 'ReadOnlyFileSystemError'; + (this as any).code = ErrorCode.ReadOnlyFileSystem; + (this as any).originalError = originalError; + } +} + +export class FileWriteError extends Error { + constructor(message: string, originalError?: unknown) { + super(message); + this.name = 'FileWriteError'; + (this as any).code = ErrorCode.FileWriteError; + (this as any).originalError = originalError; + } +} + +export class FileReadError extends Error { + constructor(message: string, originalError?: unknown) { + super(message); + this.name = 'FileReadError'; + (this as any).code = ErrorCode.FileReadError; + (this as any).originalError = originalError; + } +} export interface InitializedTaskData { data: TaskManagerFile; @@ -162,17 +189,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 ReadOnlyFileSystemError(error); } - throw createError( - ErrorCode.FileWriteError, - "Failed to save tasks file", - { originalError: error } - ); + throw new FileWriteError("Failed to save tasks file", error); } }); } @@ -181,7 +200,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 +208,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 FileReadError(`Attachment file not found: ${filename}`, error); } - throw createError( - ErrorCode.FileReadError, - `Failed to read attachment file: ${filename}`, - { originalError: error } - ); + throw new FileReadError(`Failed to read attachment file: ${filename}`, error); } } } \ No newline at end of file diff --git a/src/server/TaskManager.ts b/src/server/TaskManager.ts index 2fdd47f..6d992a2 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -3,9 +3,6 @@ import { Task, TaskManagerFile, TaskState, - StandardResponse, - ErrorCode, - Project, ProjectCreationSuccessData, ApproveTaskSuccessData, ApproveProjectSuccessData, @@ -14,16 +11,85 @@ import { ListTasksSuccessData, AddTasksSuccessData, DeleteTaskSuccessData, - ReadProjectSuccessData + ReadProjectSuccessData, + Project } from "../types/index.js"; -import { createError, createSuccessResponse } from "../utils/errors.js"; -import { generateObject, jsonSchema } from "ai"; 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; +// Custom error classes for business logic errors +export class ProjectNotFoundError extends Error { + constructor(projectId: string) { + super(`Project ${projectId} not found`); + this.name = 'ProjectNotFoundError'; + } +} + +export class TaskNotFoundError extends Error { + constructor(taskId: string) { + super(`Task ${taskId} not found`); + this.name = 'TaskNotFoundError'; + } +} + +export class ProjectAlreadyCompletedError extends Error { + constructor() { + super('Project is already completed'); + this.name = 'ProjectAlreadyCompletedError'; + } +} + +export class TaskNotDoneError extends Error { + constructor() { + super('Task not done yet'); + this.name = 'TaskNotDoneError'; + } +} + +export class TasksNotAllDoneError extends Error { + constructor() { + super('Not all tasks are done'); + this.name = 'TasksNotAllDoneError'; + } +} + +export class TasksNotAllApprovedError extends Error { + constructor() { + super('Not all done tasks are approved'); + this.name = 'TasksNotAllApprovedError'; + } +} + +export class FileReadError extends Error { + constructor(filename: string, originalError?: unknown) { + super(`Failed to read attachment file: ${filename}`); + this.name = 'FileReadError'; + (this as any).originalError = originalError; + } +} + +export class ConfigurationError extends Error { + constructor(message: string, originalError?: unknown) { + super(message); + this.name = 'ConfigurationError'; + (this as any).originalError = originalError; + } +} + +interface ProjectPlanOutput { + projectPlan: string; + tasks: Array<{ + title: string; + description: string; + toolRecommendations?: string; + ruleRecommendations?: string; + }>; +} + export class TaskManager { private projectCounter = 0; private taskCounter = 0; @@ -47,11 +113,6 @@ export class TaskManager { await this.initialized; } - /** - * 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; @@ -69,10 +130,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 +162,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 +173,7 @@ export class TaskManager { description: t.description, })), message: `Project ${projectId} created with ${newTasks.length} tasks.`, - }); + }; } public async generateProjectPlan({ @@ -126,23 +186,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 FileReadError(filename, error); } } @@ -191,166 +245,85 @@ 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 Error(`Invalid provider: ${provider}`); } 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) { + // Handle specific error cases + if (err.name === 'LoadAPIKeyError' || err.message.includes('API key is missing')) { + throw new ConfigurationError( + "Invalid or missing API key. Please check your environment variables.", + 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 - ); + if (err.message.includes('authentication') || err.message.includes('unauthorized')) { + throw new ConfigurationError( + "Authentication failed with the LLM provider. Please check your credentials.", + err + ); + } + // For unknown errors, preserve the original error but wrap it + throw new Error("Failed to generate project plan due to an unexpected error", { cause: 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 ProjectNotFoundError(projectId); } if (proj.completed) { - throw createError( - ErrorCode.ProjectAlreadyCompleted, - "Project is already completed" - ); + throw new ProjectAlreadyCompletedError(); } + const nextTask = proj.tasks.find((t) => t.status !== "done"); if (!nextTask) { // all tasks done? const allDone = proj.tasks.every((t) => t.status === "done"); if (allDone && !proj.completed) { return { - status: "all_tasks_done", - data: { - message: `All tasks have been completed. Awaiting project completion approval.` - } + message: `All tasks have been completed. Awaiting project completion approval.` }; } - throw createError( - ErrorCode.TaskNotFound, - "No undone tasks found" - ); + throw new TaskNotFoundError("No undone tasks found"); } - // 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 ProjectNotFoundError(projectId); } + const task = proj.tasks.find((t) => t.id === taskId); if (!task) { - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); + throw new TaskNotFoundError(taskId); } + if (task.status !== "done") { - throw createError( - ErrorCode.TaskNotDone, - "Task not done yet" - ); + throw new TaskNotDoneError(); } + if (task.approved) { - // Return the full expected data structure even if already approved - return createSuccessResponse({ - message: "Task already approved.", + return { projectId: proj.projectId, task: { id: task.id, @@ -359,12 +332,13 @@ export class TaskManager { completedDetails: task.completedDetails, approved: task.approved, }, - }); + }; } task.approved = true; await this.saveTasks(); - return createSuccessResponse({ + + return { projectId: proj.projectId, task: { id: task.id, @@ -373,179 +347,148 @@ 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 ProjectNotFoundError(projectId); } - // Check if project is already completed if (proj.completed) { - throw createError( - ErrorCode.ProjectAlreadyCompleted, - "Project is already completed" - ); + throw new ProjectAlreadyCompletedError(); } - // 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 TasksNotAllDoneError(); } + 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 TasksNotAllApprovedError(); } 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(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({ + return { projectId: proj.projectId, - task: { ...target }, // Return all fields from the found task - }); + task: { ...target }, + }; } } - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); + throw new TaskNotFoundError(taskId); } - 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(); 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 + + 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 ProjectNotFoundError(projectId); } + 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; 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 ProjectNotFoundError(projectId); + } + + if (proj.completed) { + throw new ProjectAlreadyCompletedError(); } 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 +497,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 +525,72 @@ 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 taskIndex = project.tasks.findIndex((t) => t.id === taskId); - if (taskIndex === -1) { - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); + const proj = this.data.projects.find((p) => p.projectId === projectId); + if (!proj) { + throw new ProjectNotFoundError(projectId); } - // Update the task with the provided updates - project.tasks[taskIndex] = { ...project.tasks[taskIndex], ...updates }; + if (proj.completed) { + throw new ProjectAlreadyCompletedError(); + } - // Check if status was updated to 'done' and if project has autoApprove enabled - if (updates.status === 'done' && project.autoApprove) { - project.tasks[taskIndex].approved = true; + const task = proj.tasks.find((t) => t.id === taskId); + if (!task) { + throw new TaskNotFoundError(taskId); } + // 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 ProjectNotFoundError(projectId); + } + + if (proj.completed) { + throw new ProjectAlreadyCompletedError(); } const taskIndex = proj.tasks.findIndex((t) => t.id === taskId); if (taskIndex === -1) { - throw createError( - ErrorCode.TaskNotFound, - `Task ${taskId} not found` - ); - } - if (proj.tasks[taskIndex].status === "done") { - throw createError( - ErrorCode.CannotDeleteCompletedTask, - "Cannot delete completed task" - ); + throw new TaskNotFoundError(taskId); } - proj.tasks.splice(taskIndex, 1); + const [deletedTask] = 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 ProjectNotFoundError(projectId); } - return createSuccessResponse({ + + return { projectId: project.projectId, initialPrompt: project.initialPrompt, projectPlan: project.projectPlan, completed: project.completed, - tasks: project.tasks - }); + tasks: project.tasks, + }; } } \ No newline at end of file diff --git a/src/server/toolExecutors.ts b/src/server/toolExecutors.ts index 707e76b..0bc37f9 100644 --- a/src/server/toolExecutors.ts +++ b/src/server/toolExecutors.ts @@ -1,11 +1,9 @@ import { TaskManager } from "./TaskManager.js"; -import { ErrorCode } from "../types/index.js"; -import { createError } from "../utils/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 +13,26 @@ 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 a JSON-RPC error 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}`); + const message = `Invalid or missing required parameter: ${paramName} (Expected string)`; + const error = new Error(message); + // Tag as a protocol error (Invalid Params) + (error as any).jsonRpcCode = -32602; + throw error; } return param; } @@ -60,11 +52,15 @@ function validateTaskId(taskId: unknown): string { } /** - * Throws an error if tasks is not defined or not an array. + * Throws a JSON-RPC error 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"); + const message = "Invalid or missing required parameter: tasks (Expected array)"; + const error = new Error(message); + // Tag as a protocol error (Invalid Params) + (error as any).jsonRpcCode = -32602; + throw error; } } @@ -77,10 +73,11 @@ 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(", ")}` - ); + const message = `Invalid state parameter. Must be one of: ${validStates.join(", ")}`; + const error = new Error(message); + // Tag as a protocol error (Invalid Params) + (error as any).jsonRpcCode = -32602; + throw error; } /** @@ -100,10 +97,11 @@ 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.` - ); + const message = `${errorPrefix || "Task"} at index ${index} must be an object`; + const error = new Error(message); + // Tag as a protocol error (Invalid Params) + (error as any).jsonRpcCode = -32602; + throw error; } const t = task as Record; @@ -131,6 +129,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 +137,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); @@ -150,20 +152,34 @@ toolExecutorMap.set(listProjectsToolExecutor.name, listProjectsToolExecutor); const createProjectToolExecutor: ToolExecutor = { name: "create_project", async execute(taskManager, args) { + // 1. Argument Validation (throws tagged Error for protocol errors) 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); // Throws tagged error if invalid + 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') { + const error = new Error("Invalid type for optional parameter 'projectPlan' (Expected string)"); + (error as any).jsonRpcCode = -32602; + throw error; + } + if (args.autoApprove !== undefined && typeof args.autoApprove !== 'boolean') { + const error = new Error("Invalid type for optional parameter 'autoApprove' (Expected boolean)"); + (error as any).jsonRpcCode = -32602; + throw error; + } + + // 2. Core Logic Execution (Can throw *untagged* errors for execution failures) + // TaskManager now returns raw data or throws its internal errors + const resultData = await taskManager.createProject( initialPrompt, validatedTasks, projectPlan, autoApprove ); - return formatToolResponse(result); + // 3. Return raw success data - NO try/catch or formatting here + return resultData; }, }; toolExecutorMap.set(createProjectToolExecutor.name, createProjectToolExecutor); @@ -174,57 +190,54 @@ 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` - ); + const error = new Error(`Invalid provider: ${provider}. Must be one of: openai, google, deepseek`); + (error as any).jsonRpcCode = -32602; + throw error; } // 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}` - ); + const error = new Error(`Missing ${envKey} environment variable required for ${provider}`); + (error as any).jsonRpcCode = -32602; + throw error; } // 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" - ); + const error = new Error("Invalid attachments: must be an array of strings"); + (error as any).jsonRpcCode = -32602; + throw error; } attachments = args.attachments.map((att, index) => { if (typeof att !== "string") { - throw createError( - ErrorCode.InvalidArgument, - `Invalid attachment at index ${index}: must be a string` - ); + const error = new Error(`Invalid attachment at index ${index}: must be a string`); + (error as any).jsonRpcCode = -32602; + throw error; } 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 +248,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); @@ -248,6 +266,7 @@ toolExecutorMap.set(getNextTaskToolExecutor.name, getNextTaskToolExecutor); const updateTaskToolExecutor: ToolExecutor = { name: "update_task", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); const taskId = validateTaskId(args.taskId); @@ -262,19 +281,17 @@ const updateTaskToolExecutor: ToolExecutor = { } if (args.toolRecommendations !== undefined) { if (typeof args.toolRecommendations !== "string") { - throw createError( - ErrorCode.InvalidArgument, - "Invalid toolRecommendations: must be a string" - ); + const error = new Error("Invalid toolRecommendations: must be a string"); + (error as any).jsonRpcCode = -32602; + throw error; } updates.toolRecommendations = args.toolRecommendations; } if (args.ruleRecommendations !== undefined) { if (typeof args.ruleRecommendations !== "string") { - throw createError( - ErrorCode.InvalidArgument, - "Invalid ruleRecommendations: must be a string" - ); + const error = new Error("Invalid ruleRecommendations: must be a string"); + (error as any).jsonRpcCode = -32602; + throw error; } updates.ruleRecommendations = args.ruleRecommendations; } @@ -286,10 +303,9 @@ const updateTaskToolExecutor: ToolExecutor = { 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'" - ); + const error = new Error("Invalid status: must be one of 'not started', 'in progress', 'done'"); + (error as any).jsonRpcCode = -32602; + throw error; } if (status === "done") { updates.completedDetails = validateRequiredStringParam( @@ -300,8 +316,11 @@ const updateTaskToolExecutor: ToolExecutor = { updates.status = status; } - const result = await taskManager.updateTask(projectId, taskId, updates); - return formatToolResponse(result); + // 2. Core Logic Execution + const resultData = await taskManager.updateTask(projectId, taskId, updates); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(updateTaskToolExecutor.name, updateTaskToolExecutor); @@ -312,9 +331,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); @@ -325,26 +349,29 @@ toolExecutorMap.set(readProjectToolExecutor.name, readProjectToolExecutor); const deleteProjectToolExecutor: ToolExecutor = { name: "delete_project", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); + // 2. Core Logic Execution const projectIndex = taskManager["data"].projects.findIndex( (p) => p.projectId === projectId ); if (projectIndex === -1) { - return formatToolResponse({ + return { status: "error", message: "Project not found", - }); + }; } // Remove project and save taskManager["data"].projects.splice(projectIndex, 1); await taskManager["saveTasks"](); - return formatToolResponse({ + // 3. Return raw success data + return { status: "project_deleted", message: `Project ${projectId} has been deleted.`, - }); + }; }, }; toolExecutorMap.set(deleteProjectToolExecutor.name, deleteProjectToolExecutor); @@ -355,11 +382,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); - const result = await taskManager.addTasksToProject(projectId, tasks); - return formatToolResponse(result); + // 2. Core Logic Execution + const resultData = await taskManager.addTasksToProject(projectId, tasks); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(addTasksToProjectToolExecutor.name, addTasksToProjectToolExecutor); @@ -370,9 +401,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 +419,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 +428,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 +443,14 @@ toolExecutorMap.set(listTasksToolExecutor.name, listTasksToolExecutor); const readTaskToolExecutor: ToolExecutor = { name: "read_task", async execute(taskManager, args) { + // 1. Argument Validation const taskId = validateTaskId(args.taskId); - const result = await taskManager.openTaskDetails(taskId); - return formatToolResponse(result); + + // 2. Core Logic Execution + const resultData = await taskManager.openTaskDetails(taskId); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(readTaskToolExecutor.name, readTaskToolExecutor); @@ -416,10 +461,22 @@ toolExecutorMap.set(readTaskToolExecutor.name, readTaskToolExecutor); const createTaskToolExecutor: ToolExecutor = { name: "create_task", async execute(taskManager, args) { + // 1. Argument Validation const projectId = validateProjectId(args.projectId); const title = validateRequiredStringParam(args.title, "title"); const description = validateRequiredStringParam(args.description, "description"); + if (args.toolRecommendations !== undefined && typeof args.toolRecommendations !== "string") { + const error = new Error("Invalid type for optional parameter 'toolRecommendations' (Expected string)"); + (error as any).jsonRpcCode = -32602; + throw error; + } + if (args.ruleRecommendations !== undefined && typeof args.ruleRecommendations !== "string") { + const error = new Error("Invalid type for optional parameter 'ruleRecommendations' (Expected string)"); + (error as any).jsonRpcCode = -32602; + throw error; + } + const singleTask = { title, description, @@ -427,8 +484,11 @@ const createTaskToolExecutor: ToolExecutor = { ruleRecommendations: args.ruleRecommendations ? String(args.ruleRecommendations) : undefined, }; - const result = await taskManager.addTasksToProject(projectId, [singleTask]); - return formatToolResponse(result); + // 2. Core Logic Execution + const resultData = await taskManager.addTasksToProject(projectId, [singleTask]); + + // 3. Return raw success data + return resultData; }, }; toolExecutorMap.set(createTaskToolExecutor.name, createTaskToolExecutor); @@ -439,11 +499,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 +518,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..65e6c42 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -1,7 +1,6 @@ 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 { normalizeError } from "../utils/errors.js"; import { toolExecutorMap } from "./toolExecutors.js"; // ---------------------- PROJECT TOOLS ---------------------- @@ -443,35 +442,51 @@ 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 }> { + const executor = toolExecutorMap.get(toolName); + + // 1. Handle "Tool Not Found" - This is a Protocol Error (-32601 Method not found) + if (!executor) { + const error = new Error(`Unknown tool: ${toolName}`); + (error as any).jsonRpcCode = -32601; // Tag as protocol error + throw error; // Throw 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); - return await executor.execute(taskManager, args); - } catch (error) { - const standardError = normalizeError(error); + // 3. Format successful execution result return { - content: [{ type: "text", text: `Error: ${standardError.message}` }], - isError: true, + content: [{ type: "text", text: JSON.stringify(resultData, null, 2) }] }; + + } catch (error: unknown) { + // 4. Handle ALL errors thrown during execution + const potentialProtocolError = error as any; + + if (potentialProtocolError?.jsonRpcCode) { + // 4a. If it's tagged as a protocol error (e.g., from validation), re-throw it. + // The MCP SDK Server will catch this and format the top-level JSON-RPC error. + throw potentialProtocolError; + } else { + // 4b. Otherwise, it's a Tool Execution Error. + console.error(`Tool Execution Error [${toolName}]:`, error); // Log the original error for debugging + const normalized = normalizeError(error); // Get standardized message/details + + // Format and RETURN the error within the 'result' field structure. + return { + content: [{ type: "text", text: `Tool execution failed: ${normalized.message}` }], + isError: true // Mark as an execution error as per MCP spec + }; + } } } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 393bf0a..7f47039 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,16 +32,7 @@ export const VALID_STATUS_TRANSITIONS = { 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' -} - +// Error Codes (kept for internal use and logging) export enum ErrorCode { // Validation Errors (1000-1999) MissingParameter = 'ERR_1000', @@ -75,14 +66,6 @@ export enum ErrorCode { 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; @@ -91,7 +74,7 @@ export interface ProjectCreationSuccessData { message: string; } -// --- NEW Success Data Interfaces --- +// --- Success Data Interfaces --- export interface ApproveTaskSuccessData { projectId: string; @@ -146,36 +129,3 @@ export interface ReadProjectSuccessData { 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/utils/errors.ts b/src/utils/errors.ts index f22acde..6b242c0 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,92 +1,52 @@ -import { ErrorCategory, ErrorCode, StandardError, SuccessResponse } from '../types/index.js'; +import { ErrorCode } from '../types/index.js'; +import { McpError } from '@modelcontextprotocol/sdk/types.js'; /** - * Creates a standardized error object + * Normalizes any error into a consistent format for Tool Execution Errors. + * This is primarily used for formatting isError:true responses. */ -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; +export function normalizeError(error: unknown): McpError { + if (error instanceof Error) { + const err = error as any; // Allow access to potential custom props + // Use JsonRpcErrorCode for McpError, but keep original code for internal use + const mcpCode = err.jsonRpcCode || JsonRpcErrorCode.ServerError; // Default to ServerError if no JSON-RPC code + const message = err.jsonRpcCode ? err.message : // Keep original message for JSON-RPC errors if logging + err.code ? err.message.replace(`[${err.code}] `, '') : err.message; // Clean internal code prefix + return new McpError( + mcpCode, + message, + err.details || { stack: err.stack } // Include stack for debugging + ); + } else { + // Handle strings or other unknowns + return new McpError( + JsonRpcErrorCode.ServerError, + typeof error === 'string' ? error : 'An unknown tool execution error occurred', + { originalError: error } + ); + } } /** - * Converts any error to a StandardError + * Creates an internal error with our custom error codes. + * Use this for TaskManager internal errors that don't need jsonRpcCode. */ -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; +export function createInternalError(code: ErrorCode, message: string, details?: unknown): Error { + const error = new Error(`[${code}] ${message}`); + (error as any).code = code; // Internal code, NOT jsonRpcCode + if (details) { + (error as any).details = details; } + return error; +} - // 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 +// JSON-RPC Error Codes +export const JsonRpcErrorCode = { + ParseError: -32700, + InvalidRequest: -32600, + MethodNotFound: -32601, + InvalidParams: -32602, + InternalError: -32603, + // -32000 to -32099 is reserved for implementation-defined server errors + ServerError: -32000 +} as const; \ No newline at end of file diff --git a/tests/integration/cli.integration.test.ts b/tests/cli/cli.integration.test.ts similarity index 100% rename from tests/integration/cli.integration.test.ts rename to tests/cli/cli.integration.test.ts 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..e27c258 --- /dev/null +++ b/tests/mcp/e2e.integration.test.ts @@ -0,0 +1,117 @@ +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(); + +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'); + }); +}); \ 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..7aaeb70 --- /dev/null +++ b/tests/mcp/test-helpers.ts @@ -0,0 +1,287 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { Task, Project, TaskManagerFile } from "../../src/types/index.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(); + +// MCP Response Types +export interface ToolResponse { + isError: boolean; + content: Array<{ type: string; text: string }>; +} + +export interface TestContext { + client: Client; + transport: StdioClientTransport; + tempDir: string; + testFilePath: string; +} + +/** + * Sets up a test context with MCP client, transport, and temp directory + */ +export async function setupTestContext(): 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 = path.join(tempDir, 'test-tasks.json'); + + // Initialize empty task manager file + await writeTaskManagerFile(testFilePath, { projects: [] }); + + 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 + const 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 + const 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; + } + + return { client, transport, tempDir, testFilePath }; +} + +/** + * Cleans up test context by closing transport and removing temp directory + */ +export async function teardownTestContext(context: TestContext) { + try { + console.log('Cleaning up...'); + // Ensure transport is properly closed + if (context.transport) { + context.transport.close(); + console.log('Transport closed'); + } + } catch (err) { + console.error('Error closing transport:', err); + } + + // Clean up temp files + try { + await fs.rm(context.tempDir, { recursive: true, force: true }); + console.log('Temp directory cleaned up'); + } catch (err) { + console.error('Error cleaning up temp directory:', err); + } +} + +/** + * Verifies that a tool response matches the MCP spec format + */ +export function verifyToolResponse(response: ToolResponse) { + 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)/); + } +} + +/** + * 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); +} + +/** + * 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 ToolResponse; + + verifyToolResponse(createResult); + expect(createResult.isError).toBeFalsy(); + + const responseData = JSON.parse((createResult.content[0] as { text: string }).text); + return responseData.data.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 ToolResponse; + + verifyToolResponse(nextTaskResult); + expect(nextTaskResult.isError).toBeFalsy(); + + const nextTask = JSON.parse((nextTaskResult.content[0] as { text: string }).text); + return nextTask.data.task.id; +} + +/** + * Reads and parses the task manager file + */ +export async function readTaskManagerFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + if ((error as any).code === 'ENOENT') { + return { projects: [] }; + } + throw error; + } +} + +/** + * Writes data to the task manager file + */ +export async function writeTaskManagerFile(filePath: string, data: TaskManagerFile): Promise { + await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); +} + +/** + * 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`); + } + + const newTask: Task = { + id: `task-${Date.now()}`, + 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/approve-task.test.ts b/tests/mcp/tools/approve-task.test.ts new file mode 100644 index 0000000..4f09737 --- /dev/null +++ b/tests/mcp/tools/approve-task.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolResponse, + createTestProjectInFile, + createTestTaskInFile, + verifyTaskInFile, + TestContext, + ToolResponse +} from '../test-helpers.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 ToolResponse; + + // Verify response + verifyToolResponse(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 ToolResponse; + + verifyToolResponse(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 and approve multiple tasks + const tasks = await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 1", + status: "done", + completedDetails: "First task done" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 2", + status: "done", + completedDetails: "Second task done" + }) + ]); + + // 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 ToolResponse; + + verifyToolResponse(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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Cannot approve incomplete task'); + }); + + 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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..c9d0e9d --- /dev/null +++ b/tests/mcp/tools/create-project.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolResponse, + verifyProjectInFile, + verifyTaskInFile, + readTaskManagerFile, + TestContext, + ToolResponse +} from '../test-helpers.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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data).toHaveProperty('projectId'); + const projectId = responseData.data.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.data.tasks[0].id, { + title: "Task 1", + description: "First test task", + status: "not started", + approved: false + }); + }); + + 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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.data.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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.data.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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.data.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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + const projectId = responseData.data.projectId; + const taskId = responseData.data.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 () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + // Missing initialPrompt and tasks + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Missing required parameter'); + }); + + it('should return error for empty tasks array', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Empty Project", + tasks: [] + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Project must have at least one task'); + }); + + it('should return error for invalid task data', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Invalid Task Project", + tasks: [ + { title: "Task 1" } // Missing required description + ] + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Missing required task parameter: description'); + }); + + it('should return error for duplicate task titles', async () => { + const result = await context.client.callTool({ + name: "create_project", + arguments: { + initialPrompt: "Duplicate Tasks Project", + tasks: [ + { title: "Same Title", description: "First task" }, + { title: "Same Title", description: "Second task" } + ] + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Duplicate task title'); + }); + }); +}); \ 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..2b2822e --- /dev/null +++ b/tests/mcp/tools/finalize-project.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolResponse, + createTestProjectInFile, + createTestTaskInFile, + verifyProjectInFile, + TestContext, + ToolResponse +} from '../test-helpers.js'; +import { McpError } 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 ToolResponse; + + // Verify response + verifyToolResponse(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 ToolResponse; + + verifyToolResponse(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, { + initialPrompt: "Incomplete Project" + }); + + // Add mix of complete and incomplete tasks + await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Done Task", + description: "Completed task", + status: "done", + approved: true, + completedDetails: "This task is done" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Pending Task", + description: "Not done yet", + status: "not started" + }) + ]); + + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Cannot finalize project: not all tasks are completed'); + + // 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, { + initialPrompt: "Unapproved Tasks Project" + }); + + // Add completed but unapproved tasks + await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Unapproved Task 1", + description: "Done but not approved", + status: "done", + approved: false, + completedDetails: "Needs approval" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Unapproved Task 2", + description: "Also done but not approved", + status: "done", + approved: false, + completedDetails: "Also needs approval" + }) + ]); + + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Cannot finalize project: not all 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, { + initialPrompt: "Already Completed Project", + completed: true + }); + + // Add completed and approved tasks + await createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Done Task", + description: "Already done", + status: "done", + approved: true, + completedDetails: "Completed in the past" + }); + + const result = await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: project.projectId + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Project is already completed'); + }); + + it('should return error for non-existent project', async () => { + try { + await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: "non_existent_project" + } + }); + fail('Expected error was not thrown'); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + const mcpError = error as McpError; + expect(mcpError.code).toBe(-32602); // Invalid params error code + expect(mcpError.message).toContain('Project non_existent_project not found'); + } + }); + + it('should return error for invalid project ID format', async () => { + try { + await context.client.callTool({ + name: "finalize_project", + arguments: { + projectId: "invalid-format" + } + }); + fail('Expected error was not thrown'); + } catch (error) { + expect(error).toBeInstanceOf(McpError); + const mcpError = error as McpError; + expect(mcpError.code).toBe(-32602); // Invalid params error code + expect(mcpError.message).toContain('Invalid project ID format'); + } + }); + }); +}); \ 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..207fd82 --- /dev/null +++ b/tests/mcp/tools/generate-project-plan.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolResponse, + TestContext, + ToolResponse +} from '../test-helpers.js'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; + +describe('generate_project_plan Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('OpenAI Provider', () => { + // Skip by default as it requires OpenAI API key + it.skip('should generate a project plan using OpenAI', async () => { + // 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(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-4-turbo", + attachments: [requirementsPath] + } + }) as ToolResponse; + + verifyToolResponse(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.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'); + }); + + it('should handle OpenAI API errors gracefully', async () => { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "openai", + model: "gpt-4-turbo", + // Invalid/missing API key should cause an error + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Error: (Authentication|API key)/i); + }); + }); + + describe('Google Provider', () => { + // Skip by default as it requires Google API key + it.skip('should generate a project plan using Google Gemini', async () => { + // 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(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-1.5-flash-latest", + attachments: [requirementsPath] + } + }) as ToolResponse; + + verifyToolResponse(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.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('GeminiTask'); + expect(firstTask.description).toBe('GeminiDescription'); + }); + + it('should handle Google API errors gracefully', async () => { + 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Error: (Authentication|API key)/i); + }); + }); + + describe('Error Cases', () => { + it('should return error for invalid provider', async () => { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "invalid_provider", + model: "some-model" + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Invalid provider'); + }); + + it('should return error for invalid model', async () => { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "openai", + model: "invalid-model" + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Error: (Invalid model|Model not found)/i); + }); + + it('should return error for non-existent attachment file', async () => { + const result = await context.client.callTool({ + name: "generate_project_plan", + arguments: { + prompt: "Test prompt", + provider: "openai", + model: "gpt-4-turbo", + attachments: ["/non/existent/file.md"] + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Error: (File not found|Cannot read file)/i); + }); + }); +}); \ 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..dcced19 --- /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, + verifyToolResponse, + createTestProjectInFile, + createTestTaskInFile, + TestContext, + ToolResponse +} from '../test-helpers.js'; + +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" + }); + const tasks = await Promise.all([ + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 1", + description: "First task", + status: "not started" + }), + createTestTaskInFile(context.testFilePath, project.projectId, { + title: "Task 2", + description: "Second task", + status: "not started" + }) + ]); + + // Get next task + const result = await context.client.callTool({ + name: "get_next_task", + arguments: { + projectId: project.projectId + } + }) as ToolResponse; + + // Verify response + verifyToolResponse(result); + expect(result.isError).toBeFalsy(); + + // Verify task data + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data.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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data.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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data.task).toMatchObject({ + id: inProgressTask.id, + title: "Current Task", + status: "in progress" + }); + }); + + it('should return null 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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data.task).toBeNull(); + }); + }); + + 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Invalid project ID format'); + }); + + 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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..c7260e6 --- /dev/null +++ b/tests/mcp/tools/list-projects.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolResponse, + verifyProtocolError, + createTestProject, + getFirstTaskId, + TestContext, + ToolResponse +} from '../test-helpers.js'; + +describe('list_projects Tool', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + 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 ToolResponse; + + // Verify response format + verifyToolResponse(result); + expect(result.isError).toBeFalsy(); + + // Parse and verify response data + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData).toHaveProperty('data'); + expect(responseData.data).toHaveProperty('projects'); + expect(Array.isArray(responseData.data.projects)).toBe(true); + + // Verify our test project is in the list + const projects = responseData.data.projects; + const testProject = projects.find((p: any) => p.projectId === projectId); + expect(testProject).toBeDefined(); + expect(testProject).toHaveProperty('initialPrompt'); + expect(testProject).toHaveProperty('taskCount'); + }); + + 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" + } + }); + + // Test filtering by 'open' state + const openResult = await context.client.callTool({ + name: "list_projects", + arguments: { state: "open" } + }) as ToolResponse; + + verifyToolResponse(openResult); + const openData = JSON.parse((openResult.content[0] as { text: string }).text); + const openProjects = openData.data.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 ToolResponse; + + verifyToolResponse(completedResult); + const completedData = JSON.parse((completedResult.content[0] as { text: string }).text); + const completedProjects = completedData.data.projects; + expect(completedProjects.some((p: any) => p.projectId === completedProjectId)).toBe(true); + expect(completedProjects.some((p: any) => p.projectId === openProjectId)).toBe(false); + }); + }); + + describe('Error Cases', () => { + it('should handle invalid state parameter', async () => { + try { + await context.client.callTool({ + name: "list_projects", + arguments: { state: "invalid_state" } + }); + fail('Expected error was not thrown'); + } catch (error: any) { + verifyProtocolError(error, -32602, "Invalid parameter: state"); + } + }); + + it('should handle server errors gracefully', async () => { + // Simulate a server error by using an invalid file path + const transport = context.transport as any; + transport.env = { + ...transport.env, + TASK_MANAGER_FILE_PATH: '/invalid/path/that/does/not/exist' + }; + + const result = await context.client.callTool({ + name: "list_projects", + arguments: {} + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Error: (ENOENT|Failed to read)/); + + // Reset the file path + transport.env.TASK_MANAGER_FILE_PATH = context.testFilePath; + }); + }); +}); \ 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..1fe35e8 --- /dev/null +++ b/tests/mcp/tools/read-project.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolResponse, + createTestProjectInFile, + createTestTaskInFile, + TestContext, + ToolResponse +} from '../test-helpers.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 ToolResponse; + + // Verify response + verifyToolResponse(result); + expect(result.isError).toBeFalsy(); + + // Verify project data + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data).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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data).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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data).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 ToolResponse; + + verifyToolResponse(result); + const responseData = JSON.parse((result.content[0] as { text: string }).text); + expect(responseData.data.tasks).toHaveLength(3); + expect(responseData.data.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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Invalid project ID format'); + }); + }); +}); \ 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..528880a --- /dev/null +++ b/tests/mcp/tools/update-task.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { + setupTestContext, + teardownTestContext, + verifyToolResponse, + createTestProjectInFile, + createTestTaskInFile, + verifyTaskInFile, + TestContext, + ToolResponse +} 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 ToolResponse; + + // Verify response + verifyToolResponse(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 ToolResponse; + + verifyToolResponse(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 ToolResponse; + + verifyToolResponse(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" + }); + + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + status: "invalid_status" // Invalid status value + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Invalid status: must be one of'); + }); + + 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" + }); + + const result = await context.client.callTool({ + name: "update_task", + arguments: { + projectId: project.projectId, + taskId: task.id, + status: "done" + // Missing required completedDetails + } + }) as ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Missing or invalid required parameter: completedDetails'); + }); + + 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: 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 ToolResponse; + + verifyToolResponse(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Error: Cannot modify 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..d55f8ca 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", + "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, From 9b4a563f7b36b570be80b226057cc0b22bc6c54d Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 31 Mar 2025 15:21:02 -0400 Subject: [PATCH 2/8] Refined test setup --- .cursor/rules/errors.mdc | 93 ++++++++++++++++ package-lock.json | 80 +++++++------- package.json | 20 ++-- src/server/TaskManager.ts | 12 +-- index.ts => src/server/index.ts | 4 +- tests/cli/cli.integration.test.ts | 4 - tests/mcp/e2e.integration.test.ts | 101 ++---------------- tests/mcp/test-helpers.ts | 64 ++++++----- tests/mcp/tools/add-tasks-to-project.test.ts | 22 ++++ tests/mcp/tools/approve-task.test.ts | 34 +++--- tests/mcp/tools/create-project.test.ts | 42 ++++---- tests/mcp/tools/create-task.test.ts | 22 ++++ tests/mcp/tools/delete-project.test.ts | 22 ++++ tests/mcp/tools/delete-task.test.ts | 22 ++++ tests/mcp/tools/finalize-project.test.ts | 27 +++-- tests/mcp/tools/generate-project-plan.test.ts | 38 +++---- tests/mcp/tools/get-next-task.test.ts | 58 +++++----- tests/mcp/tools/list-projects.test.ts | 23 ++-- tests/mcp/tools/list-tasks.test.ts | 22 ++++ tests/mcp/tools/read-project.test.ts | 30 +++--- tests/mcp/tools/read-task.test.ts | 22 ++++ tests/mcp/tools/update-task.test.ts | 38 +++---- tsconfig.json | 4 +- 23 files changed, 469 insertions(+), 335 deletions(-) create mode 100644 .cursor/rules/errors.mdc rename index.ts => src/server/index.ts (91%) create mode 100644 tests/mcp/tools/add-tasks-to-project.test.ts create mode 100644 tests/mcp/tools/create-task.test.ts create mode 100644 tests/mcp/tools/delete-project.test.ts create mode 100644 tests/mcp/tools/delete-task.test.ts create mode 100644 tests/mcp/tools/list-tasks.test.ts create mode 100644 tests/mcp/tools/read-task.test.ts diff --git a/.cursor/rules/errors.mdc b/.cursor/rules/errors.mdc new file mode 100644 index 0000000..d436231 --- /dev/null +++ b/.cursor/rules/errors.mdc @@ -0,0 +1,93 @@ +--- +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 -->|Untagged App Error| CLI_Handler["cli.ts Command Handler"] + TM -->|Untagged App Error| ToolExec["toolExecutors.ts: execute"] + end + + subgraph CLI_Path + CLI_Handler -->|Untagged App Error| CLI_Catch["cli.ts catch block"] + CLI_Catch -->|Error Object| FormatCLI["client errors.ts formatCliError"] + FormatCLI -->|Formatted String| ConsoleOut["console.error Output"] + end + + subgraph MCP_Server_Path + subgraph Validation + ToolExecVal["toolExecutors.ts Validation"] -->|Throws Tagged Protocol Error jsonRpcCode -32602| ExecToolErrHandler + end + + subgraph Execution + ToolExec -->|Untagged App Error| ExecToolErrHandler["tools.ts executeToolAndHandleErrors catch block"] + end + + ExecToolErrHandler -->|Error Object| CheckTag["Check if error has jsonRpcCode"] + CheckTag -- Tagged Error --> ReThrow["Re-throw Tagged Error"] + CheckTag -- Untagged App Error --> NormalizeErr["utils errors.ts normalizeError"] + NormalizeErr -->|McpError Object code -32000| FormatResult["Format as isError true result"] + FormatResult -->|content list with isError true| SDKHandler["server index.ts SDK Handler"] + + ReThrow -->|Tagged Protocol Error| SDKHandler + SDKHandler -- Tagged Error --> SDKFormatError["SDK Formats Top-Level Error"] + SDKHandler -- isError true Result --> SDKFormatResult["SDK Formats Result Field"] + + SDKFormatError -->|JSON-RPC Error Response| MCPClient["MCP Client"] + SDKFormatResult -->|JSON-RPC Success Response with error details| MCPClient + end +``` + +**Explanation of Error Flow and Transformations:** + +Errors primarily originate from two places: + +1. **Core Logic (`TaskManager`, `FileSystemService`):** These modules throw standard JavaScript `Error` objects, often subclassed (e.g., `ProjectNotFoundError`, `FileReadError`) but *without* any special MCP/JSON-RPC tagging (`jsonRpcCode`). These represent application-specific or file system problems. +2. **Tool Executors (`toolExecutors.ts`) Validation:** Before calling `TaskManager`, the executors validate input arguments. If validation fails, they create a *new* `Error` object and explicitly *tag* it with `jsonRpcCode = -32602` (Invalid Params). + +The handling differs significantly between the CLI and the MCP Server: + +**1. CLI Error Path (`cli.ts`)** + +1. **Origination:** An untagged application error (e.g., `ProjectNotFoundError`) is thrown by `TaskManager`. +2. **Propagation:** The error propagates directly up the call stack to the `catch` block within the specific command's action handler in `cli.ts`. +3. **Transformation (`formatCliError`):** + * The `catch` block calls `formatCliError` from `src/client/errors.ts`. + * `formatCliError` takes the raw `Error` object. + * It checks the error's `name` (e.g., 'ReadOnlyFileSystemError', 'FileReadError') to provide specific user-friendly messages for known file system issues. + * For other errors, it checks if the error has a `.code` property (like the internal `ErrorCode` enum values, e.g., 'ERR_2000') and prepends it to the error message. + * **Shape Change:** `Error` object -> Formatted `string` suitable for console output. +4. **Output:** The formatted string is printed to `console.error`. + +**2. MCP Server Error Path (`server/index.ts` via `tools.ts`)** + +1. **Origination:** + * **Validation Error:** A *tagged* protocol error (`jsonRpcCode = -32602`) is thrown by `toolExecutors.ts` validation. + * **Execution Error:** An *untagged* application error (e.g., `TaskNotDone`) is thrown by `TaskManager`. +2. **Catching (`executeToolAndHandleErrors`):** Both types of errors are caught by the `try...catch` block in `executeToolAndHandleErrors` within `src/server/tools.ts`. +3. **Branching & Transformation:** + * **If Tagged Protocol Error:** `executeToolAndHandleErrors` detects the `jsonRpcCode` property and *re-throws* the error unchanged. + * **If Untagged App Error:** + * `executeToolAndHandleErrors` calls `normalizeError` from `src/utils/errors.ts`. + * `normalizeError` takes the raw `Error` object. + * It converts the error into an `McpError` object, typically assigning the `code` to `-32000` (Server Error - a generic JSON-RPC code for implementation-defined errors). It preserves the original error message (stripping any internal `[ERR_CODE]` prefix) and potentially includes the stack trace in the `data` field for debugging. + * **Shape Change:** Raw `Error` object -> Standardized `McpError` object (with JSON-RPC code). + * `executeToolAndHandleErrors` then formats this `McpError` into the MCP `isError: true` structure: `{ content: [{ type: "text", text: "Tool execution failed: " }], isError: true }`. + * **Shape Change:** `McpError` object -> MCP Tool Result `object` with `isError: true`. +4. **SDK Handling (`@modelcontextprotocol/sdk Server`):** The MCP SDK `Server` instance (used in `server/index.ts`) handles the outcome from `executeToolAndHandleErrors`: + * **If Error was Re-thrown (Tagged Protocol Error):** The SDK catches the *thrown* error. It automatically formats this into a standard JSON-RPC top-level error response (e.g., `{"jsonrpc": "2.0", "error": {"code": -32602, "message": "...", "data": ...}, "id": ...}`). + * **If `isError: true` Object was Returned (Normalized App Error):** The SDK receives the *returned* object. It treats this as a *successful* tool execution from a protocol perspective, but one where the tool itself reported an error. It formats a standard JSON-RPC success response, placing the `isError: true` object inside the `result` field (e.g., `{"jsonrpc": "2.0", "result": {"content": [...], "isError": true}, "id": ...}`). +5. **Output:** The final JSON-RPC response (either an error response or a success response containing an `isError` result) is sent to the connected MCP Client. + +**Key Functions Changing Error Shapes:** + +1. **`toolExecutors.ts` (Validation Logic):** Creates *new* `Error` objects and *tags* them with `jsonRpcCode`. (Raw Error -> Tagged Error) +2. **`normalizeError` (`src/utils/errors.ts`):** Standardizes various error inputs into `McpError` objects, often using the generic `-32000` code for application errors. (Raw Error -> McpError) +3. **`executeToolAndHandleErrors` (`src/server/tools.ts`):** Packages the `McpError` from `normalizeError` into the MCP-specific `{ content: [...], isError: true }` return format. (McpError -> MCP Result Object) +4. **`formatCliError` (`src/client/errors.ts`):** Converts `Error` objects into user-friendly `string` messages for the CLI. (Error -> String) +5. **`@modelcontextprotocol/sdk Server`:** Formats *thrown*, tagged errors into the top-level JSON-RPC `error` object and *returned* `isError: true` results into the JSON-RPC `result` object. (Tagged Error -> JSON-RPC Error / MCP Result Object -> JSON-RPC Result) diff --git a/package-lock.json b/package-lock.json index 3c45a56..93881f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,11 @@ "version": "1.3.4", "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,7 +23,7 @@ }, "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", @@ -42,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" @@ -59,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" @@ -75,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" @@ -91,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" @@ -119,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", @@ -136,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" }, @@ -160,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": { @@ -2885,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" }, diff --git a/package.json b/package.json index 32cdab0..9070209 100644 --- a/package.json +++ b/package.json @@ -3,25 +3,23 @@ "version": "1.3.4", "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", + "start": "node dist/src/server/index.js", + "dev": "tsc && node dist/src/server/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" + "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", diff --git a/src/server/TaskManager.ts b/src/server/TaskManager.ts index 6d992a2..f6ea05b 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -286,16 +286,16 @@ export class TaskManager { throw new ProjectAlreadyCompletedError(); } - const nextTask = proj.tasks.find((t) => t.status !== "done"); + 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 { - message: `All tasks have been completed. Awaiting project completion approval.` + message: `All tasks have been completed and approved. Awaiting project completion approval.` }; } - throw new TaskNotFoundError("No undone tasks found"); + throw new TaskNotFoundError("No incomplete or unapproved tasks found"); } return { diff --git a/index.ts b/src/server/index.ts similarity index 91% rename from index.ts rename to src/server/index.ts index dcef162..9baa30b 100644 --- a/index.ts +++ b/src/server/index.ts @@ -2,8 +2,8 @@ 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, executeToolAndHandleErrors } 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 diff --git a/tests/cli/cli.integration.test.ts b/tests/cli/cli.integration.test.ts index 083d4e6..36204e1 100644 --- a/tests/cli/cli.integration.test.ts +++ b/tests/cli/cli.integration.test.ts @@ -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/mcp/e2e.integration.test.ts b/tests/mcp/e2e.integration.test.ts index e27c258..0816529 100644 --- a/tests/mcp/e2e.integration.test.ts +++ b/tests/mcp/e2e.integration.test.ts @@ -1,106 +1,20 @@ -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(); +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 client: Client; - let transport: StdioClientTransport; - let tempDir: string; - let testFilePath: string; + let context: TestContext; 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; - } + context = await setupTestContext(); }); 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); - } + await teardownTestContext(context); }); it('should list available tools', async () => { - console.log('Testing tool listing...'); - const response = await client.listTools(); + const response = await context.client.listTools(); expect(response).toBeDefined(); expect(response).toHaveProperty('tools'); expect(Array.isArray(response.tools)).toBe(true); @@ -108,7 +22,6 @@ describe('MCP Client Integration', () => { // 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'); diff --git a/tests/mcp/test-helpers.ts b/tests/mcp/test-helpers.ts index 7aaeb70..81322de 100644 --- a/tests/mcp/test-helpers.ts +++ b/tests/mcp/test-helpers.ts @@ -1,5 +1,6 @@ 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/index.js"; import * as path from 'node:path'; import * as os from 'node:os'; @@ -10,17 +11,12 @@ import dotenv from 'dotenv'; // Load environment variables from .env file dotenv.config(); -// MCP Response Types -export interface ToolResponse { - isError: boolean; - content: Array<{ type: string; text: string }>; -} - export interface TestContext { client: Client; transport: StdioClientTransport; tempDir: string; testFilePath: string; + taskCounter: number; } /** @@ -35,14 +31,10 @@ export async function setupTestContext(): Promise { // Initialize empty task manager file await writeTaskManagerFile(testFilePath, { projects: [] }); - 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 const transport = new StdioClientTransport({ command: process.execPath, // Use full path to current Node.js executable - args: ["dist/index.js"], + args: ["dist/src/server/index.js"], env: { TASK_MANAGER_FILE_PATH: testFilePath, NODE_ENV: "test", @@ -53,8 +45,6 @@ export async function setupTestContext(): Promise { } }); - console.log('Created transport with command:', process.execPath, 'dist/index.js'); - // Set up the client const client = new Client( { @@ -72,7 +62,6 @@ export async function setupTestContext(): Promise { ); 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) => { @@ -80,16 +69,14 @@ export async function setupTestContext(): Promise { }); 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; } - return { client, transport, tempDir, testFilePath }; + return { client, transport, tempDir, testFilePath, taskCounter: 0 }; } /** @@ -97,11 +84,9 @@ export async function setupTestContext(): Promise { */ export async function teardownTestContext(context: TestContext) { try { - console.log('Cleaning up...'); // Ensure transport is properly closed if (context.transport) { context.transport.close(); - console.log('Transport closed'); } } catch (err) { console.error('Error closing transport:', err); @@ -110,7 +95,6 @@ export async function teardownTestContext(context: TestContext) { // Clean up temp files try { await fs.rm(context.tempDir, { recursive: true, force: true }); - console.log('Temp directory cleaned up'); } catch (err) { console.error('Error cleaning up temp directory:', err); } @@ -119,7 +103,7 @@ export async function teardownTestContext(context: TestContext) { /** * Verifies that a tool response matches the MCP spec format */ -export function verifyToolResponse(response: ToolResponse) { +export function verifyCallToolResult(response: CallToolResult) { expect(response).toBeDefined(); expect(response).toHaveProperty('content'); expect(Array.isArray(response.content)).toBe(true); @@ -148,6 +132,28 @@ export function verifyProtocolError(error: any, expectedCode: number, expectedMe 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): { data: 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 */ @@ -165,9 +171,9 @@ export async function createTestProject(client: Client, options: { ], autoApprove: options.autoApprove } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(createResult); + verifyCallToolResult(createResult); expect(createResult.isError).toBeFalsy(); const responseData = JSON.parse((createResult.content[0] as { text: string }).text); @@ -181,9 +187,9 @@ export async function getFirstTaskId(client: Client, projectId: string): Promise const nextTaskResult = await client.callTool({ name: "get_next_task", arguments: { projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(nextTaskResult); + verifyCallToolResult(nextTaskResult); expect(nextTaskResult.isError).toBeFalsy(); const nextTask = JSON.parse((nextTaskResult.content[0] as { text: string }).text); @@ -269,8 +275,14 @@ export async function createTestTaskInFile(filePath: string, projectId: string, 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-${Date.now()}`, + id: `task-${maxTaskId + 1}`, // Use incrementing number instead of timestamp title: "Test Task", description: "Test Description", status: "not started", 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..185fd1d --- /dev/null +++ b/tests/mcp/tools/add-tasks-to-project.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; + +describe('add_tasks_to_project Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + // TODO: Add success test cases + }); + + describe('Error Cases', () => { + // TODO: Add error test cases + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/approve-task.test.ts b/tests/mcp/tools/approve-task.test.ts index 4f09737..f13aca8 100644 --- a/tests/mcp/tools/approve-task.test.ts +++ b/tests/mcp/tools/approve-task.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, + verifyCallToolResult, createTestProjectInFile, createTestTaskInFile, verifyTaskInFile, - TestContext, - ToolResponse + TestContext } from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; describe('approve_task Tool', () => { let context: TestContext; @@ -40,10 +40,10 @@ describe('approve_task Tool', () => { projectId: project.projectId, taskId: task.id } - }) as ToolResponse; + }) as CallToolResult; // Verify response - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); // Verify task was approved in file @@ -72,9 +72,9 @@ describe('approve_task Tool', () => { projectId: project.projectId, taskId: task.id } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); // Verify task was auto-approved @@ -111,9 +111,9 @@ describe('approve_task Tool', () => { projectId: project.projectId, taskId: task.id } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { @@ -131,9 +131,9 @@ describe('approve_task Tool', () => { projectId: "non_existent_project", taskId: "task-1" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Project non_existent_project not found'); }); @@ -149,9 +149,9 @@ describe('approve_task Tool', () => { projectId: project.projectId, taskId: "non_existent_task" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Task non_existent_task not found'); }); @@ -171,9 +171,9 @@ describe('approve_task Tool', () => { projectId: project.projectId, taskId: task.id } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Cannot approve incomplete task'); }); @@ -195,9 +195,9 @@ describe('approve_task Tool', () => { projectId: project.projectId, taskId: task.id } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Task is already approved'); }); diff --git a/tests/mcp/tools/create-project.test.ts b/tests/mcp/tools/create-project.test.ts index c9d0e9d..52a80f1 100644 --- a/tests/mcp/tools/create-project.test.ts +++ b/tests/mcp/tools/create-project.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, + verifyCallToolResult, verifyProjectInFile, verifyTaskInFile, readTaskManagerFile, - TestContext, - ToolResponse + TestContext } from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; describe('create_project Tool', () => { let context: TestContext; @@ -31,9 +31,9 @@ describe('create_project Tool', () => { { title: "Task 1", description: "First test task" } ] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); // Parse and verify response @@ -67,9 +67,9 @@ describe('create_project Tool', () => { { title: "Task 3", description: "Third task" } ] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); const projectId = responseData.data.projectId; @@ -94,9 +94,9 @@ describe('create_project Tool', () => { ], autoApprove: true } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); const projectId = responseData.data.projectId; @@ -116,9 +116,9 @@ describe('create_project Tool', () => { { title: "Planned Task", description: "Task with a plan" } ] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); const projectId = responseData.data.projectId; @@ -140,9 +140,9 @@ describe('create_project Tool', () => { ruleRecommendations: "Follow rules A and B" }] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); const projectId = responseData.data.projectId; const taskId = responseData.data.tasks[0].id; @@ -161,9 +161,9 @@ describe('create_project Tool', () => { arguments: { // Missing initialPrompt and tasks } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Missing required parameter'); }); @@ -175,9 +175,9 @@ describe('create_project Tool', () => { initialPrompt: "Empty Project", tasks: [] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Project must have at least one task'); }); @@ -191,9 +191,9 @@ describe('create_project Tool', () => { { title: "Task 1" } // Missing required description ] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Missing required task parameter: description'); }); @@ -208,9 +208,9 @@ describe('create_project Tool', () => { { title: "Same Title", description: "Second task" } ] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Duplicate task title'); }); diff --git a/tests/mcp/tools/create-task.test.ts b/tests/mcp/tools/create-task.test.ts new file mode 100644 index 0000000..3fc2faa --- /dev/null +++ b/tests/mcp/tools/create-task.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; + +describe('create_task Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + // TODO: Add success test cases + }); + + describe('Error Cases', () => { + // TODO: Add error test cases + }); +}); \ 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..75e1e97 --- /dev/null +++ b/tests/mcp/tools/delete-project.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; + +describe('delete_project Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + // TODO: Add success test cases + }); + + describe('Error Cases', () => { + // TODO: Add error test cases + }); +}); \ 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..1d31f33 --- /dev/null +++ b/tests/mcp/tools/delete-task.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; + +describe('delete_task Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + // TODO: Add success test cases + }); + + describe('Error Cases', () => { + // TODO: Add error test cases + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/finalize-project.test.ts b/tests/mcp/tools/finalize-project.test.ts index 2b2822e..16031ee 100644 --- a/tests/mcp/tools/finalize-project.test.ts +++ b/tests/mcp/tools/finalize-project.test.ts @@ -2,15 +2,14 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, + verifyCallToolResult, createTestProjectInFile, createTestTaskInFile, verifyProjectInFile, - TestContext, - ToolResponse + TestContext } from '../test-helpers.js'; import { McpError } from '@modelcontextprotocol/sdk/types.js'; - +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; describe('finalize_project Tool', () => { let context: TestContext; @@ -54,10 +53,10 @@ describe('finalize_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; // Verify response - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); // Verify project state in file @@ -97,9 +96,9 @@ describe('finalize_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); await verifyProjectInFile(context.testFilePath, project.projectId, { @@ -136,9 +135,9 @@ describe('finalize_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Cannot finalize project: not all tasks are completed'); @@ -176,9 +175,9 @@ describe('finalize_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Cannot finalize project: not all tasks are approved'); @@ -207,9 +206,9 @@ describe('finalize_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Project is already completed'); }); diff --git a/tests/mcp/tools/generate-project-plan.test.ts b/tests/mcp/tools/generate-project-plan.test.ts index 207fd82..bc92906 100644 --- a/tests/mcp/tools/generate-project-plan.test.ts +++ b/tests/mcp/tools/generate-project-plan.test.ts @@ -2,10 +2,10 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, - TestContext, - ToolResponse + verifyCallToolResult, + TestContext } from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; @@ -26,7 +26,7 @@ describe('generate_project_plan Tool', () => { // 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'); + console.error('Skipping test: OPENAI_API_KEY not set'); return; } @@ -54,9 +54,9 @@ describe('generate_project_plan Tool', () => { model: "gpt-4-turbo", attachments: [requirementsPath] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); const planData = JSON.parse((result.content[0] as { text: string }).text); @@ -86,9 +86,9 @@ describe('generate_project_plan Tool', () => { model: "gpt-4-turbo", // Invalid/missing API key should cause an error } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/Error: (Authentication|API key)/i); }); @@ -100,7 +100,7 @@ describe('generate_project_plan Tool', () => { // 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'); + console.error('Skipping test: GEMINI_API_KEY not set'); return; } @@ -128,9 +128,9 @@ describe('generate_project_plan Tool', () => { model: "gemini-1.5-flash-latest", attachments: [requirementsPath] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); const planData = JSON.parse((result.content[0] as { text: string }).text); @@ -160,9 +160,9 @@ describe('generate_project_plan Tool', () => { model: "gemini-1.5-flash-latest", // Invalid/missing API key should cause an error } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/Error: (Authentication|API key)/i); }); @@ -177,9 +177,9 @@ describe('generate_project_plan Tool', () => { provider: "invalid_provider", model: "some-model" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Invalid provider'); }); @@ -192,9 +192,9 @@ describe('generate_project_plan Tool', () => { provider: "openai", model: "invalid-model" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/Error: (Invalid model|Model not found)/i); }); @@ -208,9 +208,9 @@ describe('generate_project_plan Tool', () => { model: "gpt-4-turbo", attachments: ["/non/existent/file.md"] } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/Error: (File not found|Cannot read file)/i); }); diff --git a/tests/mcp/tools/get-next-task.test.ts b/tests/mcp/tools/get-next-task.test.ts index dcced19..fd6c1bc 100644 --- a/tests/mcp/tools/get-next-task.test.ts +++ b/tests/mcp/tools/get-next-task.test.ts @@ -2,12 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, + verifyToolExecutionError, + verifyToolSuccessResponse, createTestProjectInFile, createTestTaskInFile, - TestContext, - ToolResponse + TestContext } from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { Task } from "../../../src/types/index.js"; + +interface GetNextTaskResponse { + task: Task; + projectId: string; +} describe('get_next_task Tool', () => { let context: TestContext; @@ -45,14 +52,9 @@ describe('get_next_task Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; - - // Verify response - verifyToolResponse(result); - expect(result.isError).toBeFalsy(); + }) as CallToolResult; - // Verify task data - const responseData = JSON.parse((result.content[0] as { text: string }).text); + const responseData = verifyToolSuccessResponse(result); expect(responseData.data.task).toMatchObject({ id: tasks[0].id, title: "Task 1", @@ -84,10 +86,9 @@ describe('get_next_task Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); - const responseData = JSON.parse((result.content[0] as { text: string }).text); + const responseData = verifyToolSuccessResponse(result); expect(responseData.data.task).toMatchObject({ id: nextTask.id, title: "Next Task", @@ -124,10 +125,9 @@ describe('get_next_task Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); - const responseData = JSON.parse((result.content[0] as { text: string }).text); + const responseData = verifyToolSuccessResponse(result); expect(responseData.data.task).toMatchObject({ id: inProgressTask.id, title: "Current Task", @@ -135,7 +135,7 @@ describe('get_next_task Tool', () => { }); }); - it('should return null when all tasks are completed', async () => { + it('should return error when all tasks are completed', async () => { const project = await createTestProjectInFile(context.testFilePath, { initialPrompt: "Completed Project", completed: true @@ -164,11 +164,9 @@ describe('get_next_task Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); - const responseData = JSON.parse((result.content[0] as { text: string }).text); - expect(responseData.data.task).toBeNull(); + verifyToolExecutionError(result, /Error: Project is already completed/); }); }); @@ -179,11 +177,9 @@ describe('get_next_task Tool', () => { arguments: { projectId: "non_existent_project" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Project non_existent_project not found'); + verifyToolExecutionError(result, /Error: Project non_existent_project not found/); }); it('should return error for invalid project ID format', async () => { @@ -192,11 +188,9 @@ describe('get_next_task Tool', () => { arguments: { projectId: "invalid-format" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Invalid project ID format'); + verifyToolExecutionError(result, /Error: Invalid project ID format/); }); it('should return error for project with no tasks', async () => { @@ -210,11 +204,9 @@ describe('get_next_task Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Project has no tasks'); + verifyToolExecutionError(result, /Error: 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 index c7260e6..bfa29a2 100644 --- a/tests/mcp/tools/list-projects.test.ts +++ b/tests/mcp/tools/list-projects.test.ts @@ -2,14 +2,13 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, + verifyCallToolResult, verifyProtocolError, createTestProject, getFirstTaskId, - TestContext, - ToolResponse + TestContext } from '../test-helpers.js'; - +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; describe('list_projects Tool', () => { let context: TestContext; @@ -30,10 +29,10 @@ describe('list_projects Tool', () => { const result = await context.client.callTool({ name: "list_projects", arguments: {} - }) as ToolResponse; + }) as CallToolResult; // Verify response format - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); // Parse and verify response data @@ -79,9 +78,9 @@ describe('list_projects Tool', () => { const openResult = await context.client.callTool({ name: "list_projects", arguments: { state: "open" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(openResult); + verifyCallToolResult(openResult); const openData = JSON.parse((openResult.content[0] as { text: string }).text); const openProjects = openData.data.projects; expect(openProjects.some((p: any) => p.projectId === openProjectId)).toBe(true); @@ -91,9 +90,9 @@ describe('list_projects Tool', () => { const completedResult = await context.client.callTool({ name: "list_projects", arguments: { state: "completed" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(completedResult); + verifyCallToolResult(completedResult); const completedData = JSON.parse((completedResult.content[0] as { text: string }).text); const completedProjects = completedData.data.projects; expect(completedProjects.some((p: any) => p.projectId === completedProjectId)).toBe(true); @@ -125,9 +124,9 @@ describe('list_projects Tool', () => { const result = await context.client.callTool({ name: "list_projects", arguments: {} - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toMatch(/Error: (ENOENT|Failed to read)/); diff --git a/tests/mcp/tools/list-tasks.test.ts b/tests/mcp/tools/list-tasks.test.ts new file mode 100644 index 0000000..089d440 --- /dev/null +++ b/tests/mcp/tools/list-tasks.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; + +describe('list_tasks Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + // TODO: Add success test cases + }); + + describe('Error Cases', () => { + // TODO: Add error test cases + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/read-project.test.ts b/tests/mcp/tools/read-project.test.ts index 1fe35e8..3fb719d 100644 --- a/tests/mcp/tools/read-project.test.ts +++ b/tests/mcp/tools/read-project.test.ts @@ -2,12 +2,12 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, + verifyCallToolResult, createTestProjectInFile, createTestTaskInFile, - TestContext, - ToolResponse + TestContext } from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; describe('read_project Tool', () => { let context: TestContext; @@ -39,10 +39,10 @@ describe('read_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; // Verify response - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); // Verify project data @@ -82,9 +82,9 @@ describe('read_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); expect(responseData.data).toMatchObject({ projectId: project.projectId, @@ -122,9 +122,9 @@ describe('read_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); expect(responseData.data).toMatchObject({ projectId: project.projectId, @@ -167,9 +167,9 @@ describe('read_project Tool', () => { arguments: { projectId: project.projectId } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); expect(responseData.data.tasks).toHaveLength(3); expect(responseData.data.tasks.map((t: any) => t.status)).toEqual([ @@ -187,9 +187,9 @@ describe('read_project Tool', () => { arguments: { projectId: "non_existent_project" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Project non_existent_project not found'); }); @@ -200,9 +200,9 @@ describe('read_project Tool', () => { arguments: { projectId: "invalid-format" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Invalid project ID format'); }); diff --git a/tests/mcp/tools/read-task.test.ts b/tests/mcp/tools/read-task.test.ts new file mode 100644 index 0000000..e06a587 --- /dev/null +++ b/tests/mcp/tools/read-task.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; + +describe('read_task Tool', () => { + let context: TestContext; + + beforeEach(async () => { + context = await setupTestContext(); + }); + + afterEach(async () => { + await teardownTestContext(context); + }); + + describe('Success Cases', () => { + // TODO: Add success test cases + }); + + describe('Error Cases', () => { + // TODO: Add error test cases + }); +}); \ No newline at end of file diff --git a/tests/mcp/tools/update-task.test.ts b/tests/mcp/tools/update-task.test.ts index 528880a..b02d801 100644 --- a/tests/mcp/tools/update-task.test.ts +++ b/tests/mcp/tools/update-task.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; import { setupTestContext, teardownTestContext, - verifyToolResponse, + verifyCallToolResult, createTestProjectInFile, createTestTaskInFile, verifyTaskInFile, - TestContext, - ToolResponse + TestContext } from '../test-helpers.js'; +import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; describe('update_task Tool', () => { let context: TestContext; @@ -40,10 +40,10 @@ describe('update_task Tool', () => { taskId: task.id, status: "in progress" } - }) as ToolResponse; + }) as CallToolResult; // Verify response - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); // Verify file was updated @@ -69,9 +69,9 @@ describe('update_task Tool', () => { status: "done", completedDetails: "Task completed in test" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { @@ -97,9 +97,9 @@ describe('update_task Tool', () => { title: "Updated Title", description: "Updated Description" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBeFalsy(); await verifyTaskInFile(context.testFilePath, project.projectId, task.id, { @@ -125,9 +125,9 @@ describe('update_task Tool', () => { taskId: task.id, status: "invalid_status" // Invalid status value } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Invalid status: must be one of'); }); @@ -149,9 +149,9 @@ describe('update_task Tool', () => { status: "done" // Missing required completedDetails } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Missing or invalid required parameter: completedDetails'); }); @@ -164,9 +164,9 @@ describe('update_task Tool', () => { taskId: "task-1", status: "in progress" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Project non_existent_project not found'); }); @@ -183,9 +183,9 @@ describe('update_task Tool', () => { taskId: "non_existent_task", status: "in progress" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Task non_existent_task not found'); }); @@ -208,9 +208,9 @@ describe('update_task Tool', () => { taskId: task.id, title: "New Title" } - }) as ToolResponse; + }) as CallToolResult; - verifyToolResponse(result); + verifyCallToolResult(result); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('Error: Cannot modify approved task'); }); diff --git a/tsconfig.json b/tsconfig.json index d55f8ca..da8d796 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, @@ -12,6 +12,6 @@ "sourceMap": true, "types": ["jest", "node"] }, - "include": ["index.ts", "src/**/*", "tests/**/*"], + "include": ["src/server/index.ts", "src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } From 98b6d2b8a21e27aa17aea43500b701d0470a9bd9 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 31 Mar 2025 19:09:47 -0400 Subject: [PATCH 3/8] AI slop reduction --- .cursor/rules/errors.mdc | 92 ++++------- README.md | 6 +- src/client/cli.ts | 2 +- src/client/errors.ts | 21 +-- src/client/taskFormattingUtils.ts | 3 +- src/server/FileSystemService.ts | 39 +---- src/server/TaskManager.ts | 154 +++++++----------- src/server/toolExecutors.ts | 149 ++++++++--------- src/server/tools.ts | 48 +++--- src/types/data.ts | 34 ++++ src/types/errors.ts | 49 ++++++ src/types/index.ts | 131 --------------- src/types/response.ts | 66 ++++++++ src/utils/errors.ts | 52 ------ tests/cli/cli.integration.test.ts | 4 +- tests/mcp/test-helpers.ts | 20 +-- tests/mcp/tools/generate-project-plan.test.ts | 4 +- tests/mcp/tools/get-next-task.test.ts | 40 +++-- 18 files changed, 381 insertions(+), 533 deletions(-) create mode 100644 src/types/data.ts create mode 100644 src/types/errors.ts delete mode 100644 src/types/index.ts create mode 100644 src/types/response.ts delete mode 100644 src/utils/errors.ts diff --git a/.cursor/rules/errors.mdc b/.cursor/rules/errors.mdc index d436231..ad69809 100644 --- a/.cursor/rules/errors.mdc +++ b/.cursor/rules/errors.mdc @@ -9,85 +9,49 @@ alwaysApply: false graph TD subgraph Core_Logic FS[FileSystemService: e.g., FileReadError] --> TM[TaskManager: Throws App Errors, e.g., ProjectNotFound, TaskNotDone] - TM -->|Untagged App Error| CLI_Handler["cli.ts Command Handler"] - TM -->|Untagged App Error| ToolExec["toolExecutors.ts: execute"] + TM -->|App Error with code APP-xxxx| CLI_Handler["cli.ts Command Handler"] + TM -->|App Error with code APP-xxxx| ToolExec["toolExecutors.ts: execute"] end subgraph CLI_Path - CLI_Handler -->|Untagged App Error| CLI_Catch["cli.ts catch block"] + CLI_Handler -->|App Error| CLI_Catch["cli.ts catch block"] CLI_Catch -->|Error Object| FormatCLI["client errors.ts formatCliError"] - FormatCLI -->|Formatted String| ConsoleOut["console.error Output"] + FormatCLI -->|"Error [APP-xxxx]: message"| ConsoleOut["console.error Output"] end subgraph MCP_Server_Path - subgraph Validation - ToolExecVal["toolExecutors.ts Validation"] -->|Throws Tagged Protocol Error jsonRpcCode -32602| ExecToolErrHandler + subgraph Validation_Layer + ToolExecVal["toolExecutors.ts Validation"] -->|App Error, e.g., MissingParameter| ExecToolErrHandler end - subgraph Execution - ToolExec -->|Untagged App Error| ExecToolErrHandler["tools.ts executeToolAndHandleErrors catch block"] + subgraph App_Execution + ToolExec -->|App Error with code APP-xxxx| ExecToolErrHandler["tools.ts executeToolAndHandleErrors catch block"] + ExecToolErrHandler -->|Map AppError to Protocol Error or Tool Result| ErrorMapping + ErrorMapping -->|"If validation error (APP-1xxx)"| McpError["Create McpError with appropriate ErrorCode"] + ErrorMapping -->|"If business logic error (APP-2xxx+)"| FormatResult["Format as isError true result"] + + McpError -->|Throw| SDKHandler["server index.ts SDK Handler"] + FormatResult -->|"{ content: [{ text: Error [APP-xxxx]: message }], isError: true }"| SDKHandler end - ExecToolErrHandler -->|Error Object| CheckTag["Check if error has jsonRpcCode"] - CheckTag -- Tagged Error --> ReThrow["Re-throw Tagged Error"] - CheckTag -- Untagged App Error --> NormalizeErr["utils errors.ts normalizeError"] - NormalizeErr -->|McpError Object code -32000| FormatResult["Format as isError true result"] - FormatResult -->|content list with isError true| SDKHandler["server index.ts SDK Handler"] + SDKHandler -- Protocol Error --> SDKFormatError["SDK Formats as JSON-RPC Error Response"] + SDKHandler -- Tool Result --> SDKFormatResult["SDK Formats as JSON-RPC Success Response"] - ReThrow -->|Tagged Protocol Error| SDKHandler - SDKHandler -- Tagged Error --> SDKFormatError["SDK Formats Top-Level Error"] - SDKHandler -- isError true Result --> SDKFormatResult["SDK Formats Result Field"] - - SDKFormatError -->|JSON-RPC Error Response| MCPClient["MCP Client"] - SDKFormatResult -->|JSON-RPC Success Response with error details| MCPClient + SDKFormatError -->|"{ error: { code: -326xx, message: ... } }"| MCPClient["MCP Client"] + SDKFormatResult -->|"{ result: { content: [...], isError: true } }"| MCPClient end ``` -**Explanation of Error Flow and Transformations:** - -Errors primarily originate from two places: - -1. **Core Logic (`TaskManager`, `FileSystemService`):** These modules throw standard JavaScript `Error` objects, often subclassed (e.g., `ProjectNotFoundError`, `FileReadError`) but *without* any special MCP/JSON-RPC tagging (`jsonRpcCode`). These represent application-specific or file system problems. -2. **Tool Executors (`toolExecutors.ts`) Validation:** Before calling `TaskManager`, the executors validate input arguments. If validation fails, they create a *new* `Error` object and explicitly *tag* it with `jsonRpcCode = -32602` (Invalid Params). - -The handling differs significantly between the CLI and the MCP Server: - -**1. CLI Error Path (`cli.ts`)** - -1. **Origination:** An untagged application error (e.g., `ProjectNotFoundError`) is thrown by `TaskManager`. -2. **Propagation:** The error propagates directly up the call stack to the `catch` block within the specific command's action handler in `cli.ts`. -3. **Transformation (`formatCliError`):** - * The `catch` block calls `formatCliError` from `src/client/errors.ts`. - * `formatCliError` takes the raw `Error` object. - * It checks the error's `name` (e.g., 'ReadOnlyFileSystemError', 'FileReadError') to provide specific user-friendly messages for known file system issues. - * For other errors, it checks if the error has a `.code` property (like the internal `ErrorCode` enum values, e.g., 'ERR_2000') and prepends it to the error message. - * **Shape Change:** `Error` object -> Formatted `string` suitable for console output. -4. **Output:** The formatted string is printed to `console.error`. - -**2. MCP Server Error Path (`server/index.ts` via `tools.ts`)** +**Explanation of Updated Error Flow and Transformations:** -1. **Origination:** - * **Validation Error:** A *tagged* protocol error (`jsonRpcCode = -32602`) is thrown by `toolExecutors.ts` validation. - * **Execution Error:** An *untagged* application error (e.g., `TaskNotDone`) is thrown by `TaskManager`. -2. **Catching (`executeToolAndHandleErrors`):** Both types of errors are caught by the `try...catch` block in `executeToolAndHandleErrors` within `src/server/tools.ts`. -3. **Branching & Transformation:** - * **If Tagged Protocol Error:** `executeToolAndHandleErrors` detects the `jsonRpcCode` property and *re-throws* the error unchanged. - * **If Untagged App Error:** - * `executeToolAndHandleErrors` calls `normalizeError` from `src/utils/errors.ts`. - * `normalizeError` takes the raw `Error` object. - * It converts the error into an `McpError` object, typically assigning the `code` to `-32000` (Server Error - a generic JSON-RPC code for implementation-defined errors). It preserves the original error message (stripping any internal `[ERR_CODE]` prefix) and potentially includes the stack trace in the `data` field for debugging. - * **Shape Change:** Raw `Error` object -> Standardized `McpError` object (with JSON-RPC code). - * `executeToolAndHandleErrors` then formats this `McpError` into the MCP `isError: true` structure: `{ content: [{ type: "text", text: "Tool execution failed: " }], isError: true }`. - * **Shape Change:** `McpError` object -> MCP Tool Result `object` with `isError: true`. -4. **SDK Handling (`@modelcontextprotocol/sdk Server`):** The MCP SDK `Server` instance (used in `server/index.ts`) handles the outcome from `executeToolAndHandleErrors`: - * **If Error was Re-thrown (Tagged Protocol Error):** The SDK catches the *thrown* error. It automatically formats this into a standard JSON-RPC top-level error response (e.g., `{"jsonrpc": "2.0", "error": {"code": -32602, "message": "...", "data": ...}, "id": ...}`). - * **If `isError: true` Object was Returned (Normalized App Error):** The SDK receives the *returned* object. It treats this as a *successful* tool execution from a protocol perspective, but one where the tool itself reported an error. It formats a standard JSON-RPC success response, placing the `isError: true` object inside the `result` field (e.g., `{"jsonrpc": "2.0", "result": {"content": [...], "isError": true}, "id": ...}`). -5. **Output:** The final JSON-RPC response (either an error response or a success response containing an `isError` result) is sent to the connected MCP Client. +Errors are consistently through a unified `AppError` system: -**Key Functions Changing Error Shapes:** +1. **Validation Errors** (`APP-1xxx` series) + - Used for validation issues (e.g., MissingParameter, InvalidArgument) + - Thrown by tool executors during parameter validation + - Mapped to protocol-level McpErrors in `executeToolAndHandleErrors` -1. **`toolExecutors.ts` (Validation Logic):** Creates *new* `Error` objects and *tags* them with `jsonRpcCode`. (Raw Error -> Tagged Error) -2. **`normalizeError` (`src/utils/errors.ts`):** Standardizes various error inputs into `McpError` objects, often using the generic `-32000` code for application errors. (Raw Error -> McpError) -3. **`executeToolAndHandleErrors` (`src/server/tools.ts`):** Packages the `McpError` from `normalizeError` into the MCP-specific `{ content: [...], isError: true }` return format. (McpError -> MCP Result Object) -4. **`formatCliError` (`src/client/errors.ts`):** Converts `Error` objects into user-friendly `string` messages for the CLI. (Error -> String) -5. **`@modelcontextprotocol/sdk Server`:** Formats *thrown*, tagged errors into the top-level JSON-RPC `error` object and *returned* `isError: true` results into the JSON-RPC `result` object. (Tagged Error -> JSON-RPC Error / MCP Result Object -> JSON-RPC Result) +2. **Business Logic Errors** (`APP-2xxx` and higher) + - Used for all business logic and application-specific errors + - Include specific error codes (e.g., APP-2000 for ProjectNotFoundError) + - Returned as serialized CallToolResults with `isError: true` \ No newline at end of file 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/src/client/cli.ts b/src/client/cli.ts index d44d23a..7f33f2d 100644 --- a/src/client/cli.ts +++ b/src/client/cli.ts @@ -4,7 +4,7 @@ import { TaskState, Task, Project -} from "../types/index.js"; +} from "../types/data.js"; import { TaskManager } from "../server/TaskManager.js"; import { formatCliError } from "./errors.js"; import { formatProjectsList, formatTaskProgressTable } from "./taskFormattingUtils.js"; diff --git a/src/client/errors.ts b/src/client/errors.ts index 4b138e2..3424ea0 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -1,21 +1,14 @@ -import { ErrorCode } from "../types/index.js"; +import { AppError } from "../types/errors.js"; /** * Formats an error message for CLI output */ -export function formatCliError(error: Error & { code?: ErrorCode | number }): string { - // Handle our custom file system errors with user-friendly messages - if (error.name === 'ReadOnlyFileSystemError') { - return "Cannot save tasks: The file system is read-only. Please check your permissions."; - } - if (error.name === 'FileWriteError') { - return "Failed to save tasks: There was an error writing to the file."; - } - if (error.name === 'FileReadError') { - return "Failed to read file: The file could not be accessed or does not exist."; +export function formatCliError(error: Error): string { + // Handle our custom file system errors by prefixing the error code + if (error instanceof AppError) { + return `${error.code}: ${error.message}`; } - // For other errors, include the error code if available - const codePrefix = error.code ? `[${error.code}] ` : ''; - return `${codePrefix}${error.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 fce14f9..3490557 100644 --- a/src/server/FileSystemService.ts +++ b/src/server/FileSystemService.ts @@ -1,35 +1,8 @@ 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"; - -// Custom error classes for FileSystemService -export class ReadOnlyFileSystemError extends Error { - constructor(originalError?: unknown) { - super('Cannot save tasks: read-only file system'); - this.name = 'ReadOnlyFileSystemError'; - (this as any).code = ErrorCode.ReadOnlyFileSystem; - (this as any).originalError = originalError; - } -} - -export class FileWriteError extends Error { - constructor(message: string, originalError?: unknown) { - super(message); - this.name = 'FileWriteError'; - (this as any).code = ErrorCode.FileWriteError; - (this as any).originalError = originalError; - } -} - -export class FileReadError extends Error { - constructor(message: string, originalError?: unknown) { - super(message); - this.name = 'FileReadError'; - (this as any).code = ErrorCode.FileReadError; - (this as any).originalError = originalError; - } -} +import { AppError, AppErrorCode } from "../types/errors.js"; +import { TaskManagerFile } from "../types/data.js"; export interface InitializedTaskData { data: TaskManagerFile; @@ -189,9 +162,9 @@ export class FileSystemService { ); } catch (error) { if (error instanceof Error && error.message.includes("EROFS")) { - throw new ReadOnlyFileSystemError(error); + throw new AppError("Cannot save tasks: read-only file system", AppErrorCode.ReadOnlyFileSystem, error); } - throw new FileWriteError("Failed to save tasks file", error); + throw new AppError("Failed to save tasks file", AppErrorCode.FileWriteError, error); } }); } @@ -208,9 +181,9 @@ export class FileSystemService { return await readFile(filePath, 'utf-8'); } catch (error) { if (error instanceof Error && error.message.includes('ENOENT')) { - throw new FileReadError(`Attachment file not found: ${filename}`, error); + throw new AppError(`Attachment file not found: ${filename}`, AppErrorCode.FileReadError, error); } - throw new FileReadError(`Failed to read attachment file: ${filename}`, 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 f6ea05b..f10ce58 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -3,6 +3,9 @@ import { Task, TaskManagerFile, TaskState, + Project +} from "../types/data.js"; +import { ProjectCreationSuccessData, ApproveTaskSuccessData, ApproveProjectSuccessData, @@ -12,8 +15,8 @@ import { AddTasksSuccessData, DeleteTaskSuccessData, ReadProjectSuccessData, - Project -} from "../types/index.js"; +} from "../types/response.js"; +import { AppError, AppErrorCode } from "../types/errors.js"; import { FileSystemService } from "./FileSystemService.js"; import { generateObject, jsonSchema } from "ai"; @@ -21,65 +24,6 @@ import { generateObject, jsonSchema } from "ai"; const DEFAULT_PATH = path.join(FileSystemService.getAppDataDir(), "tasks.json"); const TASK_FILE_PATH = process.env.TASK_MANAGER_FILE_PATH || DEFAULT_PATH; -// Custom error classes for business logic errors -export class ProjectNotFoundError extends Error { - constructor(projectId: string) { - super(`Project ${projectId} not found`); - this.name = 'ProjectNotFoundError'; - } -} - -export class TaskNotFoundError extends Error { - constructor(taskId: string) { - super(`Task ${taskId} not found`); - this.name = 'TaskNotFoundError'; - } -} - -export class ProjectAlreadyCompletedError extends Error { - constructor() { - super('Project is already completed'); - this.name = 'ProjectAlreadyCompletedError'; - } -} - -export class TaskNotDoneError extends Error { - constructor() { - super('Task not done yet'); - this.name = 'TaskNotDoneError'; - } -} - -export class TasksNotAllDoneError extends Error { - constructor() { - super('Not all tasks are done'); - this.name = 'TasksNotAllDoneError'; - } -} - -export class TasksNotAllApprovedError extends Error { - constructor() { - super('Not all done tasks are approved'); - this.name = 'TasksNotAllApprovedError'; - } -} - -export class FileReadError extends Error { - constructor(filename: string, originalError?: unknown) { - super(`Failed to read attachment file: ${filename}`); - this.name = 'FileReadError'; - (this as any).originalError = originalError; - } -} - -export class ConfigurationError extends Error { - constructor(message: string, originalError?: unknown) { - super(message); - this.name = 'ConfigurationError'; - (this as any).originalError = originalError; - } -} - interface ProjectPlanOutput { projectPlan: string; tasks: Array<{ @@ -196,7 +140,7 @@ export class TaskManager { const content = await this.fileSystemService.readAttachmentFile(filename); attachmentContents.push(content); } catch (error) { - throw new FileReadError(filename, error); + throw new AppError(`Failed to read attachment file: ${filename}`, AppErrorCode.FileReadError, error); } } @@ -245,7 +189,7 @@ export class TaskManager { modelProvider = deepseek(model); break; default: - throw new Error(`Invalid provider: ${provider}`); + throw new AppError(`Invalid provider: ${provider}`, AppErrorCode.InvalidArgument); } try { @@ -258,19 +202,25 @@ export class TaskManager { } catch (err: any) { // Handle specific error cases if (err.name === 'LoadAPIKeyError' || err.message.includes('API key is missing')) { - throw new ConfigurationError( + throw new AppError( "Invalid or missing API key. Please check your environment variables.", + AppErrorCode.LLMConfigurationError, err ); } if (err.message.includes('authentication') || err.message.includes('unauthorized')) { - throw new ConfigurationError( + throw new AppError( "Authentication failed with the LLM provider. Please check your credentials.", + AppErrorCode.LLMConfigurationError, err ); } // For unknown errors, preserve the original error but wrap it - throw new Error("Failed to generate project plan due to an unexpected error", { cause: err }); + throw new AppError( + "Failed to generate project plan due to an unexpected error", + AppErrorCode.LLMGenerationError, + err + ); } } @@ -280,10 +230,10 @@ export class TaskManager { const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } if (proj.completed) { - throw new ProjectAlreadyCompletedError(); + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } const nextTask = proj.tasks.find((t) => !(t.status === "done" && t.approved)); @@ -295,7 +245,7 @@ export class TaskManager { message: `All tasks have been completed and approved. Awaiting project completion approval.` }; } - throw new TaskNotFoundError("No incomplete or unapproved tasks found"); + throw new AppError('No incomplete or unapproved tasks found', AppErrorCode.TaskNotFound); } return { @@ -310,29 +260,20 @@ export class TaskManager { const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } const task = proj.tasks.find((t) => t.id === taskId); if (!task) { - throw new TaskNotFoundError(taskId); + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); } if (task.status !== "done") { - throw new TaskNotDoneError(); + throw new AppError('Task not done yet', AppErrorCode.TaskNotDone); } if (task.approved) { - return { - 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; @@ -356,21 +297,21 @@ export class TaskManager { const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } if (proj.completed) { - throw new ProjectAlreadyCompletedError(); + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } const allDone = proj.tasks.every((t) => t.status === "done"); if (!allDone) { - throw new TasksNotAllDoneError(); + throw new AppError('Not all tasks are done', AppErrorCode.TasksNotAllDone); } const allApproved = proj.tasks.every((t) => t.status === "done" && t.approved); if (!allApproved) { - throw new TasksNotAllApprovedError(); + throw new AppError('Not all done tasks are approved', AppErrorCode.TasksNotAllApproved); } proj.completed = true; @@ -395,13 +336,17 @@ export class TaskManager { }; } } - throw new TaskNotFoundError(taskId); + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); } public async listProjects(state?: TaskState): Promise { await this.ensureInitialized(); 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") { @@ -435,12 +380,16 @@ export class TaskManager { await this.ensureInitialized(); await this.reloadFromDisk(); + if (state && !["all", "open", "completed", "pending_approval"].includes(state)) { + throw new AppError(`Invalid state filter: ${state}`, AppErrorCode.InvalidState); + } + let allTasks: Task[] = []; if (projectId) { const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } allTasks = [...proj.tasks]; } else { @@ -478,11 +427,11 @@ export class TaskManager { const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } if (proj.completed) { - throw new ProjectAlreadyCompletedError(); + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } const newTasks: Task[] = []; @@ -531,16 +480,20 @@ export class TaskManager { const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } if (proj.completed) { - throw new ProjectAlreadyCompletedError(); + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } const task = proj.tasks.find((t) => t.id === taskId); if (!task) { - throw new TaskNotFoundError(taskId); + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); + } + + if (task.approved) { + throw new AppError('Cannot modify an approved task', AppErrorCode.CannotModifyApprovedTask); } // Apply updates @@ -556,19 +509,24 @@ export class TaskManager { const proj = this.data.projects.find((p) => p.projectId === projectId); if (!proj) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } if (proj.completed) { - throw new ProjectAlreadyCompletedError(); + throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } const taskIndex = proj.tasks.findIndex((t) => t.id === taskId); if (taskIndex === -1) { - throw new TaskNotFoundError(taskId); + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); + } + + const task = proj.tasks[taskIndex]; + if (task.approved) { + throw new AppError('Cannot delete an approved task', AppErrorCode.CannotModifyApprovedTask); } - const [deletedTask] = proj.tasks.splice(taskIndex, 1); + proj.tasks.splice(taskIndex, 1); await this.saveTasks(); return { @@ -582,7 +540,7 @@ export class TaskManager { const project = this.data.projects.find((p) => p.projectId === projectId); if (!project) { - throw new ProjectNotFoundError(projectId); + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } return { diff --git a/src/server/toolExecutors.ts b/src/server/toolExecutors.ts index 0bc37f9..1b058b6 100644 --- a/src/server/toolExecutors.ts +++ b/src/server/toolExecutors.ts @@ -1,4 +1,5 @@ import { TaskManager } from "./TaskManager.js"; +import { AppError, AppErrorCode } from "../types/errors.js"; /** * Interface defining the contract for tool executors. @@ -24,15 +25,14 @@ interface ToolExecutor { // ---------------------- UTILITY FUNCTIONS ---------------------- /** - * Throws a JSON-RPC 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) { - const message = `Invalid or missing required parameter: ${paramName} (Expected string)`; - const error = new Error(message); - // Tag as a protocol error (Invalid Params) - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + `Invalid or missing required parameter: ${paramName} (Expected string)`, + AppErrorCode.MissingParameter + ); } return param; } @@ -52,15 +52,14 @@ function validateTaskId(taskId: unknown): string { } /** - * Throws a JSON-RPC 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)) { - const message = "Invalid or missing required parameter: tasks (Expected array)"; - const error = new Error(message); - // Tag as a protocol error (Invalid Params) - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid or missing required parameter: tasks (Expected array)", + AppErrorCode.InvalidArgument + ); } } @@ -73,11 +72,10 @@ function validateOptionalStateParam( ): string | undefined { if (state === undefined) return undefined; if (typeof state === "string" && validStates.includes(state)) return state; - const message = `Invalid state parameter. Must be one of: ${validStates.join(", ")}`; - const error = new Error(message); - // Tag as a protocol error (Invalid Params) - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + `Invalid state parameter. Must be one of: ${validStates.join(", ")}`, + AppErrorCode.InvalidState + ); } /** @@ -97,11 +95,10 @@ function validateTaskObjects( return taskArray.map((task, index) => { if (!task || typeof task !== "object") { - const message = `${errorPrefix || "Task"} at index ${index} must be an object`; - const error = new Error(message); - // Tag as a protocol error (Invalid Params) - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + `${errorPrefix || "Task"} at index ${index} must be an object`, + AppErrorCode.InvalidArgument + ); } const t = task as Record; @@ -152,25 +149,24 @@ toolExecutorMap.set(listProjectsToolExecutor.name, listProjectsToolExecutor); const createProjectToolExecutor: ToolExecutor = { name: "create_project", async execute(taskManager, args) { - // 1. Argument Validation (throws tagged Error for protocol errors) const initialPrompt = validateRequiredStringParam(args.initialPrompt, "initialPrompt"); - const validatedTasks = validateTaskObjects(args.tasks); // Throws tagged error if invalid + const validatedTasks = validateTaskObjects(args.tasks); const projectPlan = args.projectPlan !== undefined ? String(args.projectPlan) : undefined; const autoApprove = args.autoApprove === true; if (args.projectPlan !== undefined && typeof args.projectPlan !== 'string') { - const error = new Error("Invalid type for optional parameter 'projectPlan' (Expected string)"); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid type for optional parameter 'projectPlan' (Expected string)", + AppErrorCode.InvalidArgument + ); } if (args.autoApprove !== undefined && typeof args.autoApprove !== 'boolean') { - const error = new Error("Invalid type for optional parameter 'autoApprove' (Expected boolean)"); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid type for optional parameter 'autoApprove' (Expected boolean)", + AppErrorCode.InvalidArgument + ); } - // 2. Core Logic Execution (Can throw *untagged* errors for execution failures) - // TaskManager now returns raw data or throws its internal errors const resultData = await taskManager.createProject( initialPrompt, validatedTasks, @@ -178,7 +174,6 @@ const createProjectToolExecutor: ToolExecutor = { autoApprove ); - // 3. Return raw success data - NO try/catch or formatting here return resultData; }, }; @@ -197,32 +192,36 @@ const generateProjectPlanToolExecutor: ToolExecutor = { // Validate provider is one of the allowed values if (!["openai", "google", "deepseek"].includes(provider)) { - const error = new Error(`Invalid provider: ${provider}. Must be one of: openai, google, deepseek`); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + `Invalid provider: ${provider}. Must be one of: openai, google, deepseek`, + AppErrorCode.InvalidArgument + ); } // Check that the corresponding API key is set - const envKey = `${provider.toUpperCase()}_API_KEY`; + const envKey = provider === "google" ? "GOOGLE_GENERATIVE_AI_API_KEY" : `${provider.toUpperCase()}_API_KEY`; if (!process.env[envKey]) { - const error = new Error(`Missing ${envKey} environment variable required for ${provider}`); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + `Missing ${envKey} environment variable required for ${provider}`, + AppErrorCode.ConfigurationError + ); } // Validate optional attachments let attachments: string[] = []; if (args.attachments !== undefined) { if (!Array.isArray(args.attachments)) { - const error = new Error("Invalid attachments: must be an array of strings"); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid attachments: must be an array of strings", + AppErrorCode.InvalidArgument + ); } attachments = args.attachments.map((att, index) => { if (typeof att !== "string") { - const error = new Error(`Invalid attachment at index ${index}: must be a string`); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + `Invalid attachment at index ${index}: must be a string`, + AppErrorCode.InvalidArgument + ); } return att; }); @@ -266,13 +265,10 @@ toolExecutorMap.set(getNextTaskToolExecutor.name, getNextTaskToolExecutor); const updateTaskToolExecutor: ToolExecutor = { name: "update_task", async execute(taskManager, args) { - // 1. Argument Validation 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"); } @@ -281,31 +277,33 @@ const updateTaskToolExecutor: ToolExecutor = { } if (args.toolRecommendations !== undefined) { if (typeof args.toolRecommendations !== "string") { - const error = new Error("Invalid toolRecommendations: must be a string"); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid toolRecommendations: must be a string", + AppErrorCode.InvalidArgument + ); } updates.toolRecommendations = args.toolRecommendations; } if (args.ruleRecommendations !== undefined) { if (typeof args.ruleRecommendations !== "string") { - const error = new Error("Invalid ruleRecommendations: must be a string"); - (error as any).jsonRpcCode = -32602; - throw error; + 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) ) { - const error = new Error("Invalid status: must be one of 'not started', 'in progress', 'done'"); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid status: must be one of 'not started', 'in progress', 'done'", + AppErrorCode.InvalidArgument + ); } if (status === "done") { updates.completedDetails = validateRequiredStringParam( @@ -316,10 +314,7 @@ const updateTaskToolExecutor: ToolExecutor = { updates.status = status; } - // 2. Core Logic Execution const resultData = await taskManager.updateTask(projectId, taskId, updates); - - // 3. Return raw success data return resultData; }, }; @@ -349,25 +344,21 @@ toolExecutorMap.set(readProjectToolExecutor.name, readProjectToolExecutor); const deleteProjectToolExecutor: ToolExecutor = { name: "delete_project", async execute(taskManager, args) { - // 1. Argument Validation const projectId = validateProjectId(args.projectId); - // 2. Core Logic Execution const projectIndex = taskManager["data"].projects.findIndex( (p) => p.projectId === projectId ); if (projectIndex === -1) { - return { - 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"](); - // 3. Return raw success data return { status: "project_deleted", message: `Project ${projectId} has been deleted.`, @@ -461,20 +452,21 @@ toolExecutorMap.set(readTaskToolExecutor.name, readTaskToolExecutor); const createTaskToolExecutor: ToolExecutor = { name: "create_task", async execute(taskManager, args) { - // 1. Argument Validation const projectId = validateProjectId(args.projectId); const title = validateRequiredStringParam(args.title, "title"); const description = validateRequiredStringParam(args.description, "description"); if (args.toolRecommendations !== undefined && typeof args.toolRecommendations !== "string") { - const error = new Error("Invalid type for optional parameter 'toolRecommendations' (Expected string)"); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid type for optional parameter 'toolRecommendations' (Expected string)", + AppErrorCode.InvalidArgument + ); } if (args.ruleRecommendations !== undefined && typeof args.ruleRecommendations !== "string") { - const error = new Error("Invalid type for optional parameter 'ruleRecommendations' (Expected string)"); - (error as any).jsonRpcCode = -32602; - throw error; + throw new AppError( + "Invalid type for optional parameter 'ruleRecommendations' (Expected string)", + AppErrorCode.InvalidArgument + ); } const singleTask = { @@ -484,10 +476,7 @@ const createTaskToolExecutor: ToolExecutor = { ruleRecommendations: args.ruleRecommendations ? String(args.ruleRecommendations) : undefined, }; - // 2. Core Logic Execution const resultData = await taskManager.addTasksToProject(projectId, [singleTask]); - - // 3. Return raw success data return resultData; }, }; diff --git a/src/server/tools.ts b/src/server/tools.ts index 65e6c42..0200e92 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -1,7 +1,8 @@ import { Tool } from "@modelcontextprotocol/sdk/types.js"; import { TaskManager } from "./TaskManager.js"; -import { 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 ---------------------- @@ -450,14 +451,13 @@ 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" - This is a Protocol Error (-32601 Method not found) + // 1. Handle "Tool Not Found" if (!executor) { - const error = new Error(`Unknown tool: ${toolName}`); - (error as any).jsonRpcCode = -32601; // Tag as protocol error - throw error; // Throw for SDK to handle + const protocolError = new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${toolName}`); + throw protocolError; // Throw McpError for SDK to handle } try { @@ -469,24 +469,26 @@ export async function executeToolAndHandleErrors( content: [{ type: "text", text: JSON.stringify(resultData, null, 2) }] }; - } catch (error: unknown) { - // 4. Handle ALL errors thrown during execution - const potentialProtocolError = error as any; + } catch (error: AppError | unknown) { + // 4a. Handle protocol errors (missing params, invalid args) + if (error instanceof AppError) { + if (error.code === AppErrorCode.MissingParameter || error.code === AppErrorCode.InvalidArgument) { + throw new McpError(ErrorCode.InvalidParams, error.message); + } + } - if (potentialProtocolError?.jsonRpcCode) { - // 4a. If it's tagged as a protocol error (e.g., from validation), re-throw it. - // The MCP SDK Server will catch this and format the top-level JSON-RPC error. - throw potentialProtocolError; - } else { - // 4b. Otherwise, it's a Tool Execution Error. - console.error(`Tool Execution Error [${toolName}]:`, error); // Log the original error for debugging - const normalized = normalizeError(error); // Get standardized message/details + // 4b. Handle all other errors as tool execution failures + console.error(`Tool Execution Error [${toolName}]:`, error); - // Format and RETURN the error within the 'result' field structure. - return { - content: [{ type: "text", text: `Tool execution failed: ${normalized.message}` }], - isError: true // Mark as an execution error as per MCP spec - }; - } + // 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: `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..0a502d2 --- /dev/null +++ b/src/types/errors.ts @@ -0,0 +1,49 @@ +// Error Codes +export enum AppErrorCode { + // Configuration / Validation (APP-1xxx) + MissingParameter = 'APP-1000', // General missing param (mapped to protocol -32602) + InvalidState = 'APP-1001', // e.g., invalid state filter + InvalidArgument = 'APP-1002', // General invalid arg (mapped to protocol -32602) + ConfigurationError = 'APP-1003', // e.g., Missing API Key for generate_project_plan + + // Resource Not Found (APP-2xxx) + ProjectNotFound = 'APP-2000', + TaskNotFound = 'APP-2001', + // No need for EmptyTaskFile code, handle during load + + // Business Logic / State Rules (APP-3xxx) + TaskNotDone = 'APP-3000', // Cannot approve/finalize if task not done + ProjectAlreadyCompleted = 'APP-3001', + // No need for CannotDeleteCompletedTask, handle in logic + TasksNotAllDone = 'APP-3003', // Cannot finalize project + TasksNotAllApproved = 'APP-3004', // Cannot finalize project + CannotModifyApprovedTask = 'APP-3005', // Added for clarity + TaskAlreadyApproved = 'APP-3006', // Added for clarity + + // File System (APP-4xxx) + FileReadError = 'APP-4000', // Includes not found, permission denied etc. + FileWriteError = 'APP-4001', + FileParseError = 'APP-4002', // If needed during JSON parsing + ReadOnlyFileSystem = 'APP-4003', + + // LLM Interaction Errors (APP-5xxx) + LLMGenerationError = 'APP-5000', + LLMConfigurationError = 'APP-5001', // Auth, key issues specifically with LLM provider call + + // Unknown / Catch-all (APP-9xxx) + Unknown = 'APP-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; + } + } + \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 7f47039..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,131 +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 Codes (kept for internal use and logging) -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' -} - -// 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; - tasks: Task[]; -} diff --git a/src/types/response.ts b/src/types/response.ts new file mode 100644 index 0000000..cdc5ab4 --- /dev/null +++ b/src/types/response.ts @@ -0,0 +1,66 @@ +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; + 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 6b242c0..0000000 --- a/src/utils/errors.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ErrorCode } from '../types/index.js'; -import { McpError } from '@modelcontextprotocol/sdk/types.js'; - -/** - * Normalizes any error into a consistent format for Tool Execution Errors. - * This is primarily used for formatting isError:true responses. - */ -export function normalizeError(error: unknown): McpError { - if (error instanceof Error) { - const err = error as any; // Allow access to potential custom props - // Use JsonRpcErrorCode for McpError, but keep original code for internal use - const mcpCode = err.jsonRpcCode || JsonRpcErrorCode.ServerError; // Default to ServerError if no JSON-RPC code - const message = err.jsonRpcCode ? err.message : // Keep original message for JSON-RPC errors if logging - err.code ? err.message.replace(`[${err.code}] `, '') : err.message; // Clean internal code prefix - return new McpError( - mcpCode, - message, - err.details || { stack: err.stack } // Include stack for debugging - ); - } else { - // Handle strings or other unknowns - return new McpError( - JsonRpcErrorCode.ServerError, - typeof error === 'string' ? error : 'An unknown tool execution error occurred', - { originalError: error } - ); - } -} - -/** - * Creates an internal error with our custom error codes. - * Use this for TaskManager internal errors that don't need jsonRpcCode. - */ -export function createInternalError(code: ErrorCode, message: string, details?: unknown): Error { - const error = new Error(`[${code}] ${message}`); - (error as any).code = code; // Internal code, NOT jsonRpcCode - if (details) { - (error as any).details = details; - } - return error; -} - -// JSON-RPC Error Codes -export const JsonRpcErrorCode = { - ParseError: -32700, - InvalidRequest: -32600, - MethodNotFound: -32601, - InvalidParams: -32602, - InternalError: -32603, - // -32000 to -32099 is reserved for implementation-defined server errors - ServerError: -32000 -} as const; \ No newline at end of file diff --git a/tests/cli/cli.integration.test.ts b/tests/cli/cli.integration.test.ts index 36204e1..619266d 100644 --- a/tests/cli/cli.integration.test.ts +++ b/tests/cli/cli.integration.test.ts @@ -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; }); diff --git a/tests/mcp/test-helpers.ts b/tests/mcp/test-helpers.ts index 81322de..f65f75b 100644 --- a/tests/mcp/test-helpers.ts +++ b/tests/mcp/test-helpers.ts @@ -1,7 +1,7 @@ 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/index.js"; +import { Task, Project, TaskManagerFile } from "../../src/types/data.js"; import * as path from 'node:path'; import * as os from 'node:os'; import * as fs from 'node:fs/promises'; @@ -41,7 +41,7 @@ export async function setupTestContext(): Promise { 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 ?? '' + GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '' } }); @@ -146,7 +146,7 @@ export function verifyToolExecutionError(response: CallToolResult, expectedMessa /** * Verifies that a successful tool response contains valid JSON data */ -export function verifyToolSuccessResponse(response: CallToolResult): { data: T } { +export function verifyToolSuccessResponse(response: CallToolResult): T { verifyCallToolResult(response); expect(response.isError).toBeFalsy(); const jsonText = response.content[0]?.text; @@ -173,11 +173,8 @@ export async function createTestProject(client: Client, options: { } }) as CallToolResult; - verifyCallToolResult(createResult); - expect(createResult.isError).toBeFalsy(); - - const responseData = JSON.parse((createResult.content[0] as { text: string }).text); - return responseData.data.projectId; + const responseData = verifyToolSuccessResponse<{ projectId: string }>(createResult); + return responseData.projectId; } /** @@ -189,11 +186,8 @@ export async function getFirstTaskId(client: Client, projectId: string): Promise arguments: { projectId } }) as CallToolResult; - verifyCallToolResult(nextTaskResult); - expect(nextTaskResult.isError).toBeFalsy(); - - const nextTask = JSON.parse((nextTaskResult.content[0] as { text: string }).text); - return nextTask.data.task.id; + const nextTask = verifyToolSuccessResponse<{ task: { id: string } }>(nextTaskResult); + return nextTask.task.id; } /** diff --git a/tests/mcp/tools/generate-project-plan.test.ts b/tests/mcp/tools/generate-project-plan.test.ts index bc92906..e52b5c2 100644 --- a/tests/mcp/tools/generate-project-plan.test.ts +++ b/tests/mcp/tools/generate-project-plan.test.ts @@ -98,9 +98,9 @@ describe('generate_project_plan Tool', () => { // Skip by default as it requires Google API key it.skip('should generate a project plan using Google Gemini', async () => { // Skip if no Google API key is set - const googleApiKey = process.env.GEMINI_API_KEY; + const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY; if (!googleApiKey) { - console.error('Skipping test: GEMINI_API_KEY not set'); + console.error('Skipping test: GOOGLE_GENERATIVE_AI_API_KEY not set'); return; } diff --git a/tests/mcp/tools/get-next-task.test.ts b/tests/mcp/tools/get-next-task.test.ts index fd6c1bc..f69ced1 100644 --- a/tests/mcp/tools/get-next-task.test.ts +++ b/tests/mcp/tools/get-next-task.test.ts @@ -6,10 +6,11 @@ import { verifyToolSuccessResponse, createTestProjectInFile, createTestTaskInFile, + readTaskManagerFile, TestContext } from '../test-helpers.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { Task } from "../../../src/types/index.js"; +import { Task } from "../../../src/types/data.js"; interface GetNextTaskResponse { task: Task; @@ -33,18 +34,25 @@ describe('get_next_task Tool', () => { const project = await createTestProjectInFile(context.testFilePath, { initialPrompt: "Test Project" }); - const tasks = await Promise.all([ - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Task 1", - description: "First task", - status: "not started" - }), - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Task 2", - description: "Second task", - status: "not started" - }) - ]); + + // 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({ @@ -55,7 +63,7 @@ describe('get_next_task Tool', () => { }) as CallToolResult; const responseData = verifyToolSuccessResponse(result); - expect(responseData.data.task).toMatchObject({ + expect(responseData.task).toMatchObject({ id: tasks[0].id, title: "Task 1", status: "not started" @@ -89,7 +97,7 @@ describe('get_next_task Tool', () => { }) as CallToolResult; const responseData = verifyToolSuccessResponse(result); - expect(responseData.data.task).toMatchObject({ + expect(responseData.task).toMatchObject({ id: nextTask.id, title: "Next Task", status: "not started" @@ -128,7 +136,7 @@ describe('get_next_task Tool', () => { }) as CallToolResult; const responseData = verifyToolSuccessResponse(result); - expect(responseData.data.task).toMatchObject({ + expect(responseData.task).toMatchObject({ id: inProgressTask.id, title: "Current Task", status: "in progress" From 16033a8b84ab38727e717634d1948816d1ba0123 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 31 Mar 2025 20:13:26 -0400 Subject: [PATCH 4/8] Debugging and slop reduction --- .cursor/rules/errors.mdc | 20 +-- .cursor/rules/tests.mdc | 193 -------------------------- .github/workflows/npm-publish.yml | 1 - package.json | 2 +- src/client/cli.ts | 107 ++------------ src/client/errors.ts | 7 +- src/server/FileSystemService.ts | 10 +- src/server/TaskManager.ts | 54 +++++-- src/server/index.ts | 6 - src/server/tools.ts | 8 +- src/types/errors.ts | 50 +++---- tests/cli/cli.integration.test.ts | 2 +- tests/mcp/test-helpers.ts | 12 +- tests/mcp/tools/list-projects.test.ts | 120 ++++++++++------ 14 files changed, 197 insertions(+), 395 deletions(-) diff --git a/.cursor/rules/errors.mdc b/.cursor/rules/errors.mdc index ad69809..04ad501 100644 --- a/.cursor/rules/errors.mdc +++ b/.cursor/rules/errors.mdc @@ -9,14 +9,14 @@ alwaysApply: false graph TD subgraph Core_Logic FS[FileSystemService: e.g., FileReadError] --> TM[TaskManager: Throws App Errors, e.g., ProjectNotFound, TaskNotDone] - TM -->|App Error with code APP-xxxx| CLI_Handler["cli.ts Command Handler"] - TM -->|App Error with code APP-xxxx| ToolExec["toolExecutors.ts: execute"] + 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 [APP-xxxx]: message"| ConsoleOut["console.error Output"] + FormatCLI -->|"Error [ERR_xxxx]: message"| ConsoleOut["console.error Output"] end subgraph MCP_Server_Path @@ -25,13 +25,13 @@ graph TD end subgraph App_Execution - ToolExec -->|App Error with code APP-xxxx| ExecToolErrHandler["tools.ts executeToolAndHandleErrors catch block"] + 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 (APP-1xxx)"| McpError["Create McpError with appropriate ErrorCode"] - ErrorMapping -->|"If business logic error (APP-2xxx+)"| FormatResult["Format as isError true result"] + 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 [APP-xxxx]: message }], isError: true }"| SDKHandler + FormatResult -->|"{ content: [{ text: Error [ERR_xxxx]: message }], isError: true }"| SDKHandler end SDKHandler -- Protocol Error --> SDKFormatError["SDK Formats as JSON-RPC Error Response"] @@ -46,12 +46,12 @@ graph TD Errors are consistently through a unified `AppError` system: -1. **Validation Errors** (`APP-1xxx` series) +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** (`APP-2xxx` and higher) +2. **Business Logic Errors** (`ERR_2xxx` and higher) - Used for all business logic and application-specific errors - - Include specific error codes (e.g., APP-2000 for ProjectNotFoundError) + - Include specific error codes (e.g., ERR_2000 for ProjectNotFoundError) - Returned as serialized CallToolResults with `isError: true` \ No newline at end of file diff --git a/.cursor/rules/tests.mdc b/.cursor/rules/tests.mdc index 8977081..bcef8cf 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. 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/package.json b/package.json index 9070209..099a1dc 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "build": "tsc", "start": "node dist/src/server/index.js", "dev": "tsc && node dist/src/server/index.js", - "test": "NODE_OPTIONS=--experimental-vm-modules jest", + "test": "tsc && NODE_OPTIONS=--experimental-vm-modules jest", "cli": "node dist/src/cli.js" }, "repository": { diff --git a/src/client/cli.ts b/src/client/cli.ts index 7f33f2d..3ac23c1 100644 --- a/src/client/cli.ts +++ b/src/client/cli.ts @@ -30,18 +30,7 @@ program.hook('preAction', (thisCommand, actionCommand) => { try { taskManager = new TaskManager(resolvedPath); } catch (error) { - if (error instanceof Error) { - if (error.name === 'FileReadError') { - console.error(chalk.red(`Failed to initialize TaskManager: Could not read tasks file`)); - if (resolvedPath) { - console.error(chalk.yellow(`Please check if the file exists and you have permission to read: ${resolvedPath}`)); - } - } else { - console.error(chalk.red(`Failed to initialize TaskManager: ${formatCliError(error)}`)); - } - } else { - console.error(chalk.red(`Failed to initialize TaskManager: Unknown error occurred`)); - } + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -72,23 +61,8 @@ program process.exit(1); } } catch (error) { - if (error instanceof Error) { - if (error.name === 'ProjectNotFound') { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - // Optionally list available projects - const projects = await taskManager.listProjects(); - if (projects.projects.length > 0) { - console.log(chalk.yellow('Available projects:')); - projects.projects.forEach((p) => { - 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 @@ -150,11 +124,7 @@ program } } } catch (error) { - if (error instanceof Error) { - console.error(chalk.red(formatCliError(error))); - } else { - console.error(chalk.red('An unknown error occurred')); - } + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -172,24 +142,8 @@ program try { project = await taskManager.readProject(projectId); } catch (error) { - if (error instanceof Error) { - if (error.name === 'ProjectNotFound') { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - // Optionally list available projects - const projects = await taskManager.listProjects(); - if (projects.projects.length > 0) { - console.log(chalk.yellow('Available projects:')); - projects.projects.forEach((p) => { - 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 - } - throw new Error('Unknown error occurred'); + console.error(chalk.red(formatCliError(error as Error))); + process.exit(1); } // Pre-check project status @@ -250,11 +204,7 @@ program console.log(chalk.blue(` taskqueue list -p ${projectId}`)); } catch (error) { - if (error instanceof Error) { - console.error(chalk.red(formatCliError(error))); - } else { - console.error(chalk.red('An unknown error occurred')); - } + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -303,24 +253,8 @@ program } } catch (error) { - if (error instanceof Error) { - if (error.name === 'ProjectNotFound') { - console.error(chalk.red(`Project ${chalk.bold(projectId)} not found.`)); - // Optionally list available projects - const projects = await taskManager.listProjects(); - if (projects.projects.length > 0) { - console.log(chalk.yellow('Available projects:')); - projects.projects.forEach((p) => { - console.log(` - ${p.projectId}: ${p.initialPrompt.substring(0, 50)}${p.initialPrompt.length > 50 ? '...' : ''}`); - }); - } else { - console.log(chalk.yellow('No projects available.')); - } - process.exit(1); - } - console.error(chalk.red(formatCliError(error))); - process.exit(1); - } + console.error(chalk.red(formatCliError(error as Error))); + process.exit(1); } } else { // List all projects, potentially filtered @@ -338,11 +272,7 @@ program } } } catch (error) { - if (error instanceof Error) { - console.error(chalk.red(formatCliError(error))); - } else { - console.error(chalk.red('An unknown error occurred')); - } + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); @@ -383,22 +313,7 @@ program console.log(`\n${result.message}`); } } catch (error) { - if (error instanceof Error) { - // Special handling for file system errors - if (error.name === 'FileReadError') { - console.error(chalk.red("Error: Could not read one or more attachment files")); - if (options.attachment.length > 0) { - console.error(chalk.yellow("Please check if these files exist and are readable:")); - options.attachment.forEach((file: string) => { - console.error(chalk.yellow(` - ${file}`)); - }); - } - } else { - console.error(`Error: ${chalk.red(formatCliError(error))}`); - } - } else { - console.error(chalk.red('An unknown error occurred')); - } + console.error(chalk.red(formatCliError(error as Error))); process.exit(1); } }); diff --git a/src/client/errors.ts b/src/client/errors.ts index 3424ea0..ba72ffa 100644 --- a/src/client/errors.ts +++ b/src/client/errors.ts @@ -6,7 +6,12 @@ import { AppError } from "../types/errors.js"; export function formatCliError(error: Error): string { // Handle our custom file system errors by prefixing the error code if (error instanceof AppError) { - return `${error.code}: ${error.message}`; + 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}`; } // For unknown errors, just return the error message diff --git a/src/server/FileSystemService.ts b/src/server/FileSystemService.ts index 3490557..a8a795c 100644 --- a/src/server/FileSystemService.ts +++ b/src/server/FileSystemService.ts @@ -138,9 +138,13 @@ 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')) { + throw new AppError(`Tasks file not found: ${this.filePath}`, AppErrorCode.FileReadError, error); + } + throw new AppError(`Failed to read tasks file: ${error.message}`, AppErrorCode.FileReadError, error); + } + throw new AppError('Unknown error reading tasks file', AppErrorCode.FileReadError, error); } } diff --git a/src/server/TaskManager.ts b/src/server/TaskManager.ts index f10ce58..972b722 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -43,26 +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 + ); + } } 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() { diff --git a/src/server/index.ts b/src/server/index.ts index 9baa30b..3768b50 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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(); diff --git a/src/server/tools.ts b/src/server/tools.ts index 0200e92..fa5ea4b 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -472,7 +472,13 @@ export async function executeToolAndHandleErrors( } catch (error: AppError | unknown) { // 4a. Handle protocol errors (missing params, invalid args) if (error instanceof AppError) { - if (error.code === AppErrorCode.MissingParameter || error.code === AppErrorCode.InvalidArgument) { + if ([ + AppErrorCode.MissingParameter, + AppErrorCode.InvalidArgument, + AppErrorCode.InvalidState, + AppErrorCode.ConfigurationError + ].includes(error.code as AppErrorCode) + ) { throw new McpError(ErrorCode.InvalidParams, error.message); } } diff --git a/src/types/errors.ts b/src/types/errors.ts index 0a502d2..94fff69 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -1,37 +1,37 @@ // Error Codes export enum AppErrorCode { - // Configuration / Validation (APP-1xxx) - MissingParameter = 'APP-1000', // General missing param (mapped to protocol -32602) - InvalidState = 'APP-1001', // e.g., invalid state filter - InvalidArgument = 'APP-1002', // General invalid arg (mapped to protocol -32602) - ConfigurationError = 'APP-1003', // e.g., Missing API Key for generate_project_plan + // Configuration / Validation (ERR_1xxx) + MissingParameter = 'ERR_1000', // General missing param (mapped to protocol -32602) + InvalidState = 'ERR_1001', // e.g., invalid state filter + InvalidArgument = 'ERR_1002', // General invalid arg (mapped to protocol -32602) + ConfigurationError = 'ERR_1003', // e.g., Missing API Key for generate_project_plan - // Resource Not Found (APP-2xxx) - ProjectNotFound = 'APP-2000', - TaskNotFound = 'APP-2001', + // Resource Not Found (ERR_2xxx) + ProjectNotFound = 'ERR_2000', + TaskNotFound = 'ERR_2001', // No need for EmptyTaskFile code, handle during load - // Business Logic / State Rules (APP-3xxx) - TaskNotDone = 'APP-3000', // Cannot approve/finalize if task not done - ProjectAlreadyCompleted = 'APP-3001', + // 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 = 'APP-3003', // Cannot finalize project - TasksNotAllApproved = 'APP-3004', // Cannot finalize project - CannotModifyApprovedTask = 'APP-3005', // Added for clarity - TaskAlreadyApproved = 'APP-3006', // Added for clarity + 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 (APP-4xxx) - FileReadError = 'APP-4000', // Includes not found, permission denied etc. - FileWriteError = 'APP-4001', - FileParseError = 'APP-4002', // If needed during JSON parsing - ReadOnlyFileSystem = 'APP-4003', + // 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 (APP-5xxx) - LLMGenerationError = 'APP-5000', - LLMConfigurationError = 'APP-5001', // Auth, key issues specifically with LLM provider call + // LLM Interaction Errors (ERR_5xxx) + LLMGenerationError = 'ERR_5000', + LLMConfigurationError = 'ERR_5001', // Auth, key issues specifically with LLM provider call - // Unknown / Catch-all (APP-9xxx) - Unknown = 'APP-9999' + // Unknown / Catch-all (ERR_9xxx) + Unknown = 'ERR_9999' } // Add a base AppError class diff --git a/tests/cli/cli.integration.test.ts b/tests/cli/cli.integration.test.ts index 619266d..1e2078e 100644 --- a/tests/cli/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); diff --git a/tests/mcp/test-helpers.ts b/tests/mcp/test-helpers.ts index f65f75b..0aee804 100644 --- a/tests/mcp/test-helpers.ts +++ b/tests/mcp/test-helpers.ts @@ -22,14 +22,16 @@ export interface TestContext { /** * Sets up a test context with MCP client, transport, and temp directory */ -export async function setupTestContext(): Promise { +export async function setupTestContext(customFilePath?: string, skipFileInit: boolean = false): 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 = path.join(tempDir, 'test-tasks.json'); + const testFilePath = customFilePath || path.join(tempDir, 'test-tasks.json'); - // Initialize empty task manager file - await writeTaskManagerFile(testFilePath, { projects: [] }); + // Initialize empty task manager file (skip for error testing) + if (!skipFileInit) { + await writeTaskManagerFile(testFilePath, { projects: [] }); + } // Set up the transport with environment variable for test file const transport = new StdioClientTransport({ @@ -119,7 +121,7 @@ export function verifyCallToolResult(response: CallToolResult) { // If it's an error response, verify error format if (response.isError) { - expect(response.content[0].text).toMatch(/^(Error|Failed|Invalid)/); + expect(response.content[0].text).toMatch(/^(Error|Failed|Invalid|Tool execution failed)/); } } diff --git a/tests/mcp/tools/list-projects.test.ts b/tests/mcp/tools/list-projects.test.ts index bfa29a2..b24c707 100644 --- a/tests/mcp/tools/list-projects.test.ts +++ b/tests/mcp/tools/list-projects.test.ts @@ -9,18 +9,21 @@ import { TestContext } from '../test-helpers.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import path from 'path'; +import os from 'os'; + describe('list_projects Tool', () => { - let context: TestContext; + describe('Success Cases', () => { + let context: TestContext; - beforeAll(async () => { - context = await setupTestContext(); - }); + beforeAll(async () => { + context = await setupTestContext(); + }); - afterAll(async () => { - await teardownTestContext(context); - }); + afterAll(async () => { + await teardownTestContext(context); + }); - describe('Success Cases', () => { it('should list projects with no filters', async () => { // Create a test project first const projectId = await createTestProject(context.client); @@ -37,16 +40,18 @@ describe('list_projects Tool', () => { // Parse and verify response data const responseData = JSON.parse((result.content[0] as { text: string }).text); - expect(responseData).toHaveProperty('data'); - expect(responseData.data).toHaveProperty('projects'); - expect(Array.isArray(responseData.data.projects)).toBe(true); + 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.data.projects; + const projects = responseData.projects; const testProject = projects.find((p: any) => p.projectId === projectId); expect(testProject).toBeDefined(); expect(testProject).toHaveProperty('initialPrompt'); - expect(testProject).toHaveProperty('taskCount'); + expect(testProject).toHaveProperty('totalTasks'); + expect(testProject).toHaveProperty('completedTasks'); + expect(testProject).toHaveProperty('approvedTasks'); }); it('should filter projects by state', async () => { @@ -74,6 +79,22 @@ describe('list_projects Tool', () => { } }); + // 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", @@ -82,7 +103,7 @@ describe('list_projects Tool', () => { verifyCallToolResult(openResult); const openData = JSON.parse((openResult.content[0] as { text: string }).text); - const openProjects = openData.data.projects; + const openProjects = openData.projects; expect(openProjects.some((p: any) => p.projectId === openProjectId)).toBe(true); expect(openProjects.some((p: any) => p.projectId === completedProjectId)).toBe(false); @@ -94,44 +115,61 @@ describe('list_projects Tool', () => { verifyCallToolResult(completedResult); const completedData = JSON.parse((completedResult.content[0] as { text: string }).text); - const completedProjects = completedData.data.projects; + 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', () => { - it('should handle invalid state parameter', async () => { - try { - await context.client.callTool({ - name: "list_projects", - arguments: { state: "invalid_state" } - }); - fail('Expected error was not thrown'); - } catch (error: any) { - verifyProtocolError(error, -32602, "Invalid parameter: state"); - } + describe('Protocol Errors', () => { + let context: TestContext; + + beforeAll(async () => { + context = await setupTestContext(); + }); + + afterAll(async () => { + await teardownTestContext(context); + }); + + it('should handle invalid state parameter', async () => { + try { + await context.client.callTool({ + name: "list_projects", + arguments: { state: "invalid_state" } + }); + fail('Expected error was not thrown'); + } catch (error: any) { + verifyProtocolError(error, -32602, "Invalid state parameter. Must be one of: open, pending_approval, completed, all"); + } + }); }); - it('should handle server errors gracefully', async () => { - // Simulate a server error by using an invalid file path - const transport = context.transport as any; - transport.env = { - ...transport.env, - TASK_MANAGER_FILE_PATH: '/invalid/path/that/does/not/exist' - }; + describe('File System Errors', () => { + let errorContext: TestContext; + const invalidPathDir = path.join(os.tmpdir(), 'nonexistent-dir'); + const invalidFilePath = path.join(invalidPathDir, 'invalid-file.json'); - const result = await context.client.callTool({ - name: "list_projects", - arguments: {} - }) as CallToolResult; + beforeAll(async () => { + // Set up test context with invalid file path, skipping file initialization + errorContext = await setupTestContext(invalidFilePath, true); + }); - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/Error: (ENOENT|Failed to read)/); + afterAll(async () => { + await teardownTestContext(errorContext); + }); + + it('should handle server errors gracefully', async () => { + const result = await errorContext.client.callTool({ + name: "list_projects", + arguments: {} + }) as CallToolResult; - // Reset the file path - transport.env.TASK_MANAGER_FILE_PATH = context.testFilePath; + verifyCallToolResult(result); + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Tool execution failed: .*(ENOENT|Tasks file not found|Failed to read)/); + }); }); }); }); \ No newline at end of file From 52232e2d9ba275534cb46c3724655afedf7f1d2d Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 31 Mar 2025 21:42:03 -0400 Subject: [PATCH 5/8] Passing tests for some tools --- .cursor/rules/errors.mdc | 2 +- .cursor/rules/tests.mdc | 2 +- src/server/FileSystemService.ts | 72 ++++++------ src/server/TaskManager.ts | 6 +- src/server/tools.ts | 4 +- src/types/errors.ts | 21 ++-- tests/mcp/test-helpers.ts | 23 ++-- tests/mcp/tools/approve-task.test.ts | 35 +++--- tests/mcp/tools/create-project.test.ts | 128 ++++++++++----------- tests/mcp/tools/finalize-project.test.ts | 137 +++++++++-------------- tests/mcp/tools/get-next-task.test.ts | 8 +- tests/mcp/tools/list-projects.test.ts | 23 ++-- 12 files changed, 221 insertions(+), 240 deletions(-) diff --git a/.cursor/rules/errors.mdc b/.cursor/rules/errors.mdc index 04ad501..ceacf7c 100644 --- a/.cursor/rules/errors.mdc +++ b/.cursor/rules/errors.mdc @@ -53,5 +53,5 @@ Errors are consistently through a unified `AppError` system: 2. **Business Logic Errors** (`ERR_2xxx` and higher) - Used for all business logic and application-specific errors - - Include specific error codes (e.g., ERR_2000 for ProjectNotFoundError) + - Include specific error codes - Returned as serialized CallToolResults with `isError: true` \ No newline at end of file diff --git a/.cursor/rules/tests.mdc b/.cursor/rules/tests.mdc index bcef8cf..8f39314 100644 --- a/.cursor/rules/tests.mdc +++ b/.cursor/rules/tests.mdc @@ -3,4 +3,4 @@ description: Writing unit tests with `jest` globs: tests/**/* alwaysApply: false --- - +Make use of the helpers in tests/mcp/test-helpers.ts. diff --git a/src/server/FileSystemService.ts b/src/server/FileSystemService.ts index a8a795c..cfeb11c 100644 --- a/src/server/FileSystemService.ts +++ b/src/server/FileSystemService.ts @@ -3,6 +3,7 @@ import { dirname, join, resolve } from "node:path"; import { homedir } from "node:os"; 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(); }); } @@ -140,7 +147,8 @@ export class FileSystemService { } catch (error) { if (error instanceof Error) { if (error.message.includes('ENOENT')) { - throw new AppError(`Tasks file not found: ${this.filePath}`, AppErrorCode.FileReadError, error); + // If file doesn't exist, return empty data + return { projects: [] }; } throw new AppError(`Failed to read tasks file: ${error.message}`, AppErrorCode.FileReadError, error); } @@ -149,10 +157,10 @@ export class FileSystemService { } /** - * 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); diff --git a/src/server/TaskManager.ts b/src/server/TaskManager.ts index 972b722..f6cbc95 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -221,7 +221,7 @@ export class TaskManager { modelProvider = deepseek(model); break; default: - throw new AppError(`Invalid provider: ${provider}`, AppErrorCode.InvalidArgument); + throw new AppError(`Invalid provider: ${provider}`, AppErrorCode.InvalidProvider); } try { @@ -268,6 +268,10 @@ export class TaskManager { throw new AppError('Project is already completed', AppErrorCode.ProjectAlreadyCompleted); } + 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 and approved? diff --git a/src/server/tools.ts b/src/server/tools.ts index fa5ea4b..0941fec 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -474,9 +474,7 @@ export async function executeToolAndHandleErrors( if (error instanceof AppError) { if ([ AppErrorCode.MissingParameter, - AppErrorCode.InvalidArgument, - AppErrorCode.InvalidState, - AppErrorCode.ConfigurationError + AppErrorCode.InvalidArgument ].includes(error.code as AppErrorCode) ) { throw new McpError(ErrorCode.InvalidParams, error.message); diff --git a/src/types/errors.ts b/src/types/errors.ts index 94fff69..8d53b6c 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -1,14 +1,16 @@ // Error Codes export enum AppErrorCode { - // Configuration / Validation (ERR_1xxx) + // Protocol Errors (ERR_1xxx) MissingParameter = 'ERR_1000', // General missing param (mapped to protocol -32602) - InvalidState = 'ERR_1001', // e.g., invalid state filter - InvalidArgument = 'ERR_1002', // General invalid arg (mapped to protocol -32602) - ConfigurationError = 'ERR_1003', // e.g., Missing API Key for generate_project_plan - - // Resource Not Found (ERR_2xxx) - ProjectNotFound = 'ERR_2000', - TaskNotFound = 'ERR_2001', + InvalidArgument = 'ERR_1002', // Extra / invalid param (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 + // No need for EmptyTaskFile code, handle during load // Business Logic / State Rules (ERR_3xxx) @@ -44,6 +46,9 @@ export enum AppErrorCode { 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/tests/mcp/test-helpers.ts b/tests/mcp/test-helpers.ts index 0aee804..e713a3a 100644 --- a/tests/mcp/test-helpers.ts +++ b/tests/mcp/test-helpers.ts @@ -2,6 +2,7 @@ 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'; @@ -17,6 +18,7 @@ export interface TestContext { tempDir: string; testFilePath: string; taskCounter: number; + fileService: FileSystemService; } /** @@ -28,9 +30,12 @@ export async function setupTestContext(customFilePath?: string, skipFileInit: bo 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 writeTaskManagerFile(testFilePath, { projects: [] }); + await fileService.saveTasks({ projects: [] }); } // Set up the transport with environment variable for test file @@ -78,7 +83,7 @@ export async function setupTestContext(customFilePath?: string, skipFileInit: bo throw error; } - return { client, transport, tempDir, testFilePath, taskCounter: 0 }; + return { client, transport, tempDir, testFilePath, taskCounter: 0, fileService }; } /** @@ -196,22 +201,16 @@ export async function getFirstTaskId(client: Client, projectId: string): Promise * Reads and parses the task manager file */ export async function readTaskManagerFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - if ((error as any).code === 'ENOENT') { - return { projects: [] }; - } - throw error; - } + const fileService = new FileSystemService(filePath); + return fileService.reloadTasks(); } /** * Writes data to the task manager file */ export async function writeTaskManagerFile(filePath: string, data: TaskManagerFile): Promise { - await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); + const fileService = new FileSystemService(filePath); + await fileService.saveTasks(data); } /** diff --git a/tests/mcp/tools/approve-task.test.ts b/tests/mcp/tools/approve-task.test.ts index f13aca8..939d639 100644 --- a/tests/mcp/tools/approve-task.test.ts +++ b/tests/mcp/tools/approve-task.test.ts @@ -89,19 +89,20 @@ describe('approve_task Tool', () => { initialPrompt: "Multi-task Project" }); - // Create and approve multiple tasks - const tasks = await Promise.all([ - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Task 1", - status: "done", - completedDetails: "First task done" - }), - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Task 2", - status: "done", - completedDetails: "Second task done" - }) - ]); + // 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) { @@ -135,7 +136,7 @@ describe('approve_task Tool', () => { verifyCallToolResult(result); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Project non_existent_project not found'); + expect(result.content[0].text).toContain('Tool execution failed: Project non_existent_project not found'); }); it('should return error for non-existent task', async () => { @@ -153,7 +154,7 @@ describe('approve_task Tool', () => { verifyCallToolResult(result); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Task non_existent_task not found'); + expect(result.content[0].text).toContain('Tool execution failed: Task non_existent_task not found'); }); it('should return error when approving incomplete task', async () => { @@ -175,7 +176,7 @@ describe('approve_task Tool', () => { verifyCallToolResult(result); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Cannot approve incomplete task'); + expect(result.content[0].text).toContain('Tool execution failed: Task not done yet'); }); it('should return error when approving already approved task', async () => { @@ -199,7 +200,7 @@ describe('approve_task Tool', () => { verifyCallToolResult(result); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Task is already approved'); + 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 index 52a80f1..9bca3e5 100644 --- a/tests/mcp/tools/create-project.test.ts +++ b/tests/mcp/tools/create-project.test.ts @@ -8,7 +8,7 @@ import { readTaskManagerFile, TestContext } from '../test-helpers.js'; -import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { CallToolResult, McpError } from '@modelcontextprotocol/sdk/types.js'; describe('create_project Tool', () => { let context: TestContext; @@ -38,8 +38,8 @@ describe('create_project Tool', () => { // Parse and verify response const responseData = JSON.parse((result.content[0] as { text: string }).text); - expect(responseData.data).toHaveProperty('projectId'); - const projectId = responseData.data.projectId; + expect(responseData).toHaveProperty('projectId'); + const projectId = responseData.projectId; // Verify project was created in file await verifyProjectInFile(context.testFilePath, projectId, { @@ -48,7 +48,7 @@ describe('create_project Tool', () => { }); // Verify task was created - await verifyTaskInFile(context.testFilePath, projectId, responseData.data.tasks[0].id, { + await verifyTaskInFile(context.testFilePath, projectId, responseData.tasks[0].id, { title: "Task 1", description: "First test task", status: "not started", @@ -56,6 +56,35 @@ describe('create_project Tool', () => { }); }); + 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", @@ -71,7 +100,7 @@ describe('create_project Tool', () => { verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); - const projectId = responseData.data.projectId; + const projectId = responseData.projectId; // Verify all tasks were created const data = await readTaskManagerFile(context.testFilePath); @@ -98,7 +127,7 @@ describe('create_project Tool', () => { verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); - const projectId = responseData.data.projectId; + const projectId = responseData.projectId; // Verify project was created with auto-approve const data = await readTaskManagerFile(context.testFilePath); @@ -120,7 +149,7 @@ describe('create_project Tool', () => { verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); - const projectId = responseData.data.projectId; + const projectId = responseData.projectId; await verifyProjectInFile(context.testFilePath, projectId, { initialPrompt: "Planned Project", @@ -144,8 +173,8 @@ describe('create_project Tool', () => { verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); - const projectId = responseData.data.projectId; - const taskId = responseData.data.tasks[0].id; + const projectId = responseData.projectId; + const taskId = responseData.tasks[0].id; await verifyTaskInFile(context.testFilePath, projectId, taskId, { toolRecommendations: "Use tool X and Y", @@ -156,63 +185,36 @@ describe('create_project Tool', () => { describe('Error Cases', () => { it('should return error for missing required parameters', async () => { - const result = await context.client.callTool({ - name: "create_project", - arguments: { - // Missing initialPrompt and tasks - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Missing required parameter'); - }); - - it('should return error for empty tasks array', async () => { - const result = await context.client.callTool({ - name: "create_project", - arguments: { - initialPrompt: "Empty Project", - tasks: [] - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Project must have at least one task'); + 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 () => { - const result = await context.client.callTool({ - name: "create_project", - arguments: { - initialPrompt: "Invalid Task Project", - tasks: [ - { title: "Task 1" } // Missing required description - ] - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Missing required task parameter: description'); - }); - - it('should return error for duplicate task titles', async () => { - const result = await context.client.callTool({ - name: "create_project", - arguments: { - initialPrompt: "Duplicate Tasks Project", - tasks: [ - { title: "Same Title", description: "First task" }, - { title: "Same Title", description: "Second task" } - ] - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Duplicate task title'); + 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/finalize-project.test.ts b/tests/mcp/tools/finalize-project.test.ts index 16031ee..bbf28d4 100644 --- a/tests/mcp/tools/finalize-project.test.ts +++ b/tests/mcp/tools/finalize-project.test.ts @@ -6,9 +6,9 @@ import { createTestProjectInFile, createTestTaskInFile, verifyProjectInFile, + verifyToolExecutionError, TestContext } from '../test-helpers.js'; -import { McpError } from '@modelcontextprotocol/sdk/types.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; describe('finalize_project Tool', () => { let context: TestContext; @@ -111,25 +111,19 @@ describe('finalize_project Tool', () => { describe('Error Cases', () => { it('should return error when project has incomplete tasks', async () => { const project = await createTestProjectInFile(context.testFilePath, { - initialPrompt: "Incomplete Project" + projectId: "proj-1", + initialPrompt: "open project", + projectPlan: "test", + tasks: [{ + id: "task-1", + title: "open task", + description: "test", + status: "not started", + approved: false, + completedDetails: "" + }] }); - // Add mix of complete and incomplete tasks - await Promise.all([ - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Done Task", - description: "Completed task", - status: "done", - approved: true, - completedDetails: "This task is done" - }), - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Pending Task", - description: "Not done yet", - status: "not started" - }) - ]); - const result = await context.client.callTool({ name: "finalize_project", arguments: { @@ -137,9 +131,7 @@ describe('finalize_project Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Cannot finalize project: not all tasks are completed'); + verifyToolExecutionError(result, /Not all tasks are done/); // Verify project remains incomplete await verifyProjectInFile(context.testFilePath, project.projectId, { @@ -149,26 +141,18 @@ describe('finalize_project Tool', () => { it('should return error when project has unapproved tasks', async () => { const project = await createTestProjectInFile(context.testFilePath, { - initialPrompt: "Unapproved Tasks Project" - }); - - // Add completed but unapproved tasks - await Promise.all([ - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Unapproved Task 1", - description: "Done but not approved", + projectId: "proj-2", + initialPrompt: "pending approval project", + projectPlan: "test", + tasks: [{ + id: "task-2", + title: "pending approval task", + description: "test", status: "done", approved: false, - completedDetails: "Needs approval" - }), - createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Unapproved Task 2", - description: "Also done but not approved", - status: "done", - approved: false, - completedDetails: "Also needs approval" - }) - ]); + completedDetails: "completed" + }] + }); const result = await context.client.callTool({ name: "finalize_project", @@ -177,9 +161,7 @@ describe('finalize_project Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Cannot finalize project: not all tasks are approved'); + verifyToolExecutionError(result, /Not all done tasks are approved/); await verifyProjectInFile(context.testFilePath, project.projectId, { completed: false @@ -188,17 +170,18 @@ describe('finalize_project Tool', () => { it('should return error when project is already completed', async () => { const project = await createTestProjectInFile(context.testFilePath, { - initialPrompt: "Already Completed Project", - completed: true - }); - - // Add completed and approved tasks - await createTestTaskInFile(context.testFilePath, project.projectId, { - title: "Done Task", - description: "Already done", - status: "done", - approved: true, - completedDetails: "Completed in the past" + 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({ @@ -208,43 +191,29 @@ describe('finalize_project Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Project is already completed'); + verifyToolExecutionError(result, /Project is already completed/); }); it('should return error for non-existent project', async () => { - try { - await context.client.callTool({ - name: "finalize_project", - arguments: { - projectId: "non_existent_project" - } - }); - fail('Expected error was not thrown'); - } catch (error) { - expect(error).toBeInstanceOf(McpError); - const mcpError = error as McpError; - expect(mcpError.code).toBe(-32602); // Invalid params error code - expect(mcpError.message).toContain('Project non_existent_project not found'); - } + 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 () => { - try { - await context.client.callTool({ - name: "finalize_project", - arguments: { - projectId: "invalid-format" - } - }); - fail('Expected error was not thrown'); - } catch (error) { - expect(error).toBeInstanceOf(McpError); - const mcpError = error as McpError; - expect(mcpError.code).toBe(-32602); // Invalid params error code - expect(mcpError.message).toContain('Invalid project ID format'); - } + 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/get-next-task.test.ts b/tests/mcp/tools/get-next-task.test.ts index f69ced1..3390982 100644 --- a/tests/mcp/tools/get-next-task.test.ts +++ b/tests/mcp/tools/get-next-task.test.ts @@ -174,7 +174,7 @@ describe('get_next_task Tool', () => { } }) as CallToolResult; - verifyToolExecutionError(result, /Error: Project is already completed/); + verifyToolExecutionError(result, /Tool execution failed: Project is already completed/); }); }); @@ -187,7 +187,7 @@ describe('get_next_task Tool', () => { } }) as CallToolResult; - verifyToolExecutionError(result, /Error: Project non_existent_project not found/); + verifyToolExecutionError(result, /Tool execution failed: Project non_existent_project not found/); }); it('should return error for invalid project ID format', async () => { @@ -198,7 +198,7 @@ describe('get_next_task Tool', () => { } }) as CallToolResult; - verifyToolExecutionError(result, /Error: Invalid project ID format/); + verifyToolExecutionError(result, /Tool execution failed: Project invalid-format not found/); }); it('should return error for project with no tasks', async () => { @@ -214,7 +214,7 @@ describe('get_next_task Tool', () => { } }) as CallToolResult; - verifyToolExecutionError(result, /Error: Project has no tasks/); + 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 index b24c707..6ddfea5 100644 --- a/tests/mcp/tools/list-projects.test.ts +++ b/tests/mcp/tools/list-projects.test.ts @@ -3,7 +3,7 @@ import { setupTestContext, teardownTestContext, verifyCallToolResult, - verifyProtocolError, + verifyToolExecutionError, createTestProject, getFirstTaskId, TestContext @@ -122,7 +122,7 @@ describe('list_projects Tool', () => { }); describe('Error Cases', () => { - describe('Protocol Errors', () => { + describe('Validation Errors', () => { let context: TestContext; beforeAll(async () => { @@ -134,15 +134,12 @@ describe('list_projects Tool', () => { }); it('should handle invalid state parameter', async () => { - try { - await context.client.callTool({ - name: "list_projects", - arguments: { state: "invalid_state" } - }); - fail('Expected error was not thrown'); - } catch (error: any) { - verifyProtocolError(error, -32602, "Invalid state parameter. Must be one of: open, pending_approval, completed, all"); - } + 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/); }); }); @@ -166,9 +163,7 @@ describe('list_projects Tool', () => { arguments: {} }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/Tool execution failed: .*(ENOENT|Tasks file not found|Failed to read)/); + verifyToolExecutionError(result, /Failed to reload tasks from disk/); }); }); }); From 1cb339d5525e5d84921feba8a6033f31bbcc86ad Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Mon, 31 Mar 2025 23:27:57 -0400 Subject: [PATCH 6/8] Most tests passing --- src/server/TaskManager.ts | 20 +- src/server/toolExecutors.ts | 17 - src/types/errors.ts | 3 +- src/types/response.ts | 1 + tests/mcp/test-helpers.ts | 15 +- tests/mcp/tools/generate-project-plan.test.ts | 441 +++++++++++------- tests/mcp/tools/read-project.test.ts | 21 +- tests/mcp/tools/update-task.test.ts | 68 ++- 8 files changed, 349 insertions(+), 237 deletions(-) diff --git a/src/server/TaskManager.ts b/src/server/TaskManager.ts index f6cbc95..a354728 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -232,18 +232,23 @@ export class TaskManager { }); return await this.createProject(prompt, object.tasks, object.projectPlan); } catch (err: any) { - // Handle specific error cases - if (err.name === 'LoadAPIKeyError' || err.message.includes('API key is missing')) { + 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( - "Invalid or missing API key. Please check your environment variables.", - AppErrorCode.LLMConfigurationError, + `Missing API key environment variable required for ${provider}`, + AppErrorCode.ConfigurationError, err ); } - if (err.message.includes('authentication') || err.message.includes('unauthorized')) { + // 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( - "Authentication failed with the LLM provider. Please check your credentials.", - AppErrorCode.LLMConfigurationError, + `Invalid model: ${model} is not available for ${provider}`, + AppErrorCode.InvalidModel, err ); } @@ -584,6 +589,7 @@ export class TaskManager { initialPrompt: project.initialPrompt, projectPlan: project.projectPlan, completed: project.completed, + autoApprove: project.autoApprove, tasks: project.tasks, }; } diff --git a/src/server/toolExecutors.ts b/src/server/toolExecutors.ts index 1b058b6..75c8d2c 100644 --- a/src/server/toolExecutors.ts +++ b/src/server/toolExecutors.ts @@ -190,23 +190,6 @@ const generateProjectPlanToolExecutor: ToolExecutor = { 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 new AppError( - `Invalid provider: ${provider}. Must be one of: openai, google, deepseek`, - AppErrorCode.InvalidArgument - ); - } - - // Check that the corresponding API key is set - const envKey = provider === "google" ? "GOOGLE_GENERATIVE_AI_API_KEY" : `${provider.toUpperCase()}_API_KEY`; - if (!process.env[envKey]) { - throw new AppError( - `Missing ${envKey} environment variable required for ${provider}`, - AppErrorCode.ConfigurationError - ); - } - // Validate optional attachments let attachments: string[] = []; if (args.attachments !== undefined) { diff --git a/src/types/errors.ts b/src/types/errors.ts index 8d53b6c..fe40e08 100644 --- a/src/types/errors.ts +++ b/src/types/errors.ts @@ -2,7 +2,7 @@ export enum AppErrorCode { // Protocol Errors (ERR_1xxx) MissingParameter = 'ERR_1000', // General missing param (mapped to protocol -32602) - InvalidArgument = 'ERR_1002', // Extra / invalid 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 @@ -10,6 +10,7 @@ export enum AppErrorCode { 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 diff --git a/src/types/response.ts b/src/types/response.ts index cdc5ab4..24087f0 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -61,6 +61,7 @@ export interface ProjectCreationSuccessData { initialPrompt: string; projectPlan: string; completed: boolean; + autoApprove?: boolean; tasks: Task[]; } \ No newline at end of file diff --git a/tests/mcp/test-helpers.ts b/tests/mcp/test-helpers.ts index e713a3a..fa96e01 100644 --- a/tests/mcp/test-helpers.ts +++ b/tests/mcp/test-helpers.ts @@ -24,7 +24,11 @@ export interface TestContext { /** * Sets up a test context with MCP client, transport, and temp directory */ -export async function setupTestContext(customFilePath?: string, skipFileInit: boolean = false): Promise { +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 }); @@ -46,9 +50,12 @@ export async function setupTestContext(customFilePath?: string, skipFileInit: bo 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 ?? '', - GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? '' + // 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 ?? '' + }) } }); diff --git a/tests/mcp/tools/generate-project-plan.test.ts b/tests/mcp/tools/generate-project-plan.test.ts index e52b5c2..b278612 100644 --- a/tests/mcp/tools/generate-project-plan.test.ts +++ b/tests/mcp/tools/generate-project-plan.test.ts @@ -1,38 +1,32 @@ -import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'; +import { describe, it, expect } from '@jest/globals'; import { setupTestContext, teardownTestContext, verifyCallToolResult, - TestContext + 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', () => { - let context: TestContext; - - beforeAll(async () => { - context = await setupTestContext(); - }); - - afterAll(async () => { - await teardownTestContext(context); - }); - describe('OpenAI Provider', () => { // Skip by default as it requires OpenAI API key it.skip('should generate a project plan using OpenAI', async () => { - // 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 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 + // 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 @@ -40,73 +34,87 @@ describe('generate_project_plan Tool', () => { - 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-4-turbo", - 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.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'); + 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 () => { - const result = await context.client.callTool({ - name: "generate_project_plan", - arguments: { - prompt: "Test prompt", - provider: "openai", - model: "gpt-4-turbo", - // Invalid/missing API key should cause an error - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/Error: (Authentication|API key)/i); + // 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 () => { - // 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 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) + // 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 @@ -114,105 +122,216 @@ describe('generate_project_plan Tool', () => { - 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-1.5-flash-latest", - 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.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('GeminiTask'); - expect(firstTask.description).toBe('GeminiDescription'); + 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 () => { - 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 + // 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; } - }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/Error: (Authentication|API key)/i); + // 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 result = await context.client.callTool({ - name: "generate_project_plan", - arguments: { - prompt: "Test prompt", - provider: "invalid_provider", - model: "some-model" - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Invalid provider'); + 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); + } }); it('should return error for invalid model', async () => { - const result = await context.client.callTool({ - name: "generate_project_plan", - arguments: { - prompt: "Test prompt", - provider: "openai", - model: "invalid-model" - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/Error: (Invalid model|Model not found)/i); + 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 result = await context.client.callTool({ - name: "generate_project_plan", - arguments: { - prompt: "Test prompt", - provider: "openai", - model: "gpt-4-turbo", - attachments: ["/non/existent/file.md"] - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toMatch(/Error: (File not found|Cannot read file)/i); + 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/read-project.test.ts b/tests/mcp/tools/read-project.test.ts index 3fb719d..19d56d6 100644 --- a/tests/mcp/tools/read-project.test.ts +++ b/tests/mcp/tools/read-project.test.ts @@ -5,7 +5,8 @@ import { verifyCallToolResult, createTestProjectInFile, createTestTaskInFile, - TestContext + TestContext, + verifyToolExecutionError } from '../test-helpers.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; @@ -47,7 +48,7 @@ describe('read_project Tool', () => { // Verify project data const responseData = JSON.parse((result.content[0] as { text: string }).text); - expect(responseData.data).toMatchObject({ + expect(responseData).toMatchObject({ projectId: project.projectId, initialPrompt: "Test Project", completed: false, @@ -86,7 +87,7 @@ describe('read_project Tool', () => { verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); - expect(responseData.data).toMatchObject({ + expect(responseData).toMatchObject({ projectId: project.projectId, initialPrompt: "Full Project", projectPlan: "Detailed project plan", @@ -126,7 +127,7 @@ describe('read_project Tool', () => { verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); - expect(responseData.data).toMatchObject({ + expect(responseData).toMatchObject({ projectId: project.projectId, completed: true, tasks: [{ @@ -171,8 +172,8 @@ describe('read_project Tool', () => { verifyCallToolResult(result); const responseData = JSON.parse((result.content[0] as { text: string }).text); - expect(responseData.data.tasks).toHaveLength(3); - expect(responseData.data.tasks.map((t: any) => t.status)).toEqual([ + expect(responseData.tasks).toHaveLength(3); + expect(responseData.tasks.map((t: any) => t.status)).toEqual([ "not started", "in progress", "done" @@ -189,9 +190,7 @@ describe('read_project Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Project non_existent_project not found'); + verifyToolExecutionError(result, /Project non_existent_project not found/); }); it('should return error for invalid project ID format', async () => { @@ -202,9 +201,7 @@ describe('read_project Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Invalid project ID format'); + verifyToolExecutionError(result, /Project invalid-format 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 index b02d801..ddd42d0 100644 --- a/tests/mcp/tools/update-task.test.ts +++ b/tests/mcp/tools/update-task.test.ts @@ -6,9 +6,11 @@ import { createTestProjectInFile, createTestTaskInFile, verifyTaskInFile, - TestContext + 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; @@ -118,18 +120,19 @@ describe('update_task Tool', () => { title: "Test Task" }); - const result = await context.client.callTool({ - name: "update_task", - arguments: { - projectId: project.projectId, - taskId: task.id, - status: "invalid_status" // Invalid status value - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Invalid status: must be one of'); + 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 () => { @@ -141,19 +144,20 @@ describe('update_task Tool', () => { status: "in progress" }); - const result = await context.client.callTool({ - name: "update_task", - arguments: { - projectId: project.projectId, - taskId: task.id, - status: "done" - // Missing required completedDetails - } - }) as CallToolResult; - - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Missing or invalid required parameter: completedDetails'); + 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 () => { @@ -166,9 +170,7 @@ describe('update_task Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Project non_existent_project not found'); + verifyToolExecutionError(result, /Project non_existent_project not found/); }); it('should return error for non-existent task', async () => { @@ -185,9 +187,7 @@ describe('update_task Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Task non_existent_task not found'); + verifyToolExecutionError(result, /Task non_existent_task not found/); }); it('should return error when updating approved task', async () => { @@ -210,9 +210,7 @@ describe('update_task Tool', () => { } }) as CallToolResult; - verifyCallToolResult(result); - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Error: Cannot modify approved task'); + verifyToolExecutionError(result, /Cannot modify an approved task/); }); }); }); \ No newline at end of file From 433387ec1dd15ee2f6212eea989e1ace4d836627 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Tue, 1 Apr 2025 00:19:28 -0400 Subject: [PATCH 7/8] Passing tests for all tools --- package-lock.json | 4 +- package.json | 2 +- src/client/cli.ts | 4 +- src/server/TaskManager.ts | 26 +- src/server/index.ts | 2 +- src/server/toolExecutors.ts | 3 +- src/server/tools.ts | 6 +- tests/mcp/tools/add-tasks-to-project.test.ts | 163 ++++++++- tests/mcp/tools/create-task.test.ts | 152 +++++++- tests/mcp/tools/delete-project.test.ts | 215 +++++++++++- tests/mcp/tools/delete-task.test.ts | 127 ++++++- tests/mcp/tools/list-tasks.test.ts | 343 ++++++++++++++++++- tests/mcp/tools/read-task.test.ts | 139 +++++++- 13 files changed, 1135 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 93881f2..ad37864 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "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.4", diff --git a/package.json b/package.json index 099a1dc..63925f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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/src/server/index.js", diff --git a/src/client/cli.ts b/src/client/cli.ts index 3ac23c1..af3ade2 100644 --- a/src/client/cli.ts +++ b/src/client/cli.ts @@ -14,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.' @@ -235,7 +235,7 @@ program // Filter tasks based on state if provided const tasksToList = filterState ? project.tasks.filter((task: Task) => { - if (filterState === 'open') return task.status !== 'done'; + 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 diff --git a/src/server/TaskManager.ts b/src/server/TaskManager.ts index a354728..662b4ce 100644 --- a/src/server/TaskManager.ts +++ b/src/server/TaskManager.ts @@ -364,20 +364,24 @@ export class TaskManager { }; } - public async openTaskDetails(taskId: string): Promise { + public async openTaskDetails(projectId: string, taskId: string): Promise { await this.ensureInitialized(); await this.reloadFromDisk(); - for (const proj of this.data.projects) { - const target = proj.tasks.find((t) => t.id === taskId); - if (target) { - return { - projectId: proj.projectId, - task: { ...target }, - }; - } + const project = this.data.projects.find((p) => p.projectId === projectId); + if (!project) { + throw new AppError(`Project ${projectId} not found`, AppErrorCode.ProjectNotFound); } - throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); + + const target = project.tasks.find((t) => t.id === taskId); + if (!target) { + throw new AppError(`Task ${taskId} not found`, AppErrorCode.TaskNotFound); + } + + return { + projectId: project.projectId, + task: { ...target }, + }; } public async listProjects(state?: TaskState): Promise { @@ -442,7 +446,7 @@ export class TaskManager { allTasks = allTasks.filter((task) => { switch (state) { case "open": - return task.status !== "done"; + return !task.approved; case "completed": return task.status === "done" && task.approved; case "pending_approval": diff --git a/src/server/index.ts b/src/server/index.ts index 3768b50..6c96d56 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -10,7 +10,7 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprot const server = new Server( { name: "task-manager-server", - version: "1.3.4" + version: "1.4.0" }, { capabilities: { diff --git a/src/server/toolExecutors.ts b/src/server/toolExecutors.ts index 75c8d2c..9fddbb8 100644 --- a/src/server/toolExecutors.ts +++ b/src/server/toolExecutors.ts @@ -418,10 +418,11 @@ const readTaskToolExecutor: ToolExecutor = { name: "read_task", async execute(taskManager, args) { // 1. Argument Validation + const projectId = validateProjectId(args.projectId); const taskId = validateTaskId(args.taskId); // 2. Core Logic Execution - const resultData = await taskManager.openTaskDetails(taskId); + const resultData = await taskManager.openTaskDetails(projectId, taskId); // 3. Return raw success data return resultData; diff --git a/src/server/tools.ts b/src/server/tools.ts index 0941fec..5b9aee5 100644 --- a/src/server/tools.ts +++ b/src/server/tools.ts @@ -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"], }, }; diff --git a/tests/mcp/tools/add-tasks-to-project.test.ts b/tests/mcp/tools/add-tasks-to-project.test.ts index 185fd1d..c5e82f0 100644 --- a/tests/mcp/tools/add-tasks-to-project.test.ts +++ b/tests/mcp/tools/add-tasks-to-project.test.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; -import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; +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 () => { @@ -13,10 +17,163 @@ describe('add_tasks_to_project Tool', () => { }); describe('Success Cases', () => { - // TODO: Add success test 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', () => { - // TODO: Add error test 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/create-task.test.ts b/tests/mcp/tools/create-task.test.ts index 3fc2faa..6cc9a26 100644 --- a/tests/mcp/tools/create-task.test.ts +++ b/tests/mcp/tools/create-task.test.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; -import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; +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 () => { @@ -13,10 +17,152 @@ describe('create_task Tool', () => { }); describe('Success Cases', () => { - // TODO: Add success test 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', () => { - // TODO: Add error test 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 index 75e1e97..4c18503 100644 --- a/tests/mcp/tools/delete-project.test.ts +++ b/tests/mcp/tools/delete-project.test.ts @@ -1,5 +1,13 @@ -import { describe, it, expect, beforeEach } from '@jest/globals'; -import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; +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; @@ -13,10 +21,209 @@ describe('delete_project Tool', () => { }); describe('Success Cases', () => { - // TODO: Add success test 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', () => { - // TODO: Add error test 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 index 1d31f33..994acf2 100644 --- a/tests/mcp/tools/delete-task.test.ts +++ b/tests/mcp/tools/delete-task.test.ts @@ -1,5 +1,14 @@ -import { describe, it, expect, beforeEach } from '@jest/globals'; -import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; +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; @@ -13,10 +22,120 @@ describe('delete_task Tool', () => { }); describe('Success Cases', () => { - // TODO: Add success test 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', () => { - // TODO: Add error test 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/list-tasks.test.ts b/tests/mcp/tools/list-tasks.test.ts index 089d440..85427a2 100644 --- a/tests/mcp/tools/list-tasks.test.ts +++ b/tests/mcp/tools/list-tasks.test.ts @@ -1,22 +1,341 @@ -import { describe, it, expect, beforeEach } from '@jest/globals'; -import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; +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', () => { - let context: TestContext; + describe('Success Cases', () => { + let context: TestContext; - beforeEach(async () => { - context = await setupTestContext(); - }); + beforeAll(async () => { + context = await setupTestContext(); + }); - afterEach(async () => { - await teardownTestContext(context); - }); + afterAll(async () => { + await teardownTestContext(context); + }); - describe('Success Cases', () => { - // TODO: Add success test cases + 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', () => { - // TODO: Add error test 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-task.test.ts b/tests/mcp/tools/read-task.test.ts index e06a587..233e932 100644 --- a/tests/mcp/tools/read-task.test.ts +++ b/tests/mcp/tools/read-task.test.ts @@ -1,22 +1,149 @@ -import { describe, it, expect, beforeEach } from '@jest/globals'; -import { setupTestContext, teardownTestContext, TestContext, createTestProject } from '../test-helpers.js'; +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; - beforeEach(async () => { + beforeAll(async () => { context = await setupTestContext(); }); - afterEach(async () => { + afterAll(async () => { await teardownTestContext(context); }); describe('Success Cases', () => { - // TODO: Add success test 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', () => { - // TODO: Add error test 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 From 22a24bcc404d69ffbebe2435bc515b32605e5316 Mon Sep 17 00:00:00 2001 From: "Christopher C. Smith" Date: Tue, 1 Apr 2025 00:26:31 -0400 Subject: [PATCH 8/8] Skip a test that requires API key --- tests/mcp/tools/generate-project-plan.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/mcp/tools/generate-project-plan.test.ts b/tests/mcp/tools/generate-project-plan.test.ts index b278612..9c3fa42 100644 --- a/tests/mcp/tools/generate-project-plan.test.ts +++ b/tests/mcp/tools/generate-project-plan.test.ts @@ -295,7 +295,8 @@ describe('generate_project_plan Tool', () => { } }); - it('should return error for invalid model', async () => { + // Skip by default as it requires OpenAI API key + it.skip('should return error for invalid model', async () => { const context = await setupTestContext(); try {