diff --git a/.claude/commands/add-test.md b/.claude/commands/add-test.md new file mode 100644 index 0000000..94ee9c0 --- /dev/null +++ b/.claude/commands/add-test.md @@ -0,0 +1,12 @@ +Read the source file at `$ARGUMENTS` and create a comprehensive test file following project conventions: + +1. Read the source file to understand all exports and their behavior +2. Create the test file at the corresponding path under `test/` (e.g., `src/core/config.ts` -> `test/core/config.test.ts`) +3. Follow these patterns: + - Import from `vitest` (`describe`, `it`, `expect`, `vi`, `beforeEach`, `afterEach`) + - Use `test/helpers/mock-fs.ts` for temp directories when testing file operations + - Use `vi.mock('@anthropic-ai/sdk')` for anything that calls the Claude API + - Use recorded fixtures from `test/fixtures/api-responses/` where available + - Test happy paths, edge cases, and error conditions + - Group related tests with `describe` blocks +4. Run the tests to verify they pass diff --git a/.claude/commands/coverage.md b/.claude/commands/coverage.md new file mode 100644 index 0000000..2209520 --- /dev/null +++ b/.claude/commands/coverage.md @@ -0,0 +1,10 @@ +Run the test coverage report and analyze gaps: + +```bash +pnpm test:coverage +``` + +After the report: +1. Identify files with coverage below 80% +2. List the uncovered lines/branches +3. Suggest which tests to write next for maximum coverage improvement diff --git a/.claude/commands/test-file.md b/.claude/commands/test-file.md new file mode 100644 index 0000000..4ccb7d9 --- /dev/null +++ b/.claude/commands/test-file.md @@ -0,0 +1,7 @@ +Run tests for a specific file: + +```bash +pnpm vitest run $ARGUMENTS +``` + +Report the results. If tests fail, analyze the failures and suggest fixes. diff --git a/.claude/commands/validate.md b/.claude/commands/validate.md new file mode 100644 index 0000000..7c84580 --- /dev/null +++ b/.claude/commands/validate.md @@ -0,0 +1,7 @@ +Run the full validation suite and report results: + +```bash +pnpm validate +``` + +If any step fails, diagnose the issue and suggest a fix. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..54f1070 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## Summary + + +## Type of Change +- [ ] Bug fix +- [ ] New feature +- [ ] Refactoring +- [ ] Documentation +- [ ] Tests / CI + +## Checklist +- [ ] `pnpm validate` passes +- [ ] No real receipts or PII included +- [ ] Docs updated if user-facing behavior changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..db9c68d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint + + format: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm format:check + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test:coverage + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4fbede5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# receipt2sheet - Claude Code Conventions + +## Overview +TypeScript CLI tool (`r2s`) for rental property owners to process receipts into Schedule E expense spreadsheets using Claude AI. + +## Stack +- **Runtime:** Node >= 20, ESM +- **Package manager:** pnpm (use `pnpm` for all commands) +- **Language:** TypeScript 5.8, strict mode +- **Testing:** Vitest + @vitest/coverage-v8 +- **Linting:** ESLint + typescript-eslint +- **Formatting:** Prettier + +## Key Commands +```bash +pnpm validate # typecheck + format + lint + build + test (run before committing) +pnpm test # run tests +pnpm test:watch # run tests in watch mode +pnpm test:coverage # run tests with coverage report +pnpm build # compile TypeScript +pnpm typecheck # type-check without emitting +pnpm lint # lint src/ +pnpm format:check # check formatting +``` + +## Architecture +``` +src/ + commands/ CLI command handlers (init, process, doctor) + core/ Business logic (config, extract, parse, spreadsheet, ledger, confirm) + schemas/ Zod schemas (config, receipt, expense) + utils/ Pure utilities (currency, dates, files) + index.ts CLI entry point +``` + +Flow: `commands/` -> `core/` -> `schemas/` -> `utils/` + +## Conventions +- ESM with `.js` extensions in imports (even for .ts files) +- Zod for all runtime validation +- No PII or real receipts in git — `inbox/` and `processed/` are gitignored +- `templates/` contains canonical xlsx spreadsheet templates — don't modify without intent +- Amounts are non-negative numbers; dates are `YYYY-MM-DD` strings + +## Testing Patterns +- `vi.mock('@anthropic-ai/sdk')` for Claude API calls +- Use `test/helpers/mock-fs.ts` for temp directories (create in `beforeEach`, clean in `afterEach`) +- Use recorded API responses from `test/fixtures/api-responses/` +- Use generated fixture receipts from `test/fixtures/receipts/` +- Spreadsheet tests: copy real templates to temp dirs, verify with ExcelJS +- Extract tests: use actual fixture PDFs (no mocking needed) + +## Commit Conventions +- Focus on the "why" not the "what" +- End with: `Co-Authored-By: Claude Opus 4.6 ` diff --git a/README.md b/README.md index 5e78c05..cf0cced 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-blue?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Node](https://img.shields.io/badge/Node-20+-green?logo=node.js&logoColor=white)](https://nodejs.org/) +[![CI](https://github.com/TeamNickHart/receipt2sheet/actions/workflows/ci.yml/badge.svg)](https://github.com/TeamNickHart/receipt2sheet/actions/workflows/ci.yml) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING.md) > v0.1.0 @@ -184,6 +185,17 @@ Claude API usage is minimal: - ~$0.05–0.15 per receipt (PDF document or image vision) - Typical month: **$1–3** for 50 receipts +## Development + +```bash +pnpm install # install dependencies +pnpm validate # typecheck + format + lint + build + test +pnpm test # run tests +pnpm test:watch # run tests in watch mode +pnpm test:coverage # run tests with coverage report +pnpm fixtures:generate # regenerate test fixture PDFs +``` + ## Contributing Contributions are welcome! Please read the [PLAN.md](./PLAN.md) for architecture details and implementation status. diff --git a/eslint.config.js b/eslint.config.js index 8209827..9cf87c9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,6 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, { - ignores: ['dist/', 'node_modules/'], + ignores: ['dist/', 'node_modules/', 'scripts/'], }, ); diff --git a/package.json b/package.json index 925507e..c3ea7b7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "fixtures:generate": "tsx scripts/generate-fixtures.ts", + "fixtures:record": "tsx scripts/record-api-responses.ts", "typecheck": "tsc --noEmit", "lint": "eslint src/", "format": "prettier --write src/", @@ -47,12 +50,17 @@ "@eslint/js": "^10.0.1", "@types/node": "^22.15.0", "@types/pdf-parse": "^1.1.4", + "@types/pdfkit": "^0.17.6", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^10.4.0", + "pdfkit": "^0.18.0", "prettier": "^3.8.3", + "tsx": "^4.22.2", "typescript": "^5.8.0", "typescript-eslint": "^8.59.3", "vitest": "^3.1.0" }, + "packageManager": "pnpm@10.26.0", "engines": { "node": ">=20.0.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df45897..7b94d83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,12 +48,24 @@ importers: '@types/pdf-parse': specifier: ^1.1.4 version: 1.1.5 + '@types/pdfkit': + specifier: ^0.17.6 + version: 0.17.6 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0)) eslint: specifier: ^10.4.0 version: 10.4.0 + pdfkit: + specifier: ^0.18.0 + version: 0.18.0 prettier: specifier: ^3.8.3 version: 3.8.3 + tsx: + specifier: ^4.22.2 + version: 4.22.2 typescript: specifier: ^5.8.0 version: 5.9.3 @@ -62,10 +74,14 @@ importers: version: 8.59.3(eslint@10.4.0)(typescript@5.9.3) vitest: specifier: ^3.1.0 - version: 3.2.4(@types/node@22.19.19)(yaml@2.9.0) + version: 3.2.4(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0) packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@anthropic-ai/sdk@0.96.0': resolution: {integrity: sha512-KlCsODtTyb17bLUVCSDC2HtSvAbJf60sEiPEax9dInF+aDF92vS4TZJ5XD7YCQXNb1/5icYaw8Y7wMjPlIV9Zg==} hasBin: true @@ -75,10 +91,31 @@ packages: zod: optional: true + '@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.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@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'} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -89,156 +126,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.7': resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.7': resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.7': resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -438,9 +631,39 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@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==} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rollup/rollup-android-arm-eabi@4.60.4': resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} cpu: [arm] @@ -569,6 +792,9 @@ packages: '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -596,6 +822,9 @@ packages: '@types/pdf-parse@1.1.5': resolution: {integrity: sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==} + '@types/pdfkit@0.17.6': + resolution: {integrity: sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==} + '@typescript-eslint/eslint-plugin@8.59.3': resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -655,6 +884,15 @@ packages: resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -701,6 +939,18 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + archiver-utils@2.1.0: resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} engines: {node: '>= 6'} @@ -717,6 +967,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -727,6 +980,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -753,6 +1010,12 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -797,6 +1060,17 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -843,6 +1117,9 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + dotenv@17.4.2: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} @@ -850,9 +1127,15 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -864,6 +1147,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -970,6 +1258,13 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -990,6 +1285,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -997,6 +1297,13 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1044,6 +1351,31 @@ 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-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-md5@0.8.3: + resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -1077,6 +1409,9 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + listenercount@1.0.1: resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} @@ -1127,9 +1462,19 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1141,9 +1486,17 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -1185,6 +1538,12 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -1200,6 +1559,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1211,6 +1574,9 @@ packages: resolution: {integrity: sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==} engines: {node: '>=6.8.1'} + pdfkit@0.18.0: + resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1218,6 +1584,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + png-js@1.1.0: + resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -1248,6 +1617,9 @@ packages: readdir-glob@1.1.3: resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -1311,6 +1683,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -1321,13 +1697,28 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1366,6 +1757,14 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.2: + resolution: {integrity: sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1385,6 +1784,12 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unzipper@0.10.14: resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} @@ -1486,6 +1891,14 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -1510,6 +1923,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.96.0(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -1517,89 +1935,182 @@ snapshots: optionalDependencies: zod: 3.25.76 + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/runtime@7.29.2': {} + '@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': {} + '@colors/colors@1.5.0': optional: true '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/aix-ppc64@0.28.0': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm64@0.28.0': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-arm@0.28.0': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/android-x64@0.28.0': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.28.0': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/darwin-x64@0.28.0': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.28.0': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.28.0': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm64@0.28.0': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-arm@0.28.0': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-ia32@0.28.0': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-loong64@0.28.0': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-mips64el@0.28.0': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-ppc64@0.28.0': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.28.0': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-s390x@0.28.0': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true + '@esbuild/linux-x64@0.28.0': + optional: true + '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-arm64@0.28.0': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.28.0': + optional: true + '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-arm64@0.28.0': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.28.0': + optional: true + '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/openharmony-arm64@0.28.0': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/sunos-x64@0.28.0': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-arm64@0.28.0': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-ia32@0.28.0': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true + '@esbuild/win32-x64@0.28.0': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.0)': dependencies: eslint: 10.4.0 @@ -1788,8 +2299,38 @@ snapshots: optionalDependencies: '@types/node': 22.19.19 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@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 + + '@noble/ciphers@1.3.0': {} + + '@noble/hashes@1.8.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.60.4': optional: true @@ -1867,6 +2408,10 @@ snapshots: '@stablelib/base64@1.0.1': {} + '@swc/helpers@0.5.21': + dependencies: + tslib: 2.8.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1892,6 +2437,10 @@ snapshots: dependencies: '@types/node': 22.19.19 + '@types/pdfkit@0.17.6': + dependencies: + '@types/node': 22.19.19 + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@10.4.0)(typescript@5.9.3))(eslint@10.4.0)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1983,6 +2532,25 @@ snapshots: '@typescript-eslint/types': 8.59.3 eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1991,13 +2559,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.19)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.3(@types/node@22.19.19)(yaml@2.9.0) + vite: 7.3.3(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -2040,6 +2608,14 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + archiver-utils@2.1.0: dependencies: glob: 7.2.3 @@ -2078,12 +2654,20 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async@3.2.6: {} balanced-match@1.0.2: {} balanced-match@4.0.4: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} big-integer@1.6.52: {} @@ -2114,6 +2698,14 @@ snapshots: dependencies: balanced-match: 4.0.4 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + buffer-crc32@0.2.13: {} buffer-indexof-polyfill@1.0.2: {} @@ -2153,6 +2745,14 @@ snapshots: cli-width@4.1.0: {} + clone@2.1.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@13.1.0: {} compress-commons@4.1.2: @@ -2189,14 +2789,20 @@ snapshots: deep-is@0.1.4: {} + dfa@1.2.0: {} + dotenv@17.4.2: {} duplexer2@0.1.4: dependencies: readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -2232,6 +2838,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-x64': 0.27.7 + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + escape-string-regexp@4.0.0: {} eslint-scope@9.1.2: @@ -2359,6 +2994,23 @@ snapshots: flatted@3.4.2: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.21 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fs-constants@1.0.0: {} fs.realpath@1.0.0: {} @@ -2377,6 +3029,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -2388,6 +3049,10 @@ snapshots: graceful-fs@4.2.11: {} + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -2421,6 +3086,37 @@ 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-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-md5@0.8.3: {} + + js-tokens@10.0.0: {} + js-tokens@9.0.1: {} json-buffer@3.0.1: {} @@ -2458,6 +3154,11 @@ snapshots: dependencies: immediate: 3.0.6 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + listenercount@1.0.1: {} locate-path@6.0.0: @@ -2492,10 +3193,22 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -2508,8 +3221,14 @@ snapshots: dependencies: brace-expansion: 2.1.0 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} + minipass@7.1.3: {} + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -2547,6 +3266,10 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + + pako@0.2.9: {} + pako@1.0.11: {} path-exists@4.0.0: {} @@ -2555,6 +3278,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@2.0.3: {} pathval@2.0.1: {} @@ -2563,10 +3291,23 @@ snapshots: dependencies: node-ensure: 0.0.0 + pdfkit@0.18.0: + dependencies: + '@noble/ciphers': 1.3.0 + '@noble/hashes': 1.8.0 + fontkit: 2.0.4 + js-md5: 0.8.3 + linebreak: 1.1.0 + png-js: 1.1.0 + picocolors@1.1.1: {} picomatch@4.0.4: {} + png-js@1.1.0: + dependencies: + browserify-zlib: 0.2.0 + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -2601,6 +3342,8 @@ snapshots: dependencies: minimatch: 5.1.9 + restructure@3.0.2: {} + rimraf@2.7.1: dependencies: glob: 7.2.3 @@ -2677,6 +3420,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -2689,10 +3438,18 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -2701,6 +3458,14 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + + tiny-inflate@1.0.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2726,6 +3491,14 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + + tsx@4.22.2: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2745,6 +3518,16 @@ snapshots: undici-types@6.21.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unzipper@0.10.14: dependencies: big-integer: 1.6.52 @@ -2766,13 +3549,13 @@ snapshots: uuid@8.3.2: {} - vite-node@3.2.4(@types/node@22.19.19)(yaml@2.9.0): + vite-node@3.2.4(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.3(@types/node@22.19.19)(yaml@2.9.0) + vite: 7.3.3(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -2787,7 +3570,7 @@ snapshots: - tsx - yaml - vite@7.3.3(@types/node@22.19.19)(yaml@2.9.0): + vite@7.3.3(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -2798,13 +3581,14 @@ snapshots: optionalDependencies: '@types/node': 22.19.19 fsevents: 2.3.3 + tsx: 4.22.2 yaml: 2.9.0 - vitest@3.2.4(@types/node@22.19.19)(yaml@2.9.0): + vitest@3.2.4(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@22.19.19)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2822,8 +3606,8 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.3(@types/node@22.19.19)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@22.19.19)(yaml@2.9.0) + vite: 7.3.3(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.19.19)(tsx@4.22.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 @@ -2852,6 +3636,18 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} xmlchars@2.2.0: {} diff --git a/scripts/generate-fixtures.ts b/scripts/generate-fixtures.ts new file mode 100644 index 0000000..9725ef2 --- /dev/null +++ b/scripts/generate-fixtures.ts @@ -0,0 +1,568 @@ +#!/usr/bin/env tsx +/** + * Generate test fixture PDFs and images for receipt2sheet tests. + * Run: pnpm fixtures:generate + * + * All fixtures use fake vendor names and addresses. No PII. + * Inspired by real-world receipt complexity — not trivial test data. + * + * IMPORTANT: compress must be false for pdf-parse compatibility. + */ + +import PDFDocument from 'pdfkit'; +import fs from 'fs'; +import path from 'path'; + +const FIXTURES_DIR = path.resolve(import.meta.dirname!, '../test/fixtures/receipts'); +fs.mkdirSync(FIXTURES_DIR, { recursive: true }); + +function pdfToBuffer(fn: (doc: PDFKit.PDFDocument) => void): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ compress: false, size: 'LETTER', margin: 50 }); + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + fn(doc); + doc.end(); + }); +} + +/** + * Fixture 1: Payment processor receipt (Elavon-style) + * Complex because: surcharges, payment metadata, T&C noise, not a clean invoice + */ +async function generatePaymentProcessorReceipt(): Promise { + const buf = await pdfToBuffer((doc) => { + doc.fontSize(10).font('Helvetica'); + doc.text('From: SUMMIT PEST CONTROL INC noreply@elavon.com'); + doc.text('Subject: Order Confirmation'); + doc.text('Date: February 19, 2024 at 3:13 PM'); + doc.text('To: customer@example.com'); + doc.moveDown(); + + doc.fontSize(24).fillColor('#2e7d32').text('$116.91 USD', { align: 'right' }); + doc.fontSize(10).fillColor('#000').text('02/19/2024 05:13:17 PM', { align: 'right' }); + doc.text('SUMMIT PEST CONTROL INC', { align: 'right' }); + doc.moveDown(); + + doc.fontSize(14).fillColor('#fff'); + doc.rect(50, doc.y, 500, 30).fill('#2e7d32'); + doc.fillColor('#fff').text('Your payment has been approved', 50, doc.y - 25, { + align: 'center', + width: 500, + }); + doc.fillColor('#000'); + doc.moveDown(2); + + doc.fontSize(9); + doc.text( + 'Thank you for your recent payment to SPC Credit Cards, 720 E MAIN ST, DURANGO, CO, ' + + '81301-5512, 970-555-8228, INFO@SUMMITPESTCO.COM. Please keep this receipt for your files ' + + 'in the event you need to contact SPC Credit Cards about your payment.', + ); + doc.moveDown(); + + const labelX = 250; + const valX = 380; + const fields = [ + ['Payment', 'VISA 41**********4415'], + ['Transaction ID', '190224O2D-FCDA0508-306E-4A22-8C6C-1522DC8577A1'], + ['Approval Code', '09534I'], + ['ECI', ''], + ['Amount', '$113.51 USD'], + ['Invoice Number', '01-0479685'], + ['Credit Surcharge', '$3.40 USD'], + ['Total of all charges and fees', '$116.91 USD'], + ]; + + for (const [label, value] of fields) { + doc.font('Helvetica-Bold').text(label, labelX, doc.y, { width: 120, align: 'right' }); + if (value) { + doc.font('Helvetica').text(value, valX, doc.y - doc.currentLineHeight()); + } + doc.moveDown(0.3); + } + + doc.moveDown(); + doc.text('Service information will follow shortly. Thank you'); + doc.moveDown(); + + doc.fontSize(18).fillColor('#2e7d32').text('Total $116.91 USD', { align: 'center' }); + doc.fillColor('#000'); + doc.moveDown(); + + doc.fontSize(8); + doc.text( + 'Please remember that this payment using a credit card includes a Surcharge charged by LPC Credit ' + + 'Cards. There is no surcharge for debit card payments.', + ); + doc.moveDown(0.5); + doc.text( + 'Elavon is a third party payment processor engaged by the biller to process online credit and debit card bill ' + + 'payments. The biller may charge you a credit card Surcharge for your use of the Elavon Hosted Web Page ' + + 'payment service to make online bill payments to the biller using a credit card. The Surcharge is in addition ' + + 'to the Amount paid to the biller for credit card payments.', + ); + }); + + const filename = 'payment-processor-pest-control.pdf'; + fs.writeFileSync(path.join(FIXTURES_DIR, filename), buf); + console.log(` ${filename} (${buf.length} bytes)`); +} + +/** + * Fixture 2: Clean service invoice with line items + * Has: bill-to, ship-to, line items with qty/rate, sales tax, payment terms + */ +async function generateServiceInvoice(): Promise { + const buf = await pdfToBuffer((doc) => { + doc.fontSize(14).fillColor('#2e7d32').font('Helvetica-Bold').text('INVOICE'); + doc.fontSize(11).fillColor('#000').text('Ridgeline Landscaping LLC'); + doc.fontSize(9).font('Helvetica'); + doc.text('482 Elk Creek Rd'); + doc.text('Silverton, CO 81433'); + doc.text('ridgeline.landscaping@example.com'); + doc.text('+1 (970) 555-0951'); + doc.moveDown(); + + // Bill to / Ship to + doc.rect(50, doc.y, 240, 50).fill('#f5f5eb'); + doc.rect(310, doc.y - 50, 240, 50).fill('#f5f5eb'); + doc.fillColor('#000'); + doc.fontSize(8).font('Helvetica-Bold'); + doc.text('Bill to', 55, doc.y - 45); + doc.font('Helvetica').text('Jane Smith', 55, doc.y); + doc.font('Helvetica-Bold').text('Ship to', 315, doc.y - doc.currentLineHeight() * 2); + doc.font('Helvetica').text('Jane Smith', 315, doc.y - doc.currentLineHeight()); + doc.moveDown(3); + + // Invoice details + doc.font('Helvetica-Bold').text('Invoice details'); + doc.font('Helvetica'); + doc.text('Invoice no.: 2823'); + doc.text('Terms: Net 7'); + doc.text('Invoice date: 04/05/2026'); + doc.text('Due date: 04/12/2026'); + doc.moveDown(); + + // Table header + const cols = [50, 80, 190, 330, 430, 470, 530]; + doc.font('Helvetica-Bold').fontSize(8); + doc.text('#', cols[0], doc.y); + const headerY = doc.y - doc.currentLineHeight(); + doc.text('Date', cols[1], headerY); + doc.text('Product or service', cols[2], headerY); + doc.text('Qty', cols[3], headerY, { width: 40, align: 'right' }); + doc.text('Rate', cols[4], headerY, { width: 50, align: 'right' }); + doc.text('Amount', cols[5], headerY, { width: 80, align: 'right' }); + doc.font('Helvetica'); + doc.moveDown(); + + // Line 1 + doc.text('1.', cols[0], doc.y); + const line1Y = doc.y - doc.currentLineHeight(); + doc.text('03/11/2026', cols[1], line1Y); + doc.text('Maintenance Visits — Two visits in March', cols[2], line1Y, { width: 140 }); + doc.text('2', cols[3], line1Y, { width: 40, align: 'right' }); + doc.text('$120.00', cols[4], line1Y, { width: 50, align: 'right' }); + doc.text('$240.00', cols[5], line1Y, { width: 80, align: 'right' }); + doc.moveDown(); + + // Line 2 + doc.text('2.', cols[0], doc.y); + const line2Y = doc.y - doc.currentLineHeight(); + doc.text('Sales Tax', cols[2], line2Y); + doc.text('0.081', cols[3], line2Y, { width: 40, align: 'right' }); + doc.text('$240.00', cols[4], line2Y, { width: 50, align: 'right' }); + doc.text('$19.44', cols[5], line2Y, { width: 80, align: 'right' }); + doc.moveDown(); + + // Total + doc.moveDown(); + doc.font('Helvetica-Bold').fontSize(16); + doc.text('Total', 400, doc.y, { width: 50 }); + doc.text('$259.44', 460, doc.y - doc.currentLineHeight(), { width: 80, align: 'right' }); + doc.font('Helvetica').fontSize(9); + + doc.moveDown(3); + doc.fillColor('#2e7d32').font('Helvetica-Bold').text('Ways to pay'); + doc.fillColor('#000').font('Helvetica'); + doc.text('Thank you for your business! We accept ACH, or can coordinate in-person payment.'); + }); + + const filename = 'service-invoice-landscaping.pdf'; + fs.writeFileSync(path.join(FIXTURES_DIR, filename), buf); + console.log(` ${filename} (${buf.length} bytes)`); +} + +/** + * Fixture 3: E-commerce order summary (inspired by Amazon) + * Has: order #, shipping info, subtotals, a credit/discount line, tax + */ +async function generateEcommerceOrder(): Promise { + const buf = await pdfToBuffer((doc) => { + doc.fontSize(9).text('Order Details', 50, 50); + doc.text('5/16/26, 9:21 AM', 450, 50, { align: 'right' }); + doc.moveDown(2); + + doc.fontSize(20).font('Helvetica-Bold').text('Order Summary'); + doc.fontSize(10).font('Helvetica'); + doc.text('Order placed January 5, 2026 Order # 111-2725430-3019452'); + doc.moveDown(); + + // Three columns: Ship to, Payment method, Order Summary + const colY = doc.y; + doc.font('Helvetica-Bold').text('Ship to', 50, colY); + doc.font('Helvetica'); + doc.text('John Doe', 50, doc.y); + doc.text('123 Main St Apt 4', 50, doc.y); + doc.text('DENVER, CO 80202', 50, doc.y); + doc.text('United States', 50, doc.y); + + doc.font('Helvetica-Bold').text('Payment method', 220, colY); + doc.font('Helvetica'); + doc.text('Visa ending in 5098', 220, doc.y); + doc.text('Earns 5% back on items using', 220, doc.y); + doc.text('Day delivery', 220, doc.y); + + doc.font('Helvetica-Bold').text('Order Summary', 420, colY); + doc.font('Helvetica').fontSize(9); + const summaryX = 420; + const summaryValX = 520; + const summaryItems = [ + ['Item(s) Subtotal:', '$89.97'], + ['Shipping & Handling:', '$0.00'], + ['Ring Protect:', '-$9.00'], + ['Total before tax:', '$80.97'], + ['Estimated tax:', '$8.55'], + ]; + let sy = colY + 15; + for (const [label, val] of summaryItems) { + doc.text(label, summaryX, sy); + doc.text(val, summaryValX, sy, { width: 40, align: 'right' }); + sy += 13; + } + doc.font('Helvetica-Bold'); + doc.text('Grand Total:', summaryX, sy); + doc.text('$89.52', summaryValX, sy, { width: 40, align: 'right' }); + doc.font('Helvetica'); + + doc.moveDown(8); + + // Product listing + doc.rect(50, doc.y, 500, 80).stroke('#ddd'); + const prodY = doc.y + 10; + doc.fontSize(10).fillColor('#007185').text( + 'Ring Pathlight Battery — Motion-activated pathlight, bright LEDs, works with Amazon Sidewalk, Black', + 120, + prodY, + { width: 400 }, + ); + doc.fillColor('#000').fontSize(9); + doc.text('Sold by: Amazon.com', 120, doc.y); + doc.text('Supplied by: Other', 120, doc.y); + doc.text('Return window closed on February 5, 2026', 120, doc.y); + doc.text('$29.99', 120, doc.y); + doc.text('Qty: 3', 120, doc.y); + + doc.moveDown(3); + doc.fontSize(7).fillColor('#999').text('Back to top', { align: 'center' }); + doc.moveDown(); + doc.text( + 'Conditions of Use Privacy Notice Consumer Health Data Privacy Disclosure Your Ads Privacy Choices', + { align: 'center' }, + ); + doc.text('© 1996-2026, Amazon.com, Inc. or its affiliates', { align: 'center' }); + }); + + const filename = 'ecommerce-order-amazon.pdf'; + fs.writeFileSync(path.join(FIXTURES_DIR, filename), buf); + console.log(` ${filename} (${buf.length} bytes)`); +} + +/** + * Fixture 4: Service agreement — NOT an invoice + * Has: legal terms, signature lines, valued equipment, but no amount due + */ +async function generateServiceAgreement(): Promise { + const buf = await pdfToBuffer((doc) => { + doc.fontSize(18).font('Helvetica').text('Mountain Valley Electric Co.', { align: 'center' }); + doc.moveDown(0.5); + doc.fontSize(13).font('Helvetica-Bold').text('Servisavor Installation Agreement', { + align: 'center', + }); + doc.moveDown(); + + doc.fontSize(10).font('Helvetica'); + doc.text( + 'It has been determined by a Mountain Valley Electric Company crew that your customer-owned underground power cable has ' + + 'failed. Since the secondary underground power lines are under the jurisdiction of the State Department of Labor and ' + + 'Industries, only licensed electricians or the homeowner are permitted to repair or replace the cables.', + ); + doc.moveDown(0.5); + doc.text( + 'Mountain Valley Electric Company offers, as a courtesy to its members, the opportunity to provide limited emergency power to ' + + 'your home when it is determined that your underground power cables need to be repaired or replaced. We have ' + + 'equipment called a Servisavor that can be installed to restore limited power to your meter while you find a licensed ' + + 'electrician to make the repairs. By signing this document below, you are agreeing to the terms and conditions of the ' + + 'installation as follows:', + ); + doc.moveDown(); + + const terms = [ + 'The power available is limited to 62.5 amp, 15 Kva. This means that you agree to reduce your electrical load if ' + + 'the Servisavor breaker trips or if you experience any low voltage problems.', + 'The Servisavor is available to you for a limited time of seven (7) calendar days. This means that you agree to ' + + 'hire a licensed electrical contractor to repair or replace your underground cables within that time.', + 'Please contact our Operations Department at (970) 555-1556 immediately if:\n' + + ' a) You have hired an electrical contractor and the repairs cannot be completed within the 7 days.\n' + + ' b) You are unable to find an electrical contractor willing to perform the work and need our assistance.', + 'The Servisavor is Mountain Valley Electric Company property and is valued at approximately $5,000.00. You agree to ' + + 'pay all costs associated with the repair or replacement of this equipment if it is vandalized or intentionally damaged.', + ]; + + terms.forEach((term, i) => { + doc.text(`${i + 1}. ${term}`); + doc.moveDown(0.5); + }); + + doc.moveDown(); + doc.font('Helvetica-Bold').text( + 'I acknowledge and agree to the terms and conditions as stated in this document and hereby authorize Mountain ' + + 'Valley Electric Company to install its Servisavor on my property:', + ); + doc.font('Helvetica'); + doc.moveDown(2); + + doc.text('_________________________________ Date: August 16, 2024'); + doc.text('Signature'); + doc.moveDown(); + doc.text('Jane Doe'); + doc.text('Print Name'); + doc.moveDown(); + doc.text('123 Example Street'); + doc.text('Address'); + doc.moveDown(); + doc.text('Ouray, CO 81427'); + doc.text('City, State, Zip'); + }); + + const filename = 'not-an-invoice-service-agreement.pdf'; + fs.writeFileSync(path.join(FIXTURES_DIR, filename), buf); + console.log(` ${filename} (${buf.length} bytes)`); +} + +/** + * Fixture 5: Large capital purchase — spa/hot tub + * Has: deposits, balance due, terms & conditions, multi-line items, DocuSign metadata + */ +async function generateCapitalPurchaseInvoice(): Promise { + const buf = await pdfToBuffer((doc) => { + doc.fontSize(7).fillColor('#888').text( + 'DocuSign Envelope ID: CC5B8430-4809-4190-B5A5-D17E9C6AC145', + 50, + 30, + ); + doc.fillColor('#000'); + + doc.fontSize(20).font('Helvetica-Bold').text('Alpine Spa & Hot Tub', 50, 55); + doc.fontSize(8).font('Helvetica').text('Since 1985', 50, 78); + doc.text('Remit Payment to:', 50, 92); + doc.text('1450 Main Ave, Durango, CO 81301', 50, 103); + + doc.fontSize(22).font('Helvetica-Bold').text('Sales Order', 380, 55, { + width: 180, + align: 'right', + }); + doc.fontSize(9).font('Helvetica'); + const infoX = 400; + const infoValX = 490; + const infoY = 85; + const infoFields = [ + ['Sales Order ID:', '1653534'], + ['Customer ID:', '203280'], + ['Employee ID:', 'RR'], + ['Ordered:', '5/26/2025'], + ['Distribution:', 'Spa Delivery Team'], + ]; + infoFields.forEach(([label, val], i) => { + doc.text(label, infoX, infoY + i * 13); + doc.text(val, infoValX, infoY + i * 13); + }); + + doc.moveDown(2); + doc.y = 175; + + // Ship to + doc.font('Helvetica-Bold').text('Ship To:', 250, 150); + doc.font('Helvetica'); + doc.text('John Doe', 250, 163); + doc.text('123 Example Court', 250, 175); + doc.text('Ouray, CO 81427-9725', 250, 187); + doc.text('Home (970) 555-8952', 250, 199); + + doc.y = 225; + + // Items table header + doc.font('Helvetica-Bold').fontSize(9); + doc.text('Qty', 50, doc.y, { width: 30 }); + const thY = doc.y - doc.currentLineHeight(); + doc.text('Item', 85, thY, { width: 250 }); + doc.text('List Price', 370, thY, { width: 70, align: 'right' }); + doc.text('Unit Price', 440, thY, { width: 60, align: 'right' }); + doc.text('Total', 510, thY, { width: 50, align: 'right' }); + doc.font('Helvetica'); + doc.moveDown(0.5); + + const items = [ + ['1', 'WARRIOR XL - Warrior XL Hot Tub', '$9,750.03', '$7,725.00', '$7,725.00'], + ['1', ' - WXL-DRIFT-PM - Classic Drift Mahogany finish', '', '', ''], + ['1', ' - NC0027 - Cover, Mahogany, XL', '', '', ''], + ['1', ' - AQST - This spa is a floor model', '', '', ''], + ['1', ' - @EASEKITPH - @ease Chemical kit', '', '', ''], + ['', ' 01-14-3256 - @Ease King Tech Floating System', '', '', ''], + ['', ' 42624BIO - Spa Shock, 35 Oz.', '', '', ''], + ['', ' 42630BIO - Spa Total Alkalinity, 2 Lb.', '', '', ''], + ['', ' 42634BIO - Spa Ph Decreaser, 22 Oz.', '', '', ''], + ['', ' PPS2100/FC-3128 - Filter Cartridge', '', '', ''], + ['', ' Water Testing Container', '', '', ''], + ['1', ' - SPA 220/40 - Set Voltage to 220V / 40A', '', '', ''], + ['1', ' - 43290 - EVO Step, Black', '', '', ''], + ['1', ' - CREDIT CARD - Balance Due paid by credit/debit card', '', '', ''], + ['1', ' - AQ3 - Spa Backyard Delivery', '', '', ''], + ['1', ' - 29329 - Filter Cartridge, Hose Filter', '', '', ''], + ['1', ' - 9951 - Solar Cover - 8 X 8 Spa, 1', '', '', ''], + ]; + + for (const [qty, item, list, unit, total] of items) { + doc.text(qty, 50, doc.y, { width: 30 }); + const iy = doc.y - (qty ? doc.currentLineHeight() : 0); + doc.text(item, 85, qty ? iy : doc.y); + if (list) { + doc.text(list, 370, iy, { width: 70, align: 'right' }); + doc.text(unit, 440, iy, { width: 60, align: 'right' }); + doc.text(total, 510, iy, { width: 50, align: 'right' }); + } + doc.moveDown(0.2); + } + + doc.moveDown(); + doc.font('Helvetica-Bold').fontSize(9); + doc.text('Retail Value $9,750.03, Extended $7,725.00, You Save $2,025.03', 50, doc.y); + doc.moveDown(); + + // Summary table + const sumX = 430; + const sumVX = 510; + doc.font('Helvetica'); + const summary = [ + ['Sub Total', '$7,725.00'], + ['Taxes', '$625.73'], + ['Total', '$8,350.73'], + ['Deposits', '-$500.00'], + ['Invoices', '$0.00'], + ['Order Balance', '$7,850.73'], + ]; + for (const [label, val] of summary) { + if (label === 'Total' || label === 'Order Balance') doc.font('Helvetica-Bold'); + doc.text(label, sumX, doc.y, { width: 70 }); + doc.text(val, sumVX, doc.y - doc.currentLineHeight(), { width: 60, align: 'right' }); + doc.font('Helvetica'); + doc.moveDown(0.3); + } + + doc.moveDown(); + doc.text('Deposits / Invoices', 50, doc.y); + doc.text('Terms: Due On Receipt', 250, doc.y - doc.currentLineHeight()); + doc.moveDown(0.3); + doc.text('05/26/2025 Deposit - Visa *4415 - Auth 09349I KEYED', 50, doc.y); + doc.text('$500.00', 430, doc.y - doc.currentLineHeight(), { width: 80, align: 'right' }); + + doc.moveDown(2); + doc.font('Helvetica-Bold').text('Terms And Conditions'); + doc.font('Helvetica').fontSize(8); + doc.moveDown(0.3); + doc.text('Customer Acceptance of proposal:'); + doc.moveDown(0.3); + doc.font('Helvetica-BoldOblique').text('DELIVERY:'); + doc.font('Helvetica'); + doc.text( + 'Alpine Spa & Hot Tub provides an appropriate number of delivery personnel, based on the size and shape of the model purchased ' + + 'with unencumbered access by dolly. Additional personnel are available at customer expense for $75 per extra person.', + ); + doc.moveDown(0.3); + doc.text( + 'If a crane is necessary, any costs associated with it are the responsibility of the customer.', + ); + }); + + const filename = 'capital-purchase-spa.pdf'; + fs.writeFileSync(path.join(FIXTURES_DIR, filename), buf); + console.log(` ${filename} (${buf.length} bytes)`); +} + +/** + * Fixture 6: Minimal valid PNG (receipt photo placeholder) + * In real use this would be a camera photo of a receipt. + */ +function generateMinimalPng(filename: string): void { + const minPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAADklEQVQI12P4z8BQDwAEgAF/QualIQAAAABJRU5ErkJggg==', + 'base64', + ); + fs.writeFileSync(path.join(FIXTURES_DIR, filename), minPng); + console.log(` ${filename} (${minPng.length} bytes)`); +} + +/** + * Fixture 7: Minimal valid JPEG (receipt photo placeholder) + */ +function generateMinimalJpg(filename: string): void { + const minJpg = Buffer.from( + '/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof' + + 'Hh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwh' + + 'MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAAR' + + 'CAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAA' + + 'D/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A' + + 'KwA//9k=', + 'base64', + ); + fs.writeFileSync(path.join(FIXTURES_DIR, filename), minJpg); + console.log(` ${filename} (${minJpg.length} bytes)`); +} + +/** + * Fixture 8: Empty/blank PDF — tests "no content" detection + */ +async function generateEmptyPdf(): Promise { + const buf = await pdfToBuffer((doc) => { + // Page with minimal non-invoice content — no dollar amounts, under 50 chars of real text. + // Tests the "needsVision" detection when text extraction finds nothing useful. + doc.fontSize(8).fillColor('#ccc').text('Page intentionally left blank'); + }); + + const filename = 'empty.pdf'; + fs.writeFileSync(path.join(FIXTURES_DIR, filename), buf); + console.log(` ${filename} (${buf.length} bytes)`); +} + +async function main() { + console.log('Generating test fixtures...\n'); + + await generatePaymentProcessorReceipt(); + await generateServiceInvoice(); + await generateEcommerceOrder(); + await generateServiceAgreement(); + await generateCapitalPurchaseInvoice(); + generateMinimalPng('receipt-photo.png'); + generateMinimalJpg('receipt-photo.jpg'); + await generateEmptyPdf(); + + console.log('\nDone! Fixtures written to test/fixtures/receipts/'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/record-api-responses.ts b/scripts/record-api-responses.ts new file mode 100644 index 0000000..6f5065f --- /dev/null +++ b/scripts/record-api-responses.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env tsx +/** + * Record real Claude API responses for test fixtures. + * Requires ANTHROPIC_API_KEY env var. + * + * Run: pnpm fixtures:record + * + * This sends each fixture receipt through the real parse pipeline + * and saves the JSON response for use in mocked tests. + */ + +import fs from 'fs'; +import path from 'path'; +import { extractText, readFileAsBase64, getMediaType } from '../src/core/extract.js'; +import { parseReceipt } from '../src/core/parse.js'; + +const RECEIPTS_DIR = path.resolve(import.meta.dirname!, '../test/fixtures/receipts'); +const RESPONSES_DIR = path.resolve(import.meta.dirname!, '../test/fixtures/api-responses'); + +const KNOWN_VENDORS = { + Amazon: { category: 'Supplies' }, + 'Home Depot': { category: 'Supplies' }, +}; + +async function main() { + if (!process.env.ANTHROPIC_API_KEY) { + console.error('Error: ANTHROPIC_API_KEY env var is required'); + process.exit(1); + } + + fs.mkdirSync(RESPONSES_DIR, { recursive: true }); + + const files = fs.readdirSync(RECEIPTS_DIR).filter((f) => /\.(pdf|png|jpg|jpeg|webp)$/i.test(f)); + + console.log(`Recording API responses for ${files.length} fixture(s)...\n`); + + for (const file of files) { + const filePath = path.join(RECEIPTS_DIR, file); + const baseName = path.basename(file, path.extname(file)); + const outPath = path.join(RESPONSES_DIR, `${baseName}.json`); + + process.stdout.write(` ${file}... `); + + try { + const ext = path.extname(file).toLowerCase(); + let result; + + if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) { + const base64 = await readFileAsBase64(filePath); + const mediaType = getMediaType(filePath); + result = await parseReceipt({ type: 'image', data: base64, mediaType }, KNOWN_VENDORS); + } else if (ext === '.pdf') { + const extraction = await extractText(filePath); + if (extraction.text && !extraction.needsVision) { + result = await parseReceipt({ type: 'text', text: extraction.text }, KNOWN_VENDORS); + } else { + const base64 = await readFileAsBase64(filePath); + result = await parseReceipt( + { type: 'document', data: base64, mediaType: 'application/pdf' }, + KNOWN_VENDORS, + ); + } + } else { + console.log('skipped (unsupported)'); + continue; + } + + fs.writeFileSync(outPath, JSON.stringify(result.parsed, null, 2)); + console.log(`done ($${result.parsed.amount.toFixed(2)})`); + } catch (err) { + console.log(`failed: ${err instanceof Error ? err.message : err}`); + } + } + + console.log(`\nResponses saved to ${RESPONSES_DIR}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/test/core/config.test.ts b/test/core/config.test.ts new file mode 100644 index 0000000..0766cb7 --- /dev/null +++ b/test/core/config.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { findConfigPath, loadConfig, saveConfig, resolveSpreadsheetPath } from '../../src/core/config.js'; +import { createTempDir, cleanupTempDir, writeTempFile } from '../helpers/mock-fs.js'; + +const VALID_CONFIG_PATH = path.resolve(import.meta.dirname!, '../fixtures/configs/valid-config.yaml'); +const MINIMAL_CONFIG_PATH = path.resolve(import.meta.dirname!, '../fixtures/configs/minimal-config.yaml'); + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await createTempDir(); +}); + +afterEach(async () => { + await cleanupTempDir(tmpDir); +}); + +describe('findConfigPath', () => { + it('finds r2s.yaml in the given directory', async () => { + await writeTempFile(tmpDir, 'r2s.yaml', 'version: 1\n'); + const result = await findConfigPath(tmpDir); + expect(result).toBe(path.join(tmpDir, 'r2s.yaml')); + }); + + it('walks up to parent directory', async () => { + await writeTempFile(tmpDir, 'r2s.yaml', 'version: 1\n'); + const subDir = path.join(tmpDir, 'subdir'); + await fs.mkdir(subDir); + const result = await findConfigPath(subDir); + expect(result).toBe(path.join(tmpDir, 'r2s.yaml')); + }); + + it('returns null when not found', async () => { + const result = await findConfigPath(tmpDir); + expect(result).toBeNull(); + }); +}); + +describe('loadConfig', () => { + it('loads a valid config file', async () => { + const { config, configDir } = await loadConfig(VALID_CONFIG_PATH); + expect(config.default_property).toBe('cabin'); + expect(config.properties).toHaveLength(1); + expect(config.properties[0].id).toBe('cabin'); + expect(configDir).toBe(path.dirname(VALID_CONFIG_PATH)); + }); + + it('loads a minimal config with defaults', async () => { + const { config } = await loadConfig(MINIMAL_CONFIG_PATH); + expect(config.default_property).toBe('rental'); + expect(config.version).toBe(1); + expect(config.options.require_confirmation).toBe(true); + expect(config.inbox).toBe('inbox/'); + }); + + it('throws for missing config', async () => { + await expect(loadConfig(path.join(tmpDir, 'nonexistent.yaml'))).rejects.toThrow(); + }); + + it('throws for invalid config schema', async () => { + const badConfig = await writeTempFile(tmpDir, 'r2s.yaml', 'version: 1\nproperties: []\n'); + await expect(loadConfig(badConfig)).rejects.toThrow(); + }); +}); + +describe('saveConfig', () => { + it('round-trips a config', async () => { + const { config } = await loadConfig(VALID_CONFIG_PATH); + await saveConfig(config, tmpDir); + + const { config: reloaded } = await loadConfig(path.join(tmpDir, 'r2s.yaml')); + expect(reloaded.default_property).toBe(config.default_property); + expect(reloaded.properties).toEqual(config.properties); + }); +}); + +describe('resolveSpreadsheetPath', () => { + it('substitutes {year} in template', () => { + const result = resolveSpreadsheetPath('/project', 'spreadsheets/Expenses_{year}.xlsx', 2026); + expect(result).toBe(path.join('/project', 'spreadsheets/Expenses_2026.xlsx')); + }); + + it('handles template without {year}', () => { + const result = resolveSpreadsheetPath('/project', 'spreadsheets/Capital.xlsx', 2026); + expect(result).toBe(path.join('/project', 'spreadsheets/Capital.xlsx')); + }); +}); diff --git a/test/core/extract.test.ts b/test/core/extract.test.ts new file mode 100644 index 0000000..2f71a51 --- /dev/null +++ b/test/core/extract.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { extractText, readFileAsBase64, getMediaType } from '../../src/core/extract.js'; +import { createTempDir, cleanupTempDir, writeTempFile } from '../helpers/mock-fs.js'; + +const FIXTURES_DIR = path.resolve(import.meta.dirname!, '../fixtures/receipts'); + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await createTempDir(); +}); + +afterEach(async () => { + await cleanupTempDir(tmpDir); +}); + +describe('extractText', () => { + it('extracts text from a text PDF with dollar amounts', async () => { + const result = await extractText(path.join(FIXTURES_DIR, 'service-invoice-landscaping.pdf')); + expect(result.text).toBeTruthy(); + expect(result.text).toContain('Ridgeline'); + expect(result.text).toContain('$259.44'); + expect(result.needsVision).toBe(false); + expect(result.pageCount).toBeGreaterThanOrEqual(1); + }); + + it('extracts text from a complex payment receipt', async () => { + const result = await extractText( + path.join(FIXTURES_DIR, 'payment-processor-pest-control.pdf'), + ); + expect(result.text).toBeTruthy(); + expect(result.text).toContain('$116.91'); + expect(result.needsVision).toBe(false); + }); + + it('extracts text from a capital purchase invoice', async () => { + const result = await extractText(path.join(FIXTURES_DIR, 'capital-purchase-spa.pdf')); + expect(result.text).toBeTruthy(); + expect(result.text).toContain('$8,350.73'); + expect(result.needsVision).toBe(false); + }); + + it('extracts text from a non-invoice document', async () => { + const result = await extractText( + path.join(FIXTURES_DIR, 'not-an-invoice-service-agreement.pdf'), + ); + expect(result.text).toBeTruthy(); + // The service agreement mentions $5,000 valued equipment but it's not an invoice + expect(result.text).toContain('Mountain Valley Electric'); + }); + + it('marks PNG as needing vision with null text', async () => { + const result = await extractText(path.join(FIXTURES_DIR, 'receipt-photo.png')); + expect(result.text).toBeNull(); + expect(result.needsVision).toBe(true); + expect(result.pageCount).toBe(1); + }); + + it('marks JPG as needing vision with null text', async () => { + const result = await extractText(path.join(FIXTURES_DIR, 'receipt-photo.jpg')); + expect(result.text).toBeNull(); + expect(result.needsVision).toBe(true); + }); + + it('throws for unsupported file type', async () => { + // Create a real .txt file so the error is about file type, not ENOENT + const txtFile = await writeTempFile(tmpDir, 'notes.txt', 'just some notes'); + await expect(extractText(txtFile)).rejects.toThrow('Unsupported file type'); + }); +}); + +describe('readFileAsBase64', () => { + it('returns base64 string', async () => { + const result = await readFileAsBase64(path.join(FIXTURES_DIR, 'receipt-photo.png')); + expect(result).toBeTypeOf('string'); + expect(Buffer.from(result, 'base64').length).toBeGreaterThan(0); + }); +}); + +describe('getMediaType', () => { + it.each([ + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.webp', 'image/webp'], + ['.pdf', 'application/pdf'], + ])('returns correct type for %s', (ext, expected) => { + expect(getMediaType(`file${ext}`)).toBe(expected); + }); + + it('returns octet-stream for unknown type', () => { + expect(getMediaType('file.xyz')).toBe('application/octet-stream'); + }); +}); diff --git a/test/core/ledger.test.ts b/test/core/ledger.test.ts new file mode 100644 index 0000000..266d967 --- /dev/null +++ b/test/core/ledger.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { loadLedger, saveLedger, isAlreadyProcessed, type Ledger } from '../../src/core/ledger.js'; +import { createTempDir, cleanupTempDir } from '../helpers/mock-fs.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await createTempDir(); +}); + +afterEach(async () => { + await cleanupTempDir(tmpDir); +}); + +const sampleEntry = { + processedAt: '2026-03-15T10:00:00.000Z', + movedTo: '/processed/receipt.pdf', + result: { + vendor: 'Amazon', + date: '2026-03-15', + amount: 45.99, + description: 'Towels', + category: 'Supplies', + expenseType: 'operating', + }, + rawResponse: '{"vendor":"Amazon"}', + action: 'confirm' as const, +}; + +describe('loadLedger', () => { + it('returns empty object when ledger file does not exist', async () => { + const ledger = await loadLedger(tmpDir); + expect(ledger).toEqual({}); + }); + + it('loads existing ledger', async () => { + const ledgerDir = path.join(tmpDir, '.r2s'); + await fs.mkdir(ledgerDir, { recursive: true }); + const data: Ledger = { '/inbox/receipt.pdf': sampleEntry }; + await fs.writeFile(path.join(ledgerDir, 'processed.json'), JSON.stringify(data)); + + const ledger = await loadLedger(tmpDir); + expect(ledger['/inbox/receipt.pdf']).toBeDefined(); + expect(ledger['/inbox/receipt.pdf'].result.vendor).toBe('Amazon'); + }); +}); + +describe('saveLedger', () => { + it('creates .r2s directory and saves ledger', async () => { + const data: Ledger = { '/inbox/receipt.pdf': sampleEntry }; + await saveLedger(tmpDir, data); + + const raw = await fs.readFile(path.join(tmpDir, '.r2s', 'processed.json'), 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed['/inbox/receipt.pdf'].result.vendor).toBe('Amazon'); + }); + + it('overwrites existing ledger', async () => { + await saveLedger(tmpDir, { '/a.pdf': sampleEntry }); + await saveLedger(tmpDir, { '/b.pdf': sampleEntry }); + + const raw = await fs.readFile(path.join(tmpDir, '.r2s', 'processed.json'), 'utf-8'); + const parsed = JSON.parse(raw); + expect(parsed['/a.pdf']).toBeUndefined(); + expect(parsed['/b.pdf']).toBeDefined(); + }); +}); + +describe('isAlreadyProcessed', () => { + it('returns true for processed file', () => { + const ledger: Ledger = { '/inbox/receipt.pdf': sampleEntry }; + expect(isAlreadyProcessed(ledger, '/inbox/receipt.pdf')).toBe(true); + }); + + it('returns false for unprocessed file', () => { + const ledger: Ledger = {}; + expect(isAlreadyProcessed(ledger, '/inbox/receipt.pdf')).toBe(false); + }); +}); diff --git a/test/core/parse.test.ts b/test/core/parse.test.ts new file mode 100644 index 0000000..e8ebc0b --- /dev/null +++ b/test/core/parse.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockCreate = vi.fn(); + +vi.mock('@anthropic-ai/sdk', () => ({ + default: vi.fn(() => ({ + messages: { create: mockCreate }, + })), +})); + +import { parseReceipt } from '../../src/core/parse.js'; + +describe('parseReceipt', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const validResponse = JSON.stringify({ + vendor: 'Amazon', + date: '2026-03-15', + amount: 45.99, + description: 'Towels', + category: 'Supplies', + expense_type: 'operating', + }); + + it('parses text content', async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: validResponse }], + }); + + const result = await parseReceipt({ type: 'text', text: 'Invoice text...' }, {}); + expect(result.parsed.vendor).toBe('Amazon'); + expect(result.parsed.amount).toBe(45.99); + expect(result.rawResponse).toBe(validResponse); + }); + + it('parses image content', async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: validResponse }], + }); + + const result = await parseReceipt( + { type: 'image', data: 'base64data', mediaType: 'image/png' }, + {}, + ); + expect(result.parsed.vendor).toBe('Amazon'); + + const callArgs = mockCreate.mock.calls[0][0]; + const userContent = callArgs.messages[0].content; + expect(userContent[0].type).toBe('image'); + expect(userContent[1].type).toBe('text'); + }); + + it('parses document content', async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: validResponse }], + }); + + const result = await parseReceipt( + { type: 'document', data: 'base64pdf', mediaType: 'application/pdf' }, + {}, + ); + expect(result.parsed.vendor).toBe('Amazon'); + + const callArgs = mockCreate.mock.calls[0][0]; + const userContent = callArgs.messages[0].content; + expect(userContent[0].type).toBe('document'); + }); + + it('includes vendor hints when vendors provided', async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: validResponse }], + }); + + await parseReceipt({ type: 'text', text: 'Invoice...' }, { + Amazon: { category: 'Supplies' }, + 'Home Depot': { category: 'Repairs' }, + }); + + const callArgs = mockCreate.mock.calls[0][0]; + const textContent = callArgs.messages[0].content[0].text; + expect(textContent).toContain('Amazon: Supplies'); + expect(textContent).toContain('Home Depot: Repairs'); + }); + + it('throws on invalid JSON response', async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: 'not json' }], + }); + + await expect(parseReceipt({ type: 'text', text: 'test' }, {})).rejects.toThrow(); + }); + + it('throws on Zod validation failure', async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: JSON.stringify({ vendor: '' }) }], + }); + + await expect(parseReceipt({ type: 'text', text: 'test' }, {})).rejects.toThrow(); + }); +}); diff --git a/test/core/spreadsheet.test.ts b/test/core/spreadsheet.test.ts new file mode 100644 index 0000000..e47df46 --- /dev/null +++ b/test/core/spreadsheet.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import ExcelJS from 'exceljs'; +import { ensureSpreadsheet, appendExpense, backupSpreadsheet, copyTemplate } from '../../src/core/spreadsheet.js'; +import { createTempDir, cleanupTempDir } from '../helpers/mock-fs.js'; +import type { Expense } from '../../src/schemas/expense.js'; + +const TEMPLATE_DIR = path.resolve(import.meta.dirname!, '../../templates/spreadsheets'); + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await createTempDir(); +}); + +afterEach(async () => { + await cleanupTempDir(tmpDir); +}); + +function makeExpense(overrides: Partial = {}): Expense { + return { + vendor: 'Test Vendor', + date: '2026-03-15', + amount: 45.99, + description: 'Test expense', + category: 'Supplies', + expenseType: 'operating', + property: 'cabin', + receiptPath: '/tmp/receipt.pdf', + ...overrides, + }; +} + +describe('copyTemplate', () => { + it('copies a template to destination', async () => { + const src = path.join(TEMPLATE_DIR, 'Operating_Expenses.xlsx'); + const dest = path.join(tmpDir, 'spreadsheets', 'test.xlsx'); + await copyTemplate(src, dest); + + const stat = await fs.stat(dest); + expect(stat.size).toBeGreaterThan(0); + }); +}); + +describe('ensureSpreadsheet', () => { + it('creates spreadsheet from template when missing', async () => { + const dest = path.join(tmpDir, 'Expenses.xlsx'); + await ensureSpreadsheet(dest); + + const stat = await fs.stat(dest); + expect(stat.size).toBeGreaterThan(0); + }); + + it('does not overwrite existing spreadsheet', async () => { + const dest = path.join(tmpDir, 'Expenses.xlsx'); + await ensureSpreadsheet(dest); + const stat1 = await fs.stat(dest); + + await ensureSpreadsheet(dest); + const stat2 = await fs.stat(dest); + expect(stat2.mtimeMs).toBe(stat1.mtimeMs); + }); +}); + +describe('appendExpense', () => { + it('adds an expense row to the spreadsheet', async () => { + const dest = path.join(tmpDir, 'Expenses.xlsx'); + await ensureSpreadsheet(dest); + + const expense = makeExpense(); + await appendExpense(dest, expense); + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(dest); + const sheet = workbook.getWorksheet('Expenses'); + expect(sheet).toBeTruthy(); + + // Row 5 is headers, data starts at row 6 (first empty row after headers) + const row = sheet!.getRow(6); + expect(row.getCell(2).value).toBe('Supplies'); + expect(row.getCell(3).value).toBe('Test Vendor'); + expect(row.getCell(4).value).toBe('Test expense'); + expect(row.getCell(5).value).toBe(45.99); + }); + + it('appends multiple expenses to sequential rows', async () => { + const dest = path.join(tmpDir, 'Expenses.xlsx'); + await ensureSpreadsheet(dest); + + await appendExpense(dest, makeExpense({ vendor: 'Vendor A', amount: 10 })); + await appendExpense(dest, makeExpense({ vendor: 'Vendor B', amount: 20 })); + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(dest); + const sheet = workbook.getWorksheet('Expenses')!; + + expect(sheet.getCell(6, 3).value).toBe('Vendor A'); + expect(sheet.getCell(7, 3).value).toBe('Vendor B'); + }); +}); + +describe('backupSpreadsheet', () => { + it('creates a .bak copy', async () => { + const original = path.join(tmpDir, 'Expenses.xlsx'); + await ensureSpreadsheet(original); + + const backupPath = await backupSpreadsheet(original); + expect(backupPath).toBe(path.join(tmpDir, 'Expenses.bak.xlsx')); + + const stat = await fs.stat(backupPath!); + expect(stat.size).toBeGreaterThan(0); + }); + + it('returns null for non-existent file', async () => { + const result = await backupSpreadsheet(path.join(tmpDir, 'nope.xlsx')); + expect(result).toBeNull(); + }); +}); diff --git a/test/e2e/capital-routing.test.ts b/test/e2e/capital-routing.test.ts new file mode 100644 index 0000000..cd146ee --- /dev/null +++ b/test/e2e/capital-routing.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import ExcelJS from 'exceljs'; +import { createTempDir, cleanupTempDir, writeTempFile } from '../helpers/mock-fs.js'; + +const mockCreate = vi.fn(); +vi.mock('@anthropic-ai/sdk', () => ({ + default: vi.fn(() => ({ + messages: { create: mockCreate }, + })), +})); + +vi.mock('chalk', () => ({ + default: { + green: (s: string) => s, + yellow: (s: string) => s, + red: (s: string) => s, + dim: (s: string) => s, + bold: (s: string) => s, + }, +})); + +import { processCommand } from '../../src/commands/process.js'; + +const FIXTURES_DIR = path.resolve(import.meta.dirname!, '../fixtures'); + +const validConfig = ` +version: 1 +properties: + - id: cabin + name: "Test Cabin" + type: short-term-rental +default_property: cabin +spreadsheets: + operating_expenses: "spreadsheets/Operating_Expenses_{year}.xlsx" + capital_improvements: "spreadsheets/Capital_Improvements.xlsx" + year_end_summary: "spreadsheets/Year_End_Summary_{year}.xlsx" +categories: + - Supplies + - Repairs + - Cleaning and maintenance + - Other +inbox: "inbox/" +processed: "processed/" +organize_processed_by: flat +options: + require_confirmation: true + auto_categorize_known_vendors: true + move_processed_receipts: true + backup_spreadsheets: false +`; + +let tmpDir: string; +let originalCwd: () => string; +let consoleSpy: ReturnType; +let stdoutSpy: ReturnType; + +async function setupProject(dir: string) { + await writeTempFile(dir, 'r2s.yaml', validConfig); + await fs.mkdir(path.join(dir, 'inbox'), { recursive: true }); + await fs.mkdir(path.join(dir, 'processed'), { recursive: true }); + await fs.mkdir(path.join(dir, 'spreadsheets'), { recursive: true }); +} + +describe('capital vs operating expense routing', () => { + beforeEach(async () => { + vi.clearAllMocks(); + tmpDir = await createTempDir(); + await setupProject(tmpDir); + originalCwd = process.cwd; + process.cwd = () => tmpDir; + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(async () => { + process.cwd = originalCwd; + consoleSpy.mockRestore(); + stdoutSpy.mockRestore(); + await cleanupTempDir(tmpDir); + }); + + it('routes capital expenses to Capital_Improvements spreadsheet', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/capital-purchase-spa.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'spa-order.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + mockCreate.mockResolvedValueOnce({ + content: [{ + type: 'text', + text: JSON.stringify({ + vendor: 'Alpine Spa', + date: '2025-05-26', + amount: 8350.73, + description: 'Hot tub purchase and installation', + category: 'Other', + expense_type: 'capital', + }), + }], + }); + + await processCommand([], { yes: true }); + + // Capital spreadsheet should exist + const capitalPath = path.join(tmpDir, 'spreadsheets', 'Capital_Improvements.xlsx'); + expect(await fs.stat(capitalPath).then(() => true).catch(() => false)).toBe(true); + + const wb = new ExcelJS.Workbook(); + await wb.xlsx.readFile(capitalPath); + const sheet = wb.getWorksheet('Expenses')!; + expect(sheet.getCell(6, 3).value).toBe('Alpine Spa'); + expect(sheet.getCell(6, 5).value).toBe(8350.73); + + // Operating spreadsheet should NOT exist + const operatingPath = path.join(tmpDir, 'spreadsheets', 'Operating_Expenses_2025.xlsx'); + expect(await fs.stat(operatingPath).then(() => true).catch(() => false)).toBe(false); + }); + + it('routes mixed batch correctly — operating and capital to separate files', async () => { + // Copy two receipts into inbox + const src1 = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const src2 = path.join(FIXTURES_DIR, 'receipts/capital-purchase-spa.pdf'); + await fs.copyFile(src1, path.join(tmpDir, 'inbox', 'landscaping.pdf')); + await fs.copyFile(src2, path.join(tmpDir, 'inbox', 'spa.pdf')); + + // Operating expense response + mockCreate.mockResolvedValueOnce({ + content: [{ + type: 'text', + text: JSON.stringify({ + vendor: 'Ridgeline Landscaping', + date: '2026-04-05', + amount: 259.44, + description: 'Maintenance', + category: 'Cleaning and maintenance', + expense_type: 'operating', + }), + }], + }); + + // Capital expense response + mockCreate.mockResolvedValueOnce({ + content: [{ + type: 'text', + text: JSON.stringify({ + vendor: 'Alpine Spa', + date: '2025-05-26', + amount: 8350.73, + description: 'Hot tub', + category: 'Other', + expense_type: 'capital', + }), + }], + }); + + await processCommand([], { yes: true }); + + // Both spreadsheets should exist + const operatingPath = path.join(tmpDir, 'spreadsheets', 'Operating_Expenses_2026.xlsx'); + const capitalPath = path.join(tmpDir, 'spreadsheets', 'Capital_Improvements.xlsx'); + + expect(await fs.stat(operatingPath).then(() => true).catch(() => false)).toBe(true); + expect(await fs.stat(capitalPath).then(() => true).catch(() => false)).toBe(true); + + // Verify operating spreadsheet + const wbOp = new ExcelJS.Workbook(); + await wbOp.xlsx.readFile(operatingPath); + const sheetOp = wbOp.getWorksheet('Expenses')!; + expect(sheetOp.getCell(6, 3).value).toBe('Ridgeline Landscaping'); + + // Verify capital spreadsheet + const wbCap = new ExcelJS.Workbook(); + await wbCap.xlsx.readFile(capitalPath); + const sheetCap = wbCap.getWorksheet('Expenses')!; + expect(sheetCap.getCell(6, 3).value).toBe('Alpine Spa'); + + // Both receipts moved to processed + const processed = await fs.readdir(path.join(tmpDir, 'processed')); + expect(processed).toHaveLength(2); + }); +}); diff --git a/test/e2e/process-errors.test.ts b/test/e2e/process-errors.test.ts new file mode 100644 index 0000000..09f0dcb --- /dev/null +++ b/test/e2e/process-errors.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { createTempDir, cleanupTempDir, writeTempFile } from '../helpers/mock-fs.js'; + +const mockCreate = vi.fn(); +vi.mock('@anthropic-ai/sdk', () => ({ + default: vi.fn(() => ({ + messages: { create: mockCreate }, + })), +})); + +vi.mock('chalk', () => ({ + default: { + green: (s: string) => s, + yellow: (s: string) => s, + red: (s: string) => s, + dim: (s: string) => s, + bold: (s: string) => s, + }, +})); + +import { processCommand } from '../../src/commands/process.js'; + +const FIXTURES_DIR = path.resolve(import.meta.dirname!, '../fixtures'); + +const validConfig = ` +version: 1 +properties: + - id: cabin + name: "Test Cabin" + type: short-term-rental +default_property: cabin +spreadsheets: + operating_expenses: "spreadsheets/Operating_Expenses_{year}.xlsx" + capital_improvements: "spreadsheets/Capital_Improvements.xlsx" + year_end_summary: "spreadsheets/Year_End_Summary_{year}.xlsx" +categories: + - Supplies + - Repairs + - Other +inbox: "inbox/" +processed: "processed/" +organize_processed_by: flat +options: + require_confirmation: true + auto_categorize_known_vendors: true + move_processed_receipts: true + backup_spreadsheets: false +`; + +let tmpDir: string; +let originalCwd: () => string; +let consoleSpy: ReturnType; +let stdoutSpy: ReturnType; + +async function setupProject(dir: string) { + await writeTempFile(dir, 'r2s.yaml', validConfig); + await fs.mkdir(path.join(dir, 'inbox'), { recursive: true }); + await fs.mkdir(path.join(dir, 'processed'), { recursive: true }); + await fs.mkdir(path.join(dir, 'spreadsheets'), { recursive: true }); +} + +describe('process error handling', () => { + beforeEach(async () => { + vi.clearAllMocks(); + tmpDir = await createTempDir(); + await setupProject(tmpDir); + originalCwd = process.cwd; + process.cwd = () => tmpDir; + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(async () => { + process.cwd = originalCwd; + consoleSpy.mockRestore(); + stdoutSpy.mockRestore(); + await cleanupTempDir(tmpDir); + }); + + it('reports unsupported file types', async () => { + const txtFile = path.join(tmpDir, 'notes.txt'); + await fs.writeFile(txtFile, 'just notes'); + + await processCommand([txtFile], { yes: true }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('unsupported'); + }); + + it('handles API failure gracefully', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + mockCreate.mockRejectedValueOnce(new Error('API rate limit exceeded')); + + await processCommand([], { yes: true }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('failed to parse'); + }); + + it('--force reprocesses already-processed files', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + // Pre-populate ledger + const ledgerDir = path.join(tmpDir, '.r2s'); + await fs.mkdir(ledgerDir, { recursive: true }); + await fs.writeFile( + path.join(ledgerDir, 'processed.json'), + JSON.stringify({ + [inboxPdf]: { + processedAt: new Date().toISOString(), + movedTo: null, + result: { vendor: 'Old', date: '2026-01-01', amount: 0, description: '', category: 'Other', expenseType: 'operating' }, + rawResponse: '{}', + action: 'confirm', + }, + }), + ); + + mockCreate.mockResolvedValueOnce({ + content: [{ + type: 'text', + text: JSON.stringify({ + vendor: 'Ridgeline Landscaping', + date: '2026-04-05', + amount: 259.44, + description: 'Maintenance', + category: 'Repairs', + expense_type: 'operating', + }), + }], + }); + + await processCommand([], { yes: true, force: true }); + + // The API should have been called (not skipped) + expect(mockCreate).toHaveBeenCalledOnce(); + }); + + it('--dry-run makes no changes', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + mockCreate.mockResolvedValueOnce({ + content: [{ + type: 'text', + text: JSON.stringify({ + vendor: 'Ridgeline', + date: '2026-04-05', + amount: 259.44, + description: 'Maintenance', + category: 'Repairs', + expense_type: 'operating', + }), + }], + }); + + await processCommand([], { dryRun: true }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('dry run'); + + // No spreadsheet created + const spreadsheet = path.join(tmpDir, 'spreadsheets', 'Operating_Expenses_2026.xlsx'); + expect(await fs.stat(spreadsheet).then(() => true).catch(() => false)).toBe(false); + + // Receipt not moved + expect(await fs.stat(inboxPdf).then(() => true).catch(() => false)).toBe(true); + + // No ledger created + expect( + await fs.stat(path.join(tmpDir, '.r2s', 'processed.json')).then(() => true).catch(() => false), + ).toBe(false); + }); +}); diff --git a/test/e2e/process-flow.test.ts b/test/e2e/process-flow.test.ts new file mode 100644 index 0000000..a13692f --- /dev/null +++ b/test/e2e/process-flow.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import ExcelJS from 'exceljs'; +import { createTempDir, cleanupTempDir, writeTempFile } from '../helpers/mock-fs.js'; + +// Mock the Anthropic SDK before importing processCommand +const mockCreate = vi.fn(); +vi.mock('@anthropic-ai/sdk', () => ({ + default: vi.fn(() => ({ + messages: { create: mockCreate }, + })), +})); + +// Mock chalk to disable colors for predictable output +vi.mock('chalk', () => ({ + default: { + green: (s: string) => s, + yellow: (s: string) => s, + red: (s: string) => s, + dim: (s: string) => s, + bold: (s: string) => s, + }, +})); + +import { processCommand } from '../../src/commands/process.js'; + +const FIXTURES_DIR = path.resolve(import.meta.dirname!, '../fixtures'); +const TEMPLATE_DIR = path.resolve(import.meta.dirname!, '../../templates/spreadsheets'); + +const validConfig = ` +version: 1 +properties: + - id: cabin + name: "Test Cabin" + type: short-term-rental +default_property: cabin +spreadsheets: + operating_expenses: "spreadsheets/Operating_Expenses_{year}.xlsx" + capital_improvements: "spreadsheets/Capital_Improvements.xlsx" + year_end_summary: "spreadsheets/Year_End_Summary_{year}.xlsx" +categories: + - Supplies + - Repairs + - Cleaning and maintenance + - Utilities + - Other +inbox: "inbox/" +processed: "processed/" +organize_processed_by: flat +options: + require_confirmation: true + auto_categorize_known_vendors: true + move_processed_receipts: true + backup_spreadsheets: false +`; + +let tmpDir: string; +let originalCwd: () => string; +let consoleSpy: ReturnType; +let stdoutSpy: ReturnType; + +async function setupProject(dir: string) { + await writeTempFile(dir, 'r2s.yaml', validConfig); + await fs.mkdir(path.join(dir, 'inbox'), { recursive: true }); + await fs.mkdir(path.join(dir, 'processed'), { recursive: true }); + await fs.mkdir(path.join(dir, 'spreadsheets'), { recursive: true }); +} + +function mockApiResponse(response: Record) { + mockCreate.mockResolvedValueOnce({ + content: [{ type: 'text', text: JSON.stringify(response) }], + }); +} + +describe('process flow', () => { + beforeEach(async () => { + vi.clearAllMocks(); + tmpDir = await createTempDir(); + await setupProject(tmpDir); + originalCwd = process.cwd; + process.cwd = () => tmpDir; + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(async () => { + process.cwd = originalCwd; + consoleSpy.mockRestore(); + stdoutSpy.mockRestore(); + await cleanupTempDir(tmpDir); + }); + + it('processes a text PDF from inbox with --yes', async () => { + // Copy fixture receipt to inbox + const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + mockApiResponse({ + vendor: 'Ridgeline Landscaping', + date: '2026-04-05', + amount: 259.44, + description: 'Landscape maintenance', + category: 'Cleaning and maintenance', + expense_type: 'operating', + }); + + await processCommand([], { yes: true }); + + // Verify spreadsheet was created with the expense + const spreadsheet = path.join(tmpDir, 'spreadsheets', 'Operating_Expenses_2026.xlsx'); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.readFile(spreadsheet); + const sheet = wb.getWorksheet('Expenses')!; + // Find the data row (first non-null row after headers) + let dataRow = 5; + while (sheet.getCell(dataRow, 1).value !== null && typeof sheet.getCell(dataRow, 1).value === 'string') { + dataRow++; + } + // The first empty row becomes the data row after append + expect(sheet.getCell(6, 3).value).toBe('Ridgeline Landscaping'); + expect(sheet.getCell(6, 5).value).toBe(259.44); + + // Verify receipt moved to processed/ + const movedFile = path.join(tmpDir, 'processed', 'landscaping.pdf'); + expect(await fs.stat(movedFile).then(() => true).catch(() => false)).toBe(true); + + // Verify ledger updated + const ledgerRaw = await fs.readFile(path.join(tmpDir, '.r2s', 'processed.json'), 'utf-8'); + const ledger = JSON.parse(ledgerRaw); + const entry = Object.values(ledger)[0] as Record; + expect(entry).toBeDefined(); + expect((entry.result as Record).vendor).toBe('Ridgeline Landscaping'); + }); + + it('processes specific files passed as arguments', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/ecommerce-order-amazon.pdf'); + const targetPdf = path.join(tmpDir, 'some-receipt.pdf'); + await fs.copyFile(srcPdf, targetPdf); + + mockApiResponse({ + vendor: 'Amazon', + date: '2026-01-05', + amount: 89.52, + description: 'Ring Pathlight Battery', + category: 'Supplies', + expense_type: 'operating', + }); + + await processCommand([targetPdf], { yes: true }); + + // Verify spreadsheet created + const spreadsheet = path.join(tmpDir, 'spreadsheets', 'Operating_Expenses_2026.xlsx'); + expect(await fs.stat(spreadsheet).then(() => true).catch(() => false)).toBe(true); + }); + + it('shows no receipts message when inbox is empty', async () => { + await processCommand([], { yes: true }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('No receipts to process'); + }); + + it('skips already-processed files', async () => { + const srcPdf = path.join(FIXTURES_DIR, 'receipts/service-invoice-landscaping.pdf'); + const inboxPdf = path.join(tmpDir, 'inbox', 'landscaping.pdf'); + await fs.copyFile(srcPdf, inboxPdf); + + // Pre-populate ledger + const ledgerDir = path.join(tmpDir, '.r2s'); + await fs.mkdir(ledgerDir, { recursive: true }); + await fs.writeFile( + path.join(ledgerDir, 'processed.json'), + JSON.stringify({ + [inboxPdf]: { + processedAt: new Date().toISOString(), + movedTo: null, + result: { vendor: 'Test', date: '2026-01-01', amount: 0, description: '', category: 'Other', expenseType: 'operating' }, + rawResponse: '{}', + action: 'confirm', + }, + }), + ); + + await processCommand([], { yes: true }); + + const output = consoleSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('already-processed'); + expect(mockCreate).not.toHaveBeenCalled(); + }); +}); diff --git a/test/fixtures/api-responses/capital-expense.json b/test/fixtures/api-responses/capital-expense.json new file mode 100644 index 0000000..b59cb28 --- /dev/null +++ b/test/fixtures/api-responses/capital-expense.json @@ -0,0 +1,8 @@ +{ + "vendor": "Alpine Spa & Hot Tub", + "date": "2025-05-26", + "amount": 8350.73, + "description": "Hot tub purchase and installation - Warrior XL with accessories and delivery", + "category": "Other", + "expense_type": "capital" +} diff --git a/test/fixtures/api-responses/ecommerce-order-amazon.json b/test/fixtures/api-responses/ecommerce-order-amazon.json new file mode 100644 index 0000000..449e4ce --- /dev/null +++ b/test/fixtures/api-responses/ecommerce-order-amazon.json @@ -0,0 +1,8 @@ +{ + "vendor": "Amazon", + "date": "2026-01-05", + "amount": 89.52, + "description": "Ring Pathlight Battery - Motion-activated pathlight (qty 3) with Ring Protect discount", + "category": "Supplies", + "expense_type": "operating" +} diff --git a/test/fixtures/api-responses/not-an-invoice-service-agreement.json b/test/fixtures/api-responses/not-an-invoice-service-agreement.json new file mode 100644 index 0000000..8e94620 --- /dev/null +++ b/test/fixtures/api-responses/not-an-invoice-service-agreement.json @@ -0,0 +1,8 @@ +{ + "vendor": "Mountain Valley Electric Co.", + "date": "2024-08-16", + "amount": 0, + "description": "Servisavor installation agreement - emergency power equipment, not an invoice", + "category": "Utilities", + "expense_type": "operating" +} diff --git a/test/fixtures/api-responses/payment-processor-pest-control.json b/test/fixtures/api-responses/payment-processor-pest-control.json new file mode 100644 index 0000000..e46c405 --- /dev/null +++ b/test/fixtures/api-responses/payment-processor-pest-control.json @@ -0,0 +1,8 @@ +{ + "vendor": "Summit Pest Control", + "date": "2024-02-19", + "amount": 116.91, + "description": "Pest control service payment - includes $3.40 credit card surcharge", + "category": "Cleaning and maintenance", + "expense_type": "operating" +} diff --git a/test/fixtures/api-responses/service-invoice-landscaping.json b/test/fixtures/api-responses/service-invoice-landscaping.json new file mode 100644 index 0000000..adbefe0 --- /dev/null +++ b/test/fixtures/api-responses/service-invoice-landscaping.json @@ -0,0 +1,8 @@ +{ + "vendor": "Ridgeline Landscaping LLC", + "date": "2026-04-05", + "amount": 259.44, + "description": "Two maintenance visits in March - landscaping service plus sales tax", + "category": "Cleaning and maintenance", + "expense_type": "operating" +} diff --git a/test/fixtures/configs/minimal-config.yaml b/test/fixtures/configs/minimal-config.yaml new file mode 100644 index 0000000..7531454 --- /dev/null +++ b/test/fixtures/configs/minimal-config.yaml @@ -0,0 +1,15 @@ +properties: + - id: rental + name: "My Rental" + type: long-term-rental + +default_property: rental + +spreadsheets: + operating_expenses: "spreadsheets/Operating_Expenses_{year}.xlsx" + capital_improvements: "spreadsheets/Capital_Improvements.xlsx" + year_end_summary: "spreadsheets/Year_End_Summary_{year}.xlsx" + +categories: + - Supplies + - Repairs diff --git a/test/fixtures/configs/valid-config.yaml b/test/fixtures/configs/valid-config.yaml new file mode 100644 index 0000000..2206581 --- /dev/null +++ b/test/fixtures/configs/valid-config.yaml @@ -0,0 +1,49 @@ +version: 1 + +properties: + - id: cabin + name: "Lake Cabin" + location: "Lake Tahoe, CA" + type: short-term-rental + placed_in_service: "2026-01" + +default_property: cabin + +spreadsheets: + operating_expenses: "spreadsheets/Operating_Expenses_{year}.xlsx" + capital_improvements: "spreadsheets/Capital_Improvements.xlsx" + year_end_summary: "spreadsheets/Year_End_Summary_{year}.xlsx" + +categories: + - Advertising + - Auto and travel + - Cleaning and maintenance + - Commissions + - Insurance + - Legal and professional fees + - Management fees + - Mortgage interest + - Other interest + - Repairs + - Supplies + - Taxes + - Utilities + - Depreciation + - Other + +vendors: + Amazon: + category: Supplies + aliases: ["AMAZON.COM", "AMZN"] + "Evergreen Hardware & Home": + category: Supplies + +inbox: "inbox/" +processed: "processed/" +organize_processed_by: "year-month" + +options: + require_confirmation: true + auto_categorize_known_vendors: true + move_processed_receipts: true + backup_spreadsheets: true diff --git a/test/fixtures/receipts/capital-purchase-spa.pdf b/test/fixtures/receipts/capital-purchase-spa.pdf new file mode 100644 index 0000000..4ab8be9 Binary files /dev/null and b/test/fixtures/receipts/capital-purchase-spa.pdf differ diff --git a/test/fixtures/receipts/ecommerce-order-amazon.pdf b/test/fixtures/receipts/ecommerce-order-amazon.pdf new file mode 100644 index 0000000..e8d84bd Binary files /dev/null and b/test/fixtures/receipts/ecommerce-order-amazon.pdf differ diff --git a/test/fixtures/receipts/empty.pdf b/test/fixtures/receipts/empty.pdf new file mode 100644 index 0000000..c4c5eea Binary files /dev/null and b/test/fixtures/receipts/empty.pdf differ diff --git a/test/fixtures/receipts/not-an-invoice-service-agreement.pdf b/test/fixtures/receipts/not-an-invoice-service-agreement.pdf new file mode 100644 index 0000000..35146f1 Binary files /dev/null and b/test/fixtures/receipts/not-an-invoice-service-agreement.pdf differ diff --git a/test/fixtures/receipts/payment-processor-pest-control.pdf b/test/fixtures/receipts/payment-processor-pest-control.pdf new file mode 100644 index 0000000..c91b86c Binary files /dev/null and b/test/fixtures/receipts/payment-processor-pest-control.pdf differ diff --git a/test/fixtures/receipts/receipt-photo.jpg b/test/fixtures/receipts/receipt-photo.jpg new file mode 100644 index 0000000..aca3cda Binary files /dev/null and b/test/fixtures/receipts/receipt-photo.jpg differ diff --git a/test/fixtures/receipts/receipt-photo.png b/test/fixtures/receipts/receipt-photo.png new file mode 100644 index 0000000..5e0b6d0 Binary files /dev/null and b/test/fixtures/receipts/receipt-photo.png differ diff --git a/test/fixtures/receipts/service-invoice-landscaping.pdf b/test/fixtures/receipts/service-invoice-landscaping.pdf new file mode 100644 index 0000000..f52faf3 Binary files /dev/null and b/test/fixtures/receipts/service-invoice-landscaping.pdf differ diff --git a/test/helpers/index.ts b/test/helpers/index.ts new file mode 100644 index 0000000..f2f2b56 --- /dev/null +++ b/test/helpers/index.ts @@ -0,0 +1,2 @@ +export { createTempDir, cleanupTempDir, writeTempFile } from './mock-fs.js'; +export { createMockAnthropicClient, loadApiResponse } from './mock-anthropic.js'; diff --git a/test/helpers/mock-anthropic.ts b/test/helpers/mock-anthropic.ts new file mode 100644 index 0000000..1b517e7 --- /dev/null +++ b/test/helpers/mock-anthropic.ts @@ -0,0 +1,22 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const FIXTURES_DIR = path.resolve(__dirname, '../fixtures/api-responses'); + +export function createMockAnthropicClient(responseJson: string) { + return { + messages: { + create: async () => ({ + content: [{ type: 'text', text: responseJson }], + }), + }, + }; +} + +export async function loadApiResponse(name: string): Promise { + const filePath = path.join(FIXTURES_DIR, `${name}.json`); + return await fs.readFile(filePath, 'utf-8'); +} diff --git a/test/helpers/mock-fs.ts b/test/helpers/mock-fs.ts new file mode 100644 index 0000000..8d7c617 --- /dev/null +++ b/test/helpers/mock-fs.ts @@ -0,0 +1,22 @@ +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +export async function createTempDir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), 'r2s-test-')); +} + +export async function cleanupTempDir(dir: string): Promise { + await fs.rm(dir, { recursive: true, force: true }); +} + +export async function writeTempFile( + dir: string, + relativePath: string, + content: string | Buffer, +): Promise { + const fullPath = path.join(dir, relativePath); + await fs.mkdir(path.dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content); + return fullPath; +} diff --git a/test/schemas.test.ts b/test/schemas/schemas.test.ts similarity index 92% rename from test/schemas.test.ts rename to test/schemas/schemas.test.ts index 348f2a4..3c10415 100644 --- a/test/schemas.test.ts +++ b/test/schemas/schemas.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { ExpenseSchema, CategorySchema } from '../src/schemas/expense.js'; -import { ConfigSchema } from '../src/schemas/config.js'; -import { ReceiptParseResultSchema } from '../src/schemas/receipt.js'; +import { ExpenseSchema, CategorySchema } from '../../src/schemas/expense.js'; +import { ConfigSchema } from '../../src/schemas/config.js'; +import { ReceiptParseResultSchema } from '../../src/schemas/receipt.js'; describe('ExpenseSchema', () => { it('validates a valid expense', () => { @@ -30,7 +30,7 @@ describe('ExpenseSchema', () => { it('allows zero amount (agreements without invoiced amount)', () => { const result = ExpenseSchema.parse({ - vendor: 'Peninsula Light', + vendor: 'Mountain Valley Electric', date: '2026-05-12', amount: 0, description: 'Service agreement', diff --git a/test/utils/currency.test.ts b/test/utils/currency.test.ts new file mode 100644 index 0000000..6c56c40 --- /dev/null +++ b/test/utils/currency.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { formatDollars, parseDollars } from '../../src/utils/currency.js'; + +describe('formatDollars', () => { + it('formats a positive amount', () => { + expect(formatDollars(45.99)).toBe('$45.99'); + }); + + it('formats zero', () => { + expect(formatDollars(0)).toBe('$0.00'); + }); + + it('formats a large amount', () => { + expect(formatDollars(12345.6)).toBe('$12345.60'); + }); + + it('rounds to two decimal places', () => { + expect(formatDollars(1.999)).toBe('$2.00'); + }); +}); + +describe('parseDollars', () => { + it('parses a plain number string', () => { + expect(parseDollars('45.99')).toBe(45.99); + }); + + it('parses with dollar sign', () => { + expect(parseDollars('$45.99')).toBe(45.99); + }); + + it('parses with commas', () => { + expect(parseDollars('$1,234.56')).toBe(1234.56); + }); + + it('parses with leading/trailing whitespace', () => { + expect(parseDollars(' $99.00 ')).toBe(99); + }); + + it('returns null for invalid input', () => { + expect(parseDollars('not a number')).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(parseDollars('')).toBeNull(); + }); + + it('parses zero', () => { + expect(parseDollars('$0.00')).toBe(0); + }); +}); diff --git a/test/utils/dates.test.ts b/test/utils/dates.test.ts new file mode 100644 index 0000000..d3bdf37 --- /dev/null +++ b/test/utils/dates.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { currentYear, currentYearMonth, formatDate } from '../../src/utils/dates.js'; + +describe('currentYear', () => { + it('returns the current year as a number', () => { + const year = currentYear(); + expect(year).toBeTypeOf('number'); + expect(year).toBeGreaterThanOrEqual(2026); + }); +}); + +describe('currentYearMonth', () => { + it('returns YYYY-MM format', () => { + const ym = currentYearMonth(); + expect(ym).toMatch(/^\d{4}-\d{2}$/); + }); +}); + +describe('formatDate', () => { + it('formats a date as YYYY-MM-DD', () => { + expect(formatDate(new Date(2026, 0, 5))).toBe('2026-01-05'); + }); + + it('pads single-digit months and days', () => { + expect(formatDate(new Date(2026, 2, 1))).toBe('2026-03-01'); + }); + + it('handles December 31', () => { + expect(formatDate(new Date(2026, 11, 31))).toBe('2026-12-31'); + }); +}); diff --git a/test/utils/files.test.ts b/test/utils/files.test.ts new file mode 100644 index 0000000..8d0e01c --- /dev/null +++ b/test/utils/files.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import fs from 'fs/promises'; +import { ensureDir, fileExists, truncate, isSupportedFile, listInboxFiles } from '../../src/utils/files.js'; +import { createTempDir, cleanupTempDir } from '../helpers/mock-fs.js'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await createTempDir(); +}); + +afterEach(async () => { + await cleanupTempDir(tmpDir); +}); + +describe('ensureDir', () => { + it('creates a new directory', async () => { + const dir = path.join(tmpDir, 'new-dir'); + await ensureDir(dir); + const stat = await fs.stat(dir); + expect(stat.isDirectory()).toBe(true); + }); + + it('is idempotent', async () => { + const dir = path.join(tmpDir, 'existing'); + await ensureDir(dir); + await ensureDir(dir); // should not throw + const stat = await fs.stat(dir); + expect(stat.isDirectory()).toBe(true); + }); + + it('creates nested directories', async () => { + const dir = path.join(tmpDir, 'a', 'b', 'c'); + await ensureDir(dir); + const stat = await fs.stat(dir); + expect(stat.isDirectory()).toBe(true); + }); +}); + +describe('fileExists', () => { + it('returns true for existing file', async () => { + const filePath = path.join(tmpDir, 'test.txt'); + await fs.writeFile(filePath, 'hello'); + expect(await fileExists(filePath)).toBe(true); + }); + + it('returns false for non-existent file', async () => { + expect(await fileExists(path.join(tmpDir, 'nope.txt'))).toBe(false); + }); +}); + +describe('truncate', () => { + it('returns short strings unchanged', () => { + expect(truncate('hello', 10)).toBe('hello'); + }); + + it('truncates long strings with ..', () => { + // slice(0, 8-2) = 'hello ' + '..' = 'hello ..' + expect(truncate('hello world', 8)).toBe('hello ..'); + }); + + it('returns string at exact max length', () => { + expect(truncate('hello', 5)).toBe('hello'); + }); +}); + +describe('isSupportedFile', () => { + it.each(['.pdf', '.png', '.jpg', '.jpeg', '.webp'])('accepts %s', (ext) => { + expect(isSupportedFile(`receipt${ext}`)).toBe(true); + }); + + it.each(['.txt', '.doc', '.docx', '.csv', '.html'])('rejects %s', (ext) => { + expect(isSupportedFile(`file${ext}`)).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isSupportedFile('receipt.PDF')).toBe(true); + expect(isSupportedFile('photo.JPG')).toBe(true); + }); +}); + +describe('listInboxFiles', () => { + it('lists only supported files', async () => { + await fs.writeFile(path.join(tmpDir, 'receipt.pdf'), 'fake'); + await fs.writeFile(path.join(tmpDir, 'photo.png'), 'fake'); + await fs.writeFile(path.join(tmpDir, 'notes.txt'), 'fake'); + await fs.writeFile(path.join(tmpDir, '.DS_Store'), 'fake'); + + const files = await listInboxFiles(tmpDir); + expect(files).toHaveLength(2); + expect(files.every((f) => f.endsWith('.pdf') || f.endsWith('.png'))).toBe(true); + }); + + it('returns sorted paths', async () => { + await fs.writeFile(path.join(tmpDir, 'b.pdf'), 'fake'); + await fs.writeFile(path.join(tmpDir, 'a.pdf'), 'fake'); + + const files = await listInboxFiles(tmpDir); + expect(path.basename(files[0])).toBe('a.pdf'); + expect(path.basename(files[1])).toBe('b.pdf'); + }); + + it('returns empty array for non-existent directory', async () => { + const files = await listInboxFiles(path.join(tmpDir, 'nonexistent')); + expect(files).toEqual([]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..445cd19 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/index.ts'], + thresholds: { + // Current: 54% statements, 80% branches, 83% functions + // The gap is from untested interactive CLI commands (doctor, init, confirm). + // Raise thresholds as those get tested. + statements: 50, + lines: 50, + functions: 60, + branches: 60, + }, + }, + }, +});