Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
Expand Down
219 changes: 219 additions & 0 deletions tests/config/site-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

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();
});
});
54 changes: 54 additions & 0 deletions tests/tools/wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();
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');
}
});
});
12 changes: 12 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
});