From 75e9e030a6f0401e3cb6e6f54b4d4734210488cf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Feb 2026 21:22:03 +0000 Subject: [PATCH 1/2] feat: achieve 100% unit test coverage for generate command - Added 26 new test cases (48 tests total, up from 22) - Achieved 100% line and statement coverage for generate.ts - Added coverage configuration with @vitest/coverage-v8 - Tests cover all critical paths: * Error cases (cover letter templates not found, invalid models) * Model validation (OpenAI and Anthropic) * Interactive prompts (model selection for both providers) * Format conversions (PDF, multiple formats) * Edge cases (output directory exists, empty output name) * All cancellation points (document type, templates, formats, provider, model, output name) * Anthropic provider happy path * Spinner messages verification * File operations (readTemplateFile with correct parameters) * API key validation (OpenAI and Anthropic) Coverage results for generate.ts: - Statement coverage: 100% - Branch coverage: 98.21% - Function coverage: 100% - Line coverage: 100% Co-authored-by: John Vajda --- package.json | 1 + pnpm-lock.yaml | 171 ++++++- tests/unit/commands/generate.test.ts | 698 +++++++++++++++++++++++++++ vitest.config.ts | 8 + 4 files changed, 865 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 4fb6b21..116e990 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", + "@vitest/coverage-v8": "^4.0.18", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^4.0.18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe3986..fb98e09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.19.9 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0)) tsx: specifier: ^4.19.2 version: 4.21.0 @@ -54,6 +57,27 @@ importers: packages: + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@clack/core@0.4.1': resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} @@ -216,9 +240,16 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -253,79 +284,66 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -382,6 +400,15 @@ packages: '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -421,6 +448,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -503,6 +533,13 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -526,6 +563,21 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -541,6 +593,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked@14.1.4: resolution: {integrity: sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==} engines: {node: '>= 18'} @@ -612,6 +671,11 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -651,6 +715,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -773,6 +841,21 @@ packages: snapshots: + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@clack/core@0.4.1': dependencies: picocolors: 1.1.1 @@ -862,8 +945,15 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -960,6 +1050,20 @@ snapshots: dependencies: undici-types: 6.21.0 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@22.19.9)(tsx@4.21.0) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1007,6 +1111,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + chai@6.2.2: {} commander@12.1.0: {} @@ -1110,6 +1220,10 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + human-signals@8.0.1: {} is-extendable@0.1.1: {} @@ -1122,6 +1236,21 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + js-tokens@10.0.0: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -1137,6 +1266,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + marked@14.1.4: {} nanoid@3.3.11: {} @@ -1210,6 +1349,8 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 + semver@7.7.4: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -1234,6 +1375,10 @@ snapshots: strip-final-newline@4.0.0: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tinybench@2.9.0: {} tinyexec@1.0.2: {} diff --git a/tests/unit/commands/generate.test.ts b/tests/unit/commands/generate.test.ts index e53ee7e..289ccb1 100644 --- a/tests/unit/commands/generate.test.ts +++ b/tests/unit/commands/generate.test.ts @@ -612,5 +612,703 @@ describe('commands/generate.ts', () => { mockExit.mockRestore(); }); + + it('should exit if no cover letter templates found', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue([]); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job', type: 'cover-letter' }); + + expect(p.cancel).toHaveBeenCalledWith( + 'No cover letter templates found. Add one with "downfolio template add"' + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should exit if invalid OpenAI model provided', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue([ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'openai', + model: 'invalid-model' as any, + }); + + expect(p.cancel).toHaveBeenCalledWith( + 'Model "invalid-model" is not a valid OpenAI model.' + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should exit if invalid Anthropic model provided', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue([ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'anthropic', + model: 'gpt-4o-mini' as any, + }); + + expect(p.cancel).toHaveBeenCalledWith( + 'Model "gpt-4o-mini" is not a valid Anthropic model.' + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should prompt for OpenAI model if not provided', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'openai') return 'sk-openai-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue('gpt-4o' as any); + vi.mocked(p.isCancel).mockReturnValue(false); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized content', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + }); + + expect(p.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Which OpenAI model?', + }) + ); + }); + + it('should prompt for Anthropic model if not provided', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'anthropic') return 'sk-anthropic-key'; + return undefined; + }); + vi.mocked(config.getDefaultModel).mockReturnValue('claude-sonnet-4-5'); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue('claude-haiku-4-5' as any); + vi.mocked(p.isCancel).mockReturnValue(false); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized content', provider: 'anthropic' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'anthropic', + }); + + expect(p.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Which Anthropic model?', + }) + ); + }); + + it('should convert to PDF format', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['pdf'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(pandoc.convertMarkdownToFormats).toHaveBeenCalledWith( + 'Customized resume', + 'resume.md', + expect.any(String), + ['pdf'] + ); + }); + + it('should convert to multiple formats (docx and pdf)', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['docx', 'pdf'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(pandoc.convertMarkdownToFormats).toHaveBeenCalledWith( + 'Customized resume', + 'resume.md', + expect.any(String), + ['docx', 'pdf'] + ); + }); + + it('should skip directory creation if output directory already exists', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(fs.mkdirSync).not.toHaveBeenCalled(); + }); + + it('should default output name to job name when empty string provided', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'openai') return 'sk-openai-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.text).mockResolvedValue('' as any); + vi.mocked(p.isCancel).mockReturnValue(false); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized content', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.outro).toHaveBeenCalledWith( + expect.stringContaining('test_job') + ); + }); + + it('should exit if document type selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job' }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if resume template selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job', type: 'resume' }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if cover letter template selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ job: 'test_job', type: 'cover-letter' }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if format selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.multiselect).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if provider selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if OpenAI model selection is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if Anthropic model selection is cancelled', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'anthropic') return 'sk-anthropic-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.select).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'anthropic', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should exit if output name is cancelled', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(p.text).mockResolvedValue(Symbol('cancelled') as any); + vi.mocked(p.isCancel).mockReturnValue(true); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled'); + expect(mockExit).toHaveBeenCalledWith(0); + + mockExit.mockRestore(); + }); + + it('should generate documents with Anthropic provider', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'anthropic') return 'sk-anthropic-key'; + return undefined; + }); + + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument) + .mockResolvedValueOnce({ content: 'Customized resume', provider: 'anthropic' } as any) + .mockResolvedValueOnce({ content: 'Customized cover letter', provider: 'anthropic' } as any); + + await generateCommand({ + job: 'test_job', + type: 'both', + resumeTemplate: 'base_resume', + coverLetterTemplate: 'base_cover_letter', + format: ['markdown'], + output: 'test_output', + provider: 'anthropic', + model: 'claude-sonnet-4-5', + }); + + expect(ai.customizeDocument).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'anthropic', + model: 'claude-sonnet-4-5', + }) + ); + expect(p.outro).toHaveBeenCalled(); + }); + + it('should display spinner messages in correct order', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['docx'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + const messageCalls = mockSpinner.message.mock.calls; + expect(messageCalls[0][0]).toBe('Reading job description...'); + expect(messageCalls[1][0]).toBe('AI customizing resume...'); + expect(messageCalls[2][0]).toBe('Converting resume to Word/PDF...'); + }); + + it('should call readTemplateFile with correct parameters for resume', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized resume', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(files.readTemplateFile).toHaveBeenCalledWith('base_resume', 'resume'); + }); + + it('should call readTemplateFile with correct parameters for cover letter', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized cover letter', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'cover-letter', + coverLetterTemplate: 'base_cover_letter', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(files.readTemplateFile).toHaveBeenCalledWith('base_cover_letter', 'cover-letter'); + }); + + it('should exit if Anthropic API key not found when provider specified', async () => { + vi.mocked(config.getApiKey).mockImplementation((provider: string) => { + if (provider === 'openai') return 'sk-openai-key'; + return undefined; + }); + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + resumeTemplate: 'base_resume', + format: ['markdown'], + provider: 'anthropic', + }); + + expect(p.cancel).toHaveBeenCalledWith( + expect.stringContaining('Anthropic API key not found') + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should display correct spinner message when converting cover letter to Word/PDF', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument).mockResolvedValue({ content: 'Customized cover letter', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'cover-letter', + coverLetterTemplate: 'base_cover_letter', + format: ['docx'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + const messageCalls = mockSpinner.message.mock.calls; + expect(messageCalls.some(call => call[0] === 'Converting cover letter to Word/PDF...')).toBe(true); + }); + + it('should generate both documents with docx format and show conversion messages', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + vi.mocked(files.readTemplateFile).mockReturnValue('Template content'); + vi.mocked(ai.customizeDocument) + .mockResolvedValueOnce({ content: 'Customized resume', provider: 'openai' } as any) + .mockResolvedValueOnce({ content: 'Customized cover letter', provider: 'openai' } as any); + + await generateCommand({ + job: 'test_job', + type: 'both', + resumeTemplate: 'base_resume', + coverLetterTemplate: 'base_cover_letter', + format: ['docx', 'pdf'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + const messageCalls = mockSpinner.message.mock.calls; + expect(messageCalls.some(call => call[0] === 'Converting resume to Word/PDF...')).toBe(true); + expect(messageCalls.some(call => call[0] === 'Converting cover letter to Word/PDF...')).toBe(true); + }); + + it('should handle generation failure when resume template becomes undefined', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_resume', type: 'resume' as const, filePath: '/path/to/resume.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + + // Mock select to return undefined (simulating an edge case) + let selectCallCount = 0; + vi.mocked(p.select).mockImplementation(async () => { + selectCallCount++; + return undefined as any; + }); + vi.mocked(p.isCancel).mockReturnValue(false); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'resume', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.cancel).toHaveBeenCalledWith( + expect.stringContaining('Generation failed') + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); + + it('should handle generation failure when cover letter template becomes undefined', async () => { + const mockJob = { name: 'test_job', filePath: '/path/to/job.md' }; + const mockTemplates = [ + { name: 'base_cover_letter', type: 'cover-letter' as const, filePath: '/path/to/cl.md' }, + ]; + vi.mocked(files.getJob).mockReturnValue(mockJob as any); + vi.mocked(files.listTemplates).mockReturnValue(mockTemplates as any); + vi.mocked(files.readJobFile).mockReturnValue('Job description'); + + // Mock select to return undefined (simulating an edge case) + vi.mocked(p.select).mockImplementation(async () => { + return undefined as any; + }); + vi.mocked(p.isCancel).mockReturnValue(false); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await generateCommand({ + job: 'test_job', + type: 'cover-letter', + format: ['markdown'], + output: 'test_output', + provider: 'openai', + model: 'gpt-4o-mini', + }); + + expect(p.cancel).toHaveBeenCalledWith( + expect.stringContaining('Generation failed') + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 576d9cd..b0d6213 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,13 @@ export default defineConfig({ environment: 'node', include: ['tests/**/*.test.ts'], exclude: ['node_modules/', 'dist/'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'json', 'text-summary'], + reportsDirectory: './coverage', + include: ['src/**/*.ts'], + exclude: ['src/cli.ts', '**/*.test.ts', '**/*.d.ts'], + all: true, + }, }, }); From 464adad35e1f87364e954ad9e860d6d890cf60b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Feb 2026 03:57:16 +0000 Subject: [PATCH 2/2] refactor: remove unnecessary coverage configuration - Removed @vitest/coverage-v8 package dependency - Reverted vitest.config.ts coverage configuration - All 48 tests still pass without these changes - Addresses PR feedback from @jpvajda Co-authored-by: John Vajda --- package.json | 1 - pnpm-lock.yaml | 158 ----------------------------------------------- vitest.config.ts | 8 --- 3 files changed, 167 deletions(-) diff --git a/package.json b/package.json index 116e990..4fb6b21 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.2", - "@vitest/coverage-v8": "^4.0.18", "tsx": "^4.19.2", "typescript": "^5.7.2", "vitest": "^4.0.18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb98e09..39750d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,9 +42,6 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.19.9 - '@vitest/coverage-v8': - specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0)) tsx: specifier: ^4.19.2 version: 4.21.0 @@ -57,27 +54,6 @@ importers: packages: - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@bcoe/v8-coverage@1.0.2': - resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} - engines: {node: '>=18'} - '@clack/core@0.4.1': resolution: {integrity: sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==} @@ -240,16 +216,9 @@ packages: cpu: [x64] os: [win32] - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -400,15 +369,6 @@ packages: '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} - peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 - peerDependenciesMeta: - '@vitest/browser': - optional: true - '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -448,9 +408,6 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@0.3.11: - resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -533,13 +490,6 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - html-escaper@2.0.2: - resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -563,21 +513,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: - resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} - engines: {node: '>=8'} - - istanbul-lib-report@3.0.1: - resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} - engines: {node: '>=10'} - - istanbul-reports@3.2.0: - resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} - engines: {node: '>=8'} - - js-tokens@10.0.0: - resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -593,13 +528,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.2: - resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} - - make-dir@4.0.0: - resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} - engines: {node: '>=10'} - marked@14.1.4: resolution: {integrity: sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==} engines: {node: '>= 18'} @@ -671,11 +599,6 @@ packages: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -715,10 +638,6 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -841,21 +760,6 @@ packages: snapshots: - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@bcoe/v8-coverage@1.0.2': {} - '@clack/core@0.4.1': dependencies: picocolors: 1.1.1 @@ -945,15 +849,8 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -1050,20 +947,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@22.19.9)(tsx@4.21.0))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@22.19.9)(tsx@4.21.0) - '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1111,12 +994,6 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@0.3.11: - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - estree-walker: 3.0.3 - js-tokens: 10.0.0 - chai@6.2.2: {} commander@12.1.0: {} @@ -1220,10 +1097,6 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - has-flag@4.0.0: {} - - html-escaper@2.0.2: {} - human-signals@8.0.1: {} is-extendable@0.1.1: {} @@ -1236,21 +1109,6 @@ snapshots: isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} - - istanbul-lib-report@3.0.1: - dependencies: - istanbul-lib-coverage: 3.2.2 - make-dir: 4.0.0 - supports-color: 7.2.0 - - istanbul-reports@3.2.0: - dependencies: - html-escaper: 2.0.2 - istanbul-lib-report: 3.0.1 - - js-tokens@10.0.0: {} - js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -1266,16 +1124,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.2: - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - source-map-js: 1.2.1 - - make-dir@4.0.0: - dependencies: - semver: 7.7.4 - marked@14.1.4: {} nanoid@3.3.11: {} @@ -1349,8 +1197,6 @@ snapshots: extend-shallow: 2.0.1 kind-of: 6.0.3 - semver@7.7.4: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -1375,10 +1221,6 @@ snapshots: strip-final-newline@4.0.0: {} - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - tinybench@2.9.0: {} tinyexec@1.0.2: {} diff --git a/vitest.config.ts b/vitest.config.ts index b0d6213..576d9cd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,13 +6,5 @@ export default defineConfig({ environment: 'node', include: ['tests/**/*.test.ts'], exclude: ['node_modules/', 'dist/'], - coverage: { - provider: 'v8', - reporter: ['text', 'html', 'json', 'text-summary'], - reportsDirectory: './coverage', - include: ['src/**/*.ts'], - exclude: ['src/cli.ts', '**/*.test.ts', '**/*.d.ts'], - all: true, - }, }, });