From 7e9e6831efd264e1b9d6af8edef9adb4f6d1a6e3 Mon Sep 17 00:00:00 2001 From: Jon Klein Date: Wed, 13 May 2026 21:48:24 -0400 Subject: [PATCH] test: add Vitest setup with SiteManager and tool-registry coverage Establishes a foundation test suite using Vitest. ESM/TypeScript tests run via esbuild without needing a prior tsc build. - SiteManager: env parsing (numbered + legacy), getSite lookup, and detectSiteFromContext (domain/alias/id matching). 17 tests. - Tool registry wiring: every tool has a handler, names are unique, schemas are object-shaped with properties. 7 tests. - GitHub Actions workflow runs npm test on PRs and pushes to main across Node 20 and 22. - README documents the new test commands. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/test.yml | 29 ++++ README.md | 12 ++ package.json | 5 +- tests/config/site-manager.test.ts | 219 ++++++++++++++++++++++++++++++ tests/tools/wiring.test.ts | 54 ++++++++ vitest.config.ts | 12 ++ 6 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/config/site-manager.test.ts create mode 100644 tests/tools/wiring.test.ts create mode 100644 vitest.config.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..85072eb --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: Node ${{ matrix.node-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['20.x', '22.x'] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + # --ignore-scripts skips the `prepare` hook (which runs `npm run build`). + # Vitest uses esbuild and does not require a prior `tsc` build. + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Run tests + run: npm test diff --git a/README.md b/README.md index e9ae91d..7c4c88c 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,18 @@ or in development mode: npm run dev ``` +### Running Tests + +The repo uses [Vitest](https://vitest.dev/) for unit tests. Tests live under `tests/` and cover the +multi-site `SiteManager` and the MCP tool registry wiring. + +```bash +npm test # one-shot run +npm run test:watch # watch mode +``` + +Tests run on `pull_request` and on pushes to `main` via `.github/workflows/test.yml`. + ### Security - **Never commit your API keys or secrets to version control.** diff --git a/package.json b/package.json index fbe7624..81e17ff 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "start": "node ./build/server.js", "dev": "tsx watch src/server.ts", "clean": "rimraf build", + "test": "vitest run", + "test:watch": "vitest", "prepare": "npm run build" }, "keywords": [ @@ -43,7 +45,8 @@ "@types/node": "^22.10.0", "rimraf": "^5.0.5", "tsx": "^4.7.1", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "vitest": "^2.1.9" }, "publishConfig": { "access": "public" diff --git a/tests/config/site-manager.test.ts b/tests/config/site-manager.test.ts new file mode 100644 index 0000000..6c8961b --- /dev/null +++ b/tests/config/site-manager.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SiteManager } from '../../src/config/site-manager.js'; + +const WP_KEYS: string[] = [ + 'WORDPRESS_API_URL', + 'WORDPRESS_USERNAME', + 'WORDPRESS_PASSWORD', +]; +for (let i = 1; i <= 10; i++) { + WP_KEYS.push( + `WORDPRESS_${i}_URL`, + `WORDPRESS_${i}_USERNAME`, + `WORDPRESS_${i}_PASSWORD`, + `WORDPRESS_${i}_ID`, + `WORDPRESS_${i}_ALIASES`, + `WORDPRESS_${i}_DEFAULT`, + ); +} + +let envBackup: Record; + +beforeEach(() => { + envBackup = {}; + for (const key of WP_KEYS) { + envBackup[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of WP_KEYS) { + const original = envBackup[key]; + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } + } +}); + +describe('SiteManager environment loading', () => { + it('throws a helpful error when no configuration is set', () => { + const sm = new SiteManager(); + expect(() => sm.getAllSites()).toThrow(/No WordPress configuration/); + }); + + it('loads a single numbered site and makes it the default', () => { + process.env.WORDPRESS_1_URL = 'https://one.test'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + process.env.WORDPRESS_1_PASSWORD = 'pw'; + + const sm = new SiteManager(); + const sites = sm.getAllSites(); + + expect(sites).toHaveLength(1); + expect(sites[0].id).toBe('site1'); + expect(sites[0].url).toBe('https://one.test'); + expect(sm.getDefaultSiteId()).toBe('site1'); + }); + + it('uses WORDPRESS_N_ID when provided', () => { + process.env.WORDPRESS_1_URL = 'https://prod.test'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + process.env.WORDPRESS_1_PASSWORD = 'pw'; + process.env.WORDPRESS_1_ID = 'production'; + + const sm = new SiteManager(); + expect(sm.getDefaultSiteId()).toBe('production'); + expect(sm.getSite('production').url).toBe('https://prod.test'); + }); + + it('respects WORDPRESS_N_DEFAULT=true to override the first-site default', () => { + process.env.WORDPRESS_1_URL = 'https://prod.test'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + process.env.WORDPRESS_1_PASSWORD = 'pw'; + process.env.WORDPRESS_1_ID = 'production'; + + process.env.WORDPRESS_2_URL = 'https://staging.test'; + process.env.WORDPRESS_2_USERNAME = 'admin'; + process.env.WORDPRESS_2_PASSWORD = 'pw'; + process.env.WORDPRESS_2_ID = 'staging'; + process.env.WORDPRESS_2_DEFAULT = 'true'; + + const sm = new SiteManager(); + expect(sm.getDefaultSiteId()).toBe('staging'); + }); + + it('parses comma-separated aliases with whitespace trimming', () => { + process.env.WORDPRESS_1_URL = 'https://one.test'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + process.env.WORDPRESS_1_PASSWORD = 'pw'; + process.env.WORDPRESS_1_ALIASES = 'prod, main, primary'; + + const sm = new SiteManager(); + expect(sm.getSite('site1').aliases).toEqual(['prod', 'main', 'primary']); + }); + + it('falls back to legacy single-site variables when no numbered sites exist', () => { + process.env.WORDPRESS_API_URL = 'https://legacy.test'; + process.env.WORDPRESS_USERNAME = 'admin'; + process.env.WORDPRESS_PASSWORD = 'pw'; + + const sm = new SiteManager(); + const sites = sm.getAllSites(); + + expect(sites).toHaveLength(1); + expect(sites[0].id).toBe('default'); + expect(sm.getDefaultSiteId()).toBe('default'); + }); + + it('prefers numbered sites over legacy variables when both are present', () => { + process.env.WORDPRESS_1_URL = 'https://numbered.test'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + process.env.WORDPRESS_1_PASSWORD = 'pw'; + + process.env.WORDPRESS_API_URL = 'https://legacy.test'; + process.env.WORDPRESS_USERNAME = 'admin'; + process.env.WORDPRESS_PASSWORD = 'pw'; + + const sm = new SiteManager(); + const sites = sm.getAllSites(); + + expect(sites).toHaveLength(1); + expect(sites[0].id).toBe('site1'); + }); + + it('skips numbered slots that are missing any required field', () => { + process.env.WORDPRESS_1_URL = 'https://one.test'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + // password missing — slot 1 should be skipped + + process.env.WORDPRESS_2_URL = 'https://two.test'; + process.env.WORDPRESS_2_USERNAME = 'admin'; + process.env.WORDPRESS_2_PASSWORD = 'pw'; + + const sm = new SiteManager(); + const sites = sm.getAllSites(); + + expect(sites.map((s) => s.url)).toEqual(['https://two.test']); + }); +}); + +describe('SiteManager.getSite', () => { + beforeEach(() => { + process.env.WORDPRESS_1_URL = 'https://prod.test'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + process.env.WORDPRESS_1_PASSWORD = 'pw'; + process.env.WORDPRESS_1_ID = 'production'; + + process.env.WORDPRESS_2_URL = 'https://staging.test'; + process.env.WORDPRESS_2_USERNAME = 'admin'; + process.env.WORDPRESS_2_PASSWORD = 'pw'; + process.env.WORDPRESS_2_ID = 'staging'; + }); + + it('returns the default site when called with no id', () => { + const sm = new SiteManager(); + expect(sm.getSite().id).toBe('production'); + }); + + it('returns the named site when given an id', () => { + const sm = new SiteManager(); + expect(sm.getSite('staging').id).toBe('staging'); + }); + + it('throws a helpful error for unknown ids', () => { + const sm = new SiteManager(); + expect(() => sm.getSite('nope')).toThrow(/not found/); + expect(() => sm.getSite('nope')).toThrow(/production/); + expect(() => sm.getSite('nope')).toThrow(/staging/); + }); +}); + +describe('SiteManager.detectSiteFromContext', () => { + beforeEach(() => { + process.env.WORDPRESS_1_URL = 'https://example.com'; + process.env.WORDPRESS_1_USERNAME = 'admin'; + process.env.WORDPRESS_1_PASSWORD = 'pw'; + process.env.WORDPRESS_1_ID = 'production'; + process.env.WORDPRESS_1_ALIASES = 'prod,main'; + + process.env.WORDPRESS_2_URL = 'https://staging.example.org'; + process.env.WORDPRESS_2_USERNAME = 'admin'; + process.env.WORDPRESS_2_PASSWORD = 'pw'; + process.env.WORDPRESS_2_ID = 'staging'; + }); + + it('detects a site by hostname mentioned in the request', () => { + const sm = new SiteManager(); + expect(sm.detectSiteFromContext('Please update https://example.com/about')).toBe( + 'production', + ); + }); + + it('detects a site by alias mention', () => { + const sm = new SiteManager(); + expect(sm.detectSiteFromContext('Publish on prod')).toBe('production'); + }); + + it('detects a site by id mention', () => { + const sm = new SiteManager(); + expect(sm.detectSiteFromContext('Run this on staging')).toBe('staging'); + }); + + it('matches case-insensitively', () => { + const sm = new SiteManager(); + expect(sm.detectSiteFromContext('Push to PROD now')).toBe('production'); + }); + + it('returns null when nothing matches', () => { + const sm = new SiteManager(); + expect(sm.detectSiteFromContext('Just some unrelated text')).toBeNull(); + }); + + it('returns null for empty input', () => { + const sm = new SiteManager(); + expect(sm.detectSiteFromContext('')).toBeNull(); + }); +}); diff --git a/tests/tools/wiring.test.ts b/tests/tools/wiring.test.ts new file mode 100644 index 0000000..1e76db4 --- /dev/null +++ b/tests/tools/wiring.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { allTools, toolHandlers } from '../../src/tools/index.js'; + +describe('tool registry wiring', () => { + it('exposes at least one tool', () => { + expect(allTools.length).toBeGreaterThan(0); + }); + + it('has a handler for every registered tool', () => { + const handlerNames = new Set(Object.keys(toolHandlers)); + const missing = allTools.map((t) => t.name).filter((name) => !handlerNames.has(name)); + expect(missing).toEqual([]); + }); + + it('has no orphan handlers without a corresponding tool definition', () => { + const toolNames = new Set(allTools.map((t) => t.name)); + const orphans = Object.keys(toolHandlers).filter((name) => !toolNames.has(name)); + expect(orphans).toEqual([]); + }); + + it('has unique tool names', () => { + const counts = new Map(); + for (const tool of allTools) { + counts.set(tool.name, (counts.get(tool.name) ?? 0) + 1); + } + const duplicates = [...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name); + expect(duplicates).toEqual([]); + }); + + it('has a non-empty name and description on every tool', () => { + for (const tool of allTools) { + expect(tool.name, 'tool name').toBeTruthy(); + expect(tool.description, `description for ${tool.name}`).toBeTruthy(); + } + }); + + it('declares an object inputSchema with a properties record on every tool', () => { + for (const tool of allTools) { + expect(tool.inputSchema, `inputSchema for ${tool.name}`).toBeDefined(); + expect(tool.inputSchema.type, `inputSchema.type for ${tool.name}`).toBe('object'); + expect( + typeof tool.inputSchema.properties, + `inputSchema.properties for ${tool.name}`, + ).toBe('object'); + expect(tool.inputSchema.properties, `inputSchema.properties for ${tool.name}`).not.toBeNull(); + } + }); + + it('exposes each handler as a function', () => { + for (const [name, handler] of Object.entries(toolHandlers)) { + expect(typeof handler, `handler ${name}`).toBe('function'); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..f1598e9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + clearMocks: true, + env: { + DISABLE_LOGGING: 'true', + }, + }, +});