Files
factory/lib/projects-meta-mcp/tests/tools/knowledge-ask-projects.test.ts
vitya ca669d96e1 feat(lib): bundle MCP servers from .common/lib into factory/lib/
Copies source (no node_modules, dist, .tasks, .wiki, __pycache__) for:
- projects-meta-mcp v2.25.0 (TypeScript/Node)
- wiki-graph v0.3.1 (TypeScript/Node)
- interns-mcp v0.3.3 (Python/FastMCP)

.gitignore: exclude lib build artefacts (node_modules, dist, .venv, __pycache__, *.pyc)
bootstrap.ps1: add MCP build step — npm install+build for TS servers, venv+pip for Python

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:17:29 +03:00

476 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { mkdtemp, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { makeKnowledgeTools } from '../../src/tools/knowledge.js';
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
import type { Backend, CommitFileArgs, FileWithSha } from '../../src/lib/backend.js';
let wikiRoot: string;
let projectCwd: string;
let cacheFile: string;
beforeEach(async () => {
wikiRoot = await mkdtemp(join(tmpdir(), 'kw-shared-'));
await mkdir(wikiRoot, { recursive: true });
projectCwd = await mkdtemp(join(tmpdir(), 'kw-cwd-'));
cacheFile = join(await mkdtemp(join(tmpdir(), 'kw-cache-')), 'cache.json');
});
interface FetchedPage {
body: string;
}
/**
* Pages are keyed by qualified `<owner>/<repo>` so the mock backend can verify
* that handlers route fetches to the correct owner — the whole point of the
* cross-owner regression in [multi-owner-ask-projects-owner-fix].
*/
function makeBackend(pages: Record<string, Record<string, FetchedPage | Error>>): Backend {
return {
async listUserRepos() {
return [];
},
async getRawFile(owner, repo, path) {
const repoPages = pages[`${owner}/${repo}`];
if (!repoPages) return null;
const v = repoPages[path];
if (v === undefined) return null;
if (v instanceof Error) throw v;
return v.body;
},
async getFileWithSha(): Promise<FileWithSha | null> {
return null;
},
async commitFile(_a: CommitFileArgs) {
throw new Error('not used');
},
};
}
async function seedCache(projects: CacheFile['projects']) {
const file: CacheFile = {
schemaVersion: 3,
synced_at: '2026-04-30T00:00:00Z',
synced_from: 'https://g',
machine: 'm',
projects,
errors: [],
};
await writeCache(cacheFile, file);
}
function call(
tools: ReturnType<typeof makeKnowledgeTools>,
name: string,
args: Record<string, unknown>,
) {
const t = tools.find((x) => x.name === name);
if (!t) throw new Error(`tool ${name} missing`);
return t.handler(args);
}
const sampleIndex = `# Wiki Index
## Concepts
- [yarn-on-windows](concepts/yarn-on-windows.md) — Yarn on Windows
- [other](concepts/other.md) — Other Concept
## Raw
- [pdf-dump](raw/pdf-dump.md) — Heavy PDF
`;
describe('knowledge.ask_projects', () => {
it('returns matches across listed projects with snippets', async () => {
await seedCache([
{
name: 'u/foo',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
'u/foo': {
'.wiki/concepts/yarn-on-windows.md': {
body: '---\ntitle: Yarn on Windows\n---\n\nyarn install on windows is fiddly',
},
},
});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'yarn',
projects: ['u/foo'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.asked_projects).toEqual(['u/foo']);
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].project).toBe('u/foo');
expect(parsed.results[0].matches).toHaveLength(1);
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
expect(parsed.results[0].matches[0].type).toBe('concepts');
expect(parsed.results[0].matches[0].title).toBe('Yarn on Windows');
expect(parsed.results[0].matches[0].snippet).toContain('yarn');
});
it('routes fetches to the correct owner across multiple owners (cross-owner)', async () => {
await seedCache([
{
name: 'victor/books',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
{
name: 'OpeItcLoc03/common',
default_branch: 'master',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
]);
const calls: Array<{ owner: string; repo: string }> = [];
const backend: Backend = {
async listUserRepos() {
return [];
},
async getRawFile(owner, repo, path) {
calls.push({ owner, repo });
if (owner === 'victor' && repo === 'books') {
return path === '.wiki/concepts/yarn-on-windows.md' ? 'victor body about yarn' : null;
}
if (owner === 'OpeItcLoc03' && repo === 'common') {
return path === '.wiki/concepts/yarn-on-windows.md' ? 'common body about yarn' : null;
}
// Reject acting-identity routing — proves the bug is gone.
throw new Error(`unexpected fetch ${owner}/${repo}`);
},
async getFileWithSha(): Promise<FileWithSha | null> {
return null;
},
async commitFile() {
throw new Error('not used');
},
};
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'OpeItcLoc03',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'yarn',
projects: ['victor/books', 'OpeItcLoc03/common'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results).toHaveLength(2);
const victorResult = parsed.results.find((x: { project: string }) => x.project === 'victor/books');
const commonResult = parsed.results.find((x: { project: string }) => x.project === 'OpeItcLoc03/common');
expect(victorResult.matches[0].snippet).toContain('victor body');
expect(commonResult.matches[0].snippet).toContain('common body');
// Owner extracted from projectName, not from acting giteaUser.
expect(calls.some((c) => c.owner === 'victor' && c.repo === 'books')).toBe(true);
expect(calls.some((c) => c.owner === 'OpeItcLoc03' && c.repo === 'common')).toBe(true);
});
it('rejects bare project name with parseTargetProject hint', async () => {
await seedCache([]);
const backend = makeBackend({});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'x',
projects: ['books'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].project).toBe('books');
expect(parsed.results[0].error).toContain('must be qualified');
});
it('continues with valid projects when one is bare', async () => {
await seedCache([
{
name: 'u/foo',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
'u/foo': {
'.wiki/concepts/yarn-on-windows.md': { body: 'yarn body' },
},
});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'yarn',
projects: ['books', 'u/foo'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results).toHaveLength(2);
expect(parsed.results[0].error).toContain('must be qualified');
expect(parsed.results[1].matches).toHaveLength(1);
});
it('rejects agenda literal with explanatory error', async () => {
await seedCache([]);
const backend = makeBackend({});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'x',
projects: ['agenda'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].error).toContain('agenda not supported');
});
it('reports "no wiki" for projects without wiki_index', async () => {
await seedCache([
{
name: 'u/no-wiki',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
},
]);
const backend = makeBackend({});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'anything',
projects: ['u/no-wiki'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results).toEqual([
{ project: 'u/no-wiki', error: 'no wiki — repo has no .wiki/index.md' },
]);
});
it('reports "unknown project" when project not in cache', async () => {
await seedCache([]);
const backend = makeBackend({});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'x',
projects: ['u/ghost'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results).toEqual([
{ project: 'u/ghost', error: 'unknown project — run sync first' },
]);
});
it('excludes raw type by default', async () => {
await seedCache([
{
name: 'u/foo',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
'u/foo': {
'.wiki/raw/pdf-dump.md': { body: 'pdf about pdf' },
},
});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'pdf',
projects: ['u/foo'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].matches).toEqual([]);
});
it('includes raw when explicitly asked', async () => {
await seedCache([
{
name: 'u/foo',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
'u/foo': { '.wiki/raw/pdf-dump.md': { body: 'pdf body' } },
});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'pdf',
projects: ['u/foo'],
types: ['raw'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].matches).toHaveLength(1);
expect(parsed.results[0].matches[0].slug).toBe('raw/pdf-dump');
});
it('respects limit per project', async () => {
const wideIndex = `# Wiki Index\n\n## Concepts\n\n- [a](concepts/a.md) — A\n- [b](concepts/b.md) — B\n- [c](concepts/c.md) — C\n`;
await seedCache([
{
name: 'u/foo',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: wideIndex,
},
]);
const backend = makeBackend({
'u/foo': {
'.wiki/concepts/a.md': { body: '' },
'.wiki/concepts/b.md': { body: '' },
'.wiki/concepts/c.md': { body: '' },
},
});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: '',
projects: ['u/foo'],
limit: 2,
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].matches).toHaveLength(2);
});
it('marks snippet as "(fetch failed)" when live fetch throws', async () => {
await seedCache([
{
name: 'u/foo',
default_branch: 'main',
fetched_at: '2026-04-30T00:00:00Z',
active_tasks: [],
all_tasks_count: 0,
raw: '',
wiki_index: sampleIndex,
},
]);
const backend = makeBackend({
'u/foo': {
'.wiki/concepts/yarn-on-windows.md': new Error('boom'),
},
});
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend,
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'yarn',
projects: ['u/foo'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.results[0].matches[0].snippet).toBe('(fetch failed)');
expect(parsed.results[0].matches[0].slug).toBe('concepts/yarn-on-windows');
});
it('returns error when backend not configured', async () => {
await seedCache([]);
const tools = makeKnowledgeTools({ wikiRoot, projectCwd, cacheFile });
const r = await call(tools, 'knowledge.ask_projects', {
query: 'x',
projects: ['u/foo'],
});
expect(r.isError).toBe(true);
expect(r.content[0].text).toContain('disabled');
});
it('next_action_hint mentions both knowledge.get_from and tasks.create', async () => {
await seedCache([]);
const tools = makeKnowledgeTools({
wikiRoot,
projectCwd,
cacheFile,
backend: makeBackend({}),
giteaUser: 'u',
});
const r = await call(tools, 'knowledge.ask_projects', {
query: 'x',
projects: ['u/foo'],
});
const parsed = JSON.parse(r.content[0].text);
expect(parsed.next_action_hint).toContain('knowledge.get_from');
expect(parsed.next_action_hint).toContain('tasks.create');
});
});