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>
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { mkdtemp } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { writeCache, type CacheFile } from '../../src/lib/cache.js';
|
|
import { parseTargetProject, resolveTarget } from '../../src/lib/resolve-target.js';
|
|
|
|
describe('parseTargetProject', () => {
|
|
it('accepts the agenda literal', () => {
|
|
const r = parseTargetProject('agenda');
|
|
expect(r).toEqual({ agenda: true });
|
|
});
|
|
|
|
it('accepts qualified <owner>/<repo>', () => {
|
|
const r = parseTargetProject('victor/books');
|
|
expect(r).toEqual({ agenda: false, owner: 'victor', repo: 'books' });
|
|
});
|
|
|
|
it('rejects bare repo name with hint', () => {
|
|
const r = parseTargetProject('books');
|
|
expect(r).toEqual({ error: expect.stringContaining('must be qualified') });
|
|
const err = (r as { error: string }).error;
|
|
expect(err).toContain('use `<owner>/<repo>`');
|
|
expect(err).toContain('victor/books');
|
|
expect(err).toContain('Got: `books`');
|
|
});
|
|
|
|
it('rejects the legacy _meta literal (replaced by `agenda` in 2.0)', () => {
|
|
const r = parseTargetProject('_meta');
|
|
expect(r).toEqual({ error: expect.stringContaining('must be qualified') });
|
|
expect((r as { error: string }).error).toContain('Got: `_meta`');
|
|
});
|
|
|
|
it('rejects empty string', () => {
|
|
const r = parseTargetProject('');
|
|
expect('error' in r).toBe(true);
|
|
});
|
|
|
|
it('rejects leading slash', () => {
|
|
const r = parseTargetProject('/books');
|
|
expect('error' in r).toBe(true);
|
|
});
|
|
|
|
it('rejects trailing slash', () => {
|
|
const r = parseTargetProject('victor/');
|
|
expect('error' in r).toBe(true);
|
|
});
|
|
|
|
it('rejects more than one slash', () => {
|
|
const r = parseTargetProject('a/b/c');
|
|
expect('error' in r).toBe(true);
|
|
});
|
|
|
|
it('rejects whitespace inside', () => {
|
|
const r = parseTargetProject('victor /books');
|
|
expect('error' in r).toBe(true);
|
|
});
|
|
|
|
it('rejects empty owner segment', () => {
|
|
const r = parseTargetProject('//books');
|
|
expect('error' in r).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resolveTarget', () => {
|
|
let cacheFile: string;
|
|
const baseFixture: CacheFile = {
|
|
schemaVersion: 3,
|
|
synced_at: '2026-05-08T00:00:00Z',
|
|
synced_from: 'https://g',
|
|
machine: 'm',
|
|
projects: [
|
|
{
|
|
name: 'victor/books',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-05-08T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
},
|
|
{
|
|
name: 'OpeItcLoc03/common',
|
|
default_branch: 'master',
|
|
fetched_at: '2026-05-08T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
},
|
|
],
|
|
errors: [],
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'resolve-target-'));
|
|
cacheFile = join(dir, 'cache.json');
|
|
await writeCache(cacheFile, baseFixture);
|
|
});
|
|
|
|
it('returns owner+repo+branch for qualified target in cache', async () => {
|
|
const r = await resolveTarget(cacheFile, 'victor/books', 'agenda', 'OpeItcLoc03');
|
|
expect(r).toEqual({ owner: 'victor', repo: 'books', branch: 'main', isAgenda: false });
|
|
});
|
|
|
|
it('routes agenda to fallbackOwner + agendaRepo', async () => {
|
|
await writeCache(cacheFile, {
|
|
...baseFixture,
|
|
projects: [
|
|
...baseFixture.projects,
|
|
{
|
|
name: 'agenda',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-05-08T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
},
|
|
],
|
|
});
|
|
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
|
expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true });
|
|
});
|
|
|
|
it('rejects bare name with hint message', async () => {
|
|
const r = await resolveTarget(cacheFile, 'books', 'agenda', 'OpeItcLoc03');
|
|
expect('error' in r).toBe(true);
|
|
expect((r as { error: string }).error).toContain('must be qualified');
|
|
});
|
|
|
|
it('errors on cache miss for qualified target', async () => {
|
|
const r = await resolveTarget(cacheFile, 'ghost/repo', 'agenda', 'OpeItcLoc03');
|
|
expect('error' in r).toBe(true);
|
|
expect((r as { error: string }).error).toContain('not in cache');
|
|
});
|
|
|
|
it('qualified-unknown owner returns cache-miss error, not shape-error', async () => {
|
|
const r = await resolveTarget(cacheFile, 'ekassir/books', 'agenda', 'OpeItcLoc03');
|
|
expect('error' in r).toBe(true);
|
|
const err = (r as { error: string }).error;
|
|
expect(err).not.toContain('must be qualified');
|
|
expect(err).toContain('not in cache');
|
|
expect(err).toContain('ekassir/books');
|
|
});
|
|
|
|
it('errors on missing agenda entry in cache', async () => {
|
|
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
|
expect('error' in r).toBe(true);
|
|
expect((r as { error: string }).error).toContain('agenda not in cache');
|
|
});
|
|
|
|
it('errors when agenda is local-only', async () => {
|
|
await writeCache(cacheFile, {
|
|
...baseFixture,
|
|
projects: [
|
|
...baseFixture.projects,
|
|
{
|
|
name: 'agenda',
|
|
default_branch: 'local',
|
|
fetched_at: '2026-05-08T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
},
|
|
],
|
|
});
|
|
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03');
|
|
expect('error' in r).toBe(true);
|
|
expect((r as { error: string }).error).toContain('Gitea repo "agenda" not found');
|
|
});
|
|
|
|
it('errors when cache missing entirely', async () => {
|
|
const r = await resolveTarget('/nonexistent/cache.json', 'victor/books', 'agenda', 'OpeItcLoc03');
|
|
expect('error' in r).toBe(true);
|
|
expect((r as { error: string }).error).toContain('cache missing');
|
|
});
|
|
|
|
it('routes agenda to agendaOwner override (qualified agenda_tasks_repo)', async () => {
|
|
await writeCache(cacheFile, {
|
|
...baseFixture,
|
|
projects: [
|
|
...baseFixture.projects,
|
|
{
|
|
name: 'agenda',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-05-08T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
},
|
|
],
|
|
});
|
|
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', 'victor');
|
|
expect(r).toEqual({ owner: 'victor', repo: 'agenda', branch: 'main', isAgenda: true });
|
|
});
|
|
|
|
it('falls back to fallbackOwner when agendaOwner is undefined (bare agenda_tasks_repo)', async () => {
|
|
await writeCache(cacheFile, {
|
|
...baseFixture,
|
|
projects: [
|
|
...baseFixture.projects,
|
|
{
|
|
name: 'agenda',
|
|
default_branch: 'main',
|
|
fetched_at: '2026-05-08T00:00:00Z',
|
|
active_tasks: [],
|
|
all_tasks_count: 0,
|
|
raw: '',
|
|
},
|
|
],
|
|
});
|
|
const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', undefined);
|
|
expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true });
|
|
});
|
|
});
|