Files
factory/lib/projects-meta-mcp/tests/lib/sync-runner.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

638 lines
22 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { runSync } from '../../src/lib/sync-runner.js';
import { parseStatusMd } from '../../src/lib/status-md.js';
import type { Backend } from '../../src/lib/backend.js';
type FileMap = Record<string, string | null | Error>;
type RepoFiles = string | null | Error | FileMap;
function fakeClient(
repos: Array<{ name: string; default_branch: string; archived?: boolean }>,
files: Record<string, RepoFiles>,
): Backend {
return {
async listUserRepos() {
return repos;
},
async getRawFile(_u, repo, path) {
const v = files[repo];
if (v === undefined) return null;
if (typeof v === 'string' || v === null || v instanceof Error) {
if (path === '.tasks/STATUS.md') {
if (v instanceof Error) throw v;
return v;
}
return null;
}
const inner = (v as FileMap)[path];
if (inner === undefined) return null;
if (inner instanceof Error) throw inner;
return inner;
},
async getFileWithSha() {
throw new Error('not used in sync tests');
},
async commitFile() {
throw new Error('not used in sync tests');
},
};
}
describe('runSync', () => {
const now = () => new Date('2026-04-29T12:00:00Z');
const sample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [t1] — first\n**Next action:** do x\n`;
it('aggregates STATUS.md across repos', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }, { name: 'b', default_branch: 'main' }],
{ a: sample, b: sample },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects).toHaveLength(2);
expect(cache.errors).toEqual([]);
expect(cache.projects[0].active_tasks[0].slug).toBe('t1');
});
// Regression [aggregate-blocked-no-slug-invisible]: a blocked task whose
// **Blocker:** is free text (e.g. "design-hold") rather than another task's
// slug must still be indexed into active_tasks — status is keyed off the 🔵
// emoji, never off the shape of the blocker. The aggregate view reads from
// active_tasks, so a missed index here makes such tasks invisible.
it('indexes a blocked task with a non-slug (text) blocker', async () => {
const md =
`# Task Board\n_Updated: 2026-04-29_\n\n` +
`## 🔵 [held] — held on a design decision\n` +
`**Status:** blocked\n` +
`**Where I stopped:** waiting on design\n` +
`**Next action:** resume after design ruling\n` +
`**Blocker:** design-hold\n` +
`**Branch:** n/a\n`;
const cache = await runSync({
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: md }),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
const blocked = cache.projects[0].active_tasks.filter((t) => t.status === 'blocked');
expect(blocked.map((t) => t.slug)).toEqual(['held']);
});
it('skips 404 (no STATUS.md) without error', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }, { name: 'no-tasks', default_branch: 'main' }],
{ a: sample, 'no-tasks': null },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
expect(cache.errors).toEqual([]);
});
it('records error and continues on network failure', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }, { name: 'broken', default_branch: 'main' }],
{ a: sample, broken: new Error('network down') },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
expect(cache.errors).toEqual([{ project: 'u/broken', reason: 'network down' }]);
});
it('sets synced_at, machine, synced_from', async () => {
const cache = await runSync({
client: fakeClient([], {}),
parseStatus: parseStatusMd,
now,
machine: 'host-x',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.synced_at).toBe('2026-04-29T12:00:00.000Z');
expect(cache.machine).toBe('host-x');
expect(cache.synced_from).toBe('https://g');
});
it('injects agenda pseudo-project when readAgendaTasksLocal returns content', async () => {
const agendaSample = `# Task Board\n_Updated: 2026-04-29_\n\n## 🔴 [m1] — agenda task\n**Next action:** ship MVP-1\n\n## ⚪ [m2] — placeholder\n**Next action:** none\n`;
const cache = await runSync({
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
readAgendaTasksLocal: async () => agendaSample,
});
const names = cache.projects.map((p) => p.name);
expect(names).toContain('agenda');
expect(names).toContain('u/a');
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
expect(agenda.default_branch).toBe('local');
expect(agenda.all_tasks_count).toBe(2);
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['m1']);
expect(cache.errors).toEqual([]);
});
it('skips agenda when readAgendaTasksLocal returns null', async () => {
const cache = await runSync({
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
readAgendaTasksLocal: async () => null,
});
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
expect(cache.errors).toEqual([]);
});
it('records error under "agenda" when readAgendaTasksLocal throws', async () => {
const cache = await runSync({
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
readAgendaTasksLocal: async () => {
throw new Error('disk read failed');
},
});
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
expect(cache.errors).toEqual([{ project: 'agenda', reason: 'disk read failed' }]);
});
it('uses Gitea repo for agenda when agendaTasksRepo is found and excludes it from regular projects', async () => {
const agendaFromGitea = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [g1] — gitea-side\n**Next action:** ship\n`;
const agendaFromLocal = `# Agenda\n## 🔴 [l1] — local should not win\n`;
const cache = await runSync({
client: fakeClient(
[
{ name: 'a', default_branch: 'main' },
{ name: 'projects-tasks', default_branch: 'main' },
],
{ a: sample, 'projects-tasks': agendaFromGitea },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
agendaTasksRepo: 'projects-tasks',
readAgendaTasksLocal: async () => agendaFromLocal,
});
const names = cache.projects.map((p) => p.name).sort();
expect(names).toEqual(['agenda', 'u/a']);
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
expect(agenda.default_branch).toBe('main');
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['g1']);
});
it('falls back to local when agendaTasksRepo is configured but absent in Gitea', async () => {
const localContent = `# Local agenda\n## 🔴 [l1] — fallback\n**Next action:** x\n`;
const cache = await runSync({
client: fakeClient([{ name: 'a', default_branch: 'main' }], { a: sample }),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
agendaTasksRepo: 'projects-tasks',
readAgendaTasksLocal: async () => localContent,
});
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
expect(agenda).toBeDefined();
expect(agenda.default_branch).toBe('local');
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['l1']);
});
it('falls back to local when Gitea repo exists but has no STATUS.md', async () => {
const localContent = `# Local\n## 🔴 [l2] — fallback-2\n`;
const cache = await runSync({
client: fakeClient(
[
{ name: 'a', default_branch: 'main' },
{ name: 'projects-tasks', default_branch: 'main' },
],
{ a: sample, 'projects-tasks': null },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
agendaTasksRepo: 'projects-tasks',
readAgendaTasksLocal: async () => localContent,
});
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
expect(agenda).toBeDefined();
expect(agenda.default_branch).toBe('local');
// projects-tasks excluded from regular project list even when its STATUS.md is absent.
expect(cache.projects.map((p) => p.name)).not.toContain('u/projects-tasks');
});
it('records gitea error in cache.errors when fetch throws and no local fallback', async () => {
const cache = await runSync({
client: fakeClient(
[
{ name: 'a', default_branch: 'main' },
{ name: 'projects-tasks', default_branch: 'main' },
],
{ a: sample, 'projects-tasks': new Error('gitea 500') },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
agendaTasksRepo: 'projects-tasks',
});
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
expect(cache.errors).toEqual([{ project: 'agenda', reason: 'gitea 500' }]);
});
it('captures wiki_index alongside STATUS.md when both exist', async () => {
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }],
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': wikiIndex } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects).toHaveLength(1);
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
});
it('captures wiki-only repo (no STATUS.md) into projects list', async () => {
const wikiIndex = `# Wiki Index\n\n## Concepts\n\n- [foo](concepts/foo.md) — Foo\n`;
const cache = await runSync({
client: fakeClient(
[{ name: 'wiki-only', default_branch: 'main' }],
{ 'wiki-only': { '.tasks/STATUS.md': null, '.wiki/index.md': wikiIndex } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects).toHaveLength(1);
expect(cache.projects[0].name).toBe('u/wiki-only');
expect(cache.projects[0].raw).toBe('');
expect(cache.projects[0].active_tasks).toEqual([]);
expect(cache.projects[0].all_tasks_count).toBe(0);
expect(cache.projects[0].wiki_index).toBe(wikiIndex);
});
it('omits wiki_index when .wiki/index.md is absent', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }],
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects[0].wiki_index).toBeUndefined();
});
it('skips repo when neither STATUS.md nor wiki_index exist', async () => {
const cache = await runSync({
client: fakeClient(
[
{ name: 'a', default_branch: 'main' },
{ name: 'empty', default_branch: 'main' },
],
{
a: { '.tasks/STATUS.md': sample, '.wiki/index.md': null },
empty: { '.tasks/STATUS.md': null, '.wiki/index.md': null },
},
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects.map((p) => p.name)).toEqual(['u/a']);
expect(cache.errors).toEqual([]);
});
it('does not record error when wiki_index fetch fails — degrades silently', async () => {
const cache = await runSync({
client: fakeClient(
[{ name: 'a', default_branch: 'main' }],
{ a: { '.tasks/STATUS.md': sample, '.wiki/index.md': new Error('wiki fetch 500') } },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects).toHaveLength(1);
expect(cache.projects[0].wiki_index).toBeUndefined();
expect(cache.errors).toEqual([]);
});
it('local wins when agendaTasksRepo not configured at all', async () => {
const localContent = `# Local\n## 🔴 [only] — local\n`;
const cache = await runSync({
client: fakeClient(
[
{ name: 'a', default_branch: 'main' },
{ name: 'projects-tasks', default_branch: 'main' },
],
{ a: sample, 'projects-tasks': 'should not be read' },
),
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['u'],
// agendaTasksRepo intentionally omitted
readAgendaTasksLocal: async () => localContent,
});
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
expect(agenda.default_branch).toBe('local');
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['only']);
// Without agendaTasksRepo, projects-tasks shouldn't be filtered out.
expect(cache.projects.map((p) => p.name).sort()).toEqual(['agenda', 'u/a', 'u/projects-tasks']);
});
it('skips archived repos', async () => {
const cache = await runSync({
client: fakeClient(
[
{ name: 'live', default_branch: 'main', archived: false },
{ name: 'archived-repo', default_branch: 'main', archived: true },
],
{ live: sample },
),
parseStatus: parseStatusMd,
now,
machine: 'test',
giteaUrl: 'https://g',
owners: ['u'],
});
expect(cache.projects.map((p) => p.name)).toEqual(['u/live']);
expect(cache.archived_skipped).toBe(1);
});
it('aggregates repos across multiple owners with qualified <owner>/<repo> names', async () => {
const ownerARepos = [
{ name: 'repo-a1', default_branch: 'main' },
{ name: 'repo-a2', default_branch: 'main' },
];
const ownerBRepos = [{ name: 'repo-b1', default_branch: 'main' }];
const calls: Array<{ owner: string; repo: string }> = [];
const client: Backend = {
async listUserRepos(owner) {
if (owner === 'owner-a') return ownerARepos;
if (owner === 'owner-b') return ownerBRepos;
return [];
},
async getRawFile(owner, repo, path) {
calls.push({ owner, repo });
if (path !== '.tasks/STATUS.md') return null;
return sample;
},
async getFileWithSha() {
throw new Error('not used');
},
async commitFile() {
throw new Error('not used');
},
};
const cache = await runSync({
client,
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['owner-a', 'owner-b'],
});
expect(cache.projects.map((p) => p.name).sort()).toEqual([
'owner-a/repo-a1',
'owner-a/repo-a2',
'owner-b/repo-b1',
]);
// getRawFile should have been called with each repo's actual owner — not
// a uniform "acting user" — which is the whole point of multi-owner sync.
const ownerARepoCalls = calls.filter((c) => c.repo === 'repo-a1' || c.repo === 'repo-a2');
expect(ownerARepoCalls.every((c) => c.owner === 'owner-a')).toBe(true);
const ownerBRepoCalls = calls.filter((c) => c.repo === 'repo-b1');
expect(ownerBRepoCalls.every((c) => c.owner === 'owner-b')).toBe(true);
expect(cache.errors).toEqual([]);
});
it('uses agendaTasksOwner to pin agenda to a specific owner — non-acting owner', async () => {
const agendaContent = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [v1] — victor agenda\n**Next action:** ship\n`;
const calls: Array<{ owner: string; repo: string; path: string }> = [];
const client: Backend = {
async listUserRepos(owner) {
if (owner === 'OpeItcLoc03') return [{ name: 'agenda', default_branch: 'main' }];
if (owner === 'victor') return [{ name: 'agenda', default_branch: 'main' }];
return [];
},
async getRawFile(owner, repo, path) {
calls.push({ owner, repo, path });
if (path !== '.tasks/STATUS.md') return null;
if (owner === 'victor' && repo === 'agenda') return agendaContent;
if (owner === 'OpeItcLoc03' && repo === 'agenda') return sample;
return null;
},
async getFileWithSha() {
throw new Error('not used');
},
async commitFile() {
throw new Error('not used');
},
};
const cache = await runSync({
client,
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['OpeItcLoc03', 'victor'],
agendaTasksRepo: 'agenda',
agendaTasksOwner: 'victor',
});
const names = cache.projects.map((p) => p.name).sort();
// agenda pinned to victor; OpeItcLoc03/agenda stays in regular project list.
expect(names).toEqual(['OpeItcLoc03/agenda', 'agenda']);
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['v1']);
const agendaCalls = calls.filter(
(c) => c.repo === 'agenda' && c.path === '.tasks/STATUS.md',
);
// Only victor was called for the agenda fetch — OpeItcLoc03 wasn't
// misroute-targeted just because it shares the repo name.
const victorAgendaCalls = agendaCalls.filter((c) => c.owner === 'victor');
expect(victorAgendaCalls).toHaveLength(1);
});
it('iterates union of owners + aggregateSkipOwners, populating cache with qualified keys for skip-owner repos', async () => {
// Acceptance: OpeItcLoc03 infra repos must be present in cache (qualified)
// so MCP-write tools can resolve them — even though they're hidden from
// aggregation views (the aggregation filter is enforced separately at the
// tasks.aggregate / search / get layer, not here).
const ownerARepos = [{ name: 'biz1', default_branch: 'main' }];
const skipOwnerRepos = [
{ name: 'common', default_branch: 'master' },
{ name: 'claude-skills', default_branch: 'master' },
];
const client: Backend = {
async listUserRepos(owner) {
if (owner === 'victor') return ownerARepos;
if (owner === 'OpeItcLoc03') return skipOwnerRepos;
return [];
},
async getRawFile(_owner, _repo, path) {
if (path !== '.tasks/STATUS.md') return null;
return sample;
},
async getFileWithSha() {
throw new Error('not used');
},
async commitFile() {
throw new Error('not used');
},
};
const cache = await runSync({
client,
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['victor'],
aggregateSkipOwners: ['OpeItcLoc03'],
});
expect(cache.projects.map((p) => p.name).sort()).toEqual([
'OpeItcLoc03/claude-skills',
'OpeItcLoc03/common',
'victor/biz1',
]);
expect(cache.errors).toEqual([]);
});
it('dedupes when an owner appears in both owners and aggregateSkipOwners', async () => {
// If user fat-fingers and lists same owner in both — sync should not
// double-fetch. Real-world cause: copy-paste error in auth.toml.
let listUserReposCalls = 0;
const client: Backend = {
async listUserRepos() {
listUserReposCalls++;
return [{ name: 'r1', default_branch: 'main' }];
},
async getRawFile(_o, _r, path) {
return path === '.tasks/STATUS.md' ? sample : null;
},
async getFileWithSha() {
throw new Error('not used');
},
async commitFile() {
throw new Error('not used');
},
};
const cache = await runSync({
client,
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['victor'],
aggregateSkipOwners: ['victor'],
});
expect(listUserReposCalls).toBe(1);
expect(cache.projects.map((p) => p.name)).toEqual(['victor/r1']);
});
it('finds agenda repo under whichever owner contains it', async () => {
const agendaContent = `# Agenda\n_Updated: 2026-04-29_\n\n## 🔴 [a1] — agenda task\n**Next action:** ship\n`;
const calls: Array<{ owner: string; repo: string; path: string }> = [];
const client: Backend = {
async listUserRepos(owner) {
if (owner === 'owner-x') return [{ name: 'biz-repo', default_branch: 'main' }];
if (owner === 'acting-user') return [{ name: 'agenda', default_branch: 'main' }];
return [];
},
async getRawFile(owner, repo, path) {
calls.push({ owner, repo, path });
if (path !== '.tasks/STATUS.md') return null;
if (owner === 'owner-x' && repo === 'biz-repo') return sample;
if (owner === 'acting-user' && repo === 'agenda') return agendaContent;
return null;
},
async getFileWithSha() {
throw new Error('not used');
},
async commitFile() {
throw new Error('not used');
},
};
const cache = await runSync({
client,
parseStatus: parseStatusMd,
now,
machine: 'm',
giteaUrl: 'https://g',
owners: ['owner-x', 'acting-user'],
agendaTasksRepo: 'agenda',
});
const names = cache.projects.map((p) => p.name).sort();
expect(names).toEqual(['agenda', 'owner-x/biz-repo']);
const agenda = cache.projects.find((p) => p.name === 'agenda')!;
expect(agenda.active_tasks.map((t) => t.slug)).toEqual(['a1']);
// Confirm agenda was fetched from the right owner — not from owner-x.
const agendaCalls = calls.filter(
(c) => c.repo === 'agenda' && c.path === '.tasks/STATUS.md',
);
expect(agendaCalls).toHaveLength(1);
expect(agendaCalls[0].owner).toBe('acting-user');
});
});