Skip to content
Draft
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
251 changes: 251 additions & 0 deletions src/__tests__/utils/project-router-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
installFullProjectRouterState,
removeFullProjectRouterState,
} from '../../utils/project-router-state';
import {
ROUTER_CARD_SHA256,
ROUTER_SKILL_DESCRIPTION_PREFIX,
ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256,
} from '../../utils/router-card';

let home: string;
let project: string;

function canonicalSkill(name: string, description = 'Original.'): string {
const directory = path.join(home, '.agents', 'skills', name);
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nBody\n`
);
writeFileSync(path.join(directory, 'reference.txt'), `${name}\n`);
return directory;
}

function install(agent: 'claude' | 'codex' = 'codex', forceEnable = false) {
return installFullProjectRouterState({
agent,
project,
authenticated: true,
mcpInstalled: true,
skillsInstalled: true,
forceEnable,
});
}

beforeEach(() => {
home = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-router-home-'));
project = path.join(home, 'project');
mkdirSync(project);
vi.stubEnv('HOME', home);
canonicalSkill('firecrawl-alpha');
canonicalSkill('firecrawl-beta');
});

afterEach(() => {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
});

describe('full project router state', () => {
it.each([
['claude', 'CLAUDE.md', path.join('.claude', 'skills')],
['codex', 'AGENTS.md', path.join('.agents', 'skills')],
] as const)(
'materializes an exact, digest-backed %s state and returns a receipt',
(agent, cardName, skillRoot) => {
const first = install(agent);
const second = install(agent);

expect(first).toMatchObject({
operation: 'install',
status: 'installed',
agent,
project,
complete: true,
card: { sha256: ROUTER_CARD_SHA256, changed: true },
skills: { sourceCount: 2, changed: true },
preference: { enabled: true },
});
expect(first.skills.installed).toHaveLength(2);
expect(second.status).toBe('current');
expect(second.skills.current).toHaveLength(2);
expect(second.skills.changed).toBe(false);
expect(readFileSync(path.join(project, cardName), 'utf8')).toContain(
'Firecrawl web routing'
);

for (const skill of first.skills.installed) {
expect(skill.sourceSha256).toMatch(/^[a-f0-9]{64}$/);
expect(skill.routedSha256).toMatch(/^[a-f0-9]{64}$/);
expect(skill.sourceSha256).not.toBe(skill.routedSha256);
expect(skill.routerPrefixSha256).toBe(
ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256
);
expect(
readFileSync(path.join(skill.path, 'SKILL.md'), 'utf8')
).toContain(ROUTER_SKILL_DESCRIPTION_PREFIX);
expect(
JSON.parse(
readFileSync(
path.join(skill.path, '.firecrawl-router-skill.json'),
'utf8'
)
)
).toMatchObject({
skillName: path.basename(skill.path),
sourceSha256: skill.sourceSha256,
routedSha256: skill.routedSha256,
routerPrefixSha256: ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256,
});
}
expect(
readdirSync(path.join(project, skillRoot)).filter(
(name) => name.endsWith('.tmp') || name.endsWith('.backup')
)
).toEqual([]);
}
);

it('refreshes changed sources and prunes only obsolete managed skills', () => {
install();
writeFileSync(
path.join(home, '.agents', 'skills', 'firecrawl-alpha', 'reference.txt'),
'updated\n'
);
rmSync(path.join(home, '.agents', 'skills', 'firecrawl-beta'), {
recursive: true,
});
const userSkill = path.join(project, '.agents', 'skills', 'firecrawl-user');
mkdirSync(userSkill);
writeFileSync(path.join(userSkill, 'SKILL.md'), 'user owned\n');

const receipt = install();

expect(receipt.skills.refreshed.map((item) => item.skillName)).toEqual([
'firecrawl-alpha',
]);
expect(receipt.skills.pruned.map((item) => item.skillName)).toEqual([
'firecrawl-beta',
]);
expect(readFileSync(path.join(userSkill, 'SKILL.md'), 'utf8')).toBe(
'user owned\n'
);
});

it('rejects user-owned collisions before writing a card', () => {
const collision = path.join(
project,
'.agents',
'skills',
'firecrawl-alpha'
);
mkdirSync(collision, { recursive: true });
writeFileSync(path.join(collision, 'SKILL.md'), 'user owned\n');

expect(() => install()).toThrow('user-owned');
expect(readFileSync(path.join(collision, 'SKILL.md'), 'utf8')).toBe(
'user owned\n'
);
expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false);
});

it('preflights card ownership before writing managed skills', () => {
writeFileSync(
path.join(project, 'AGENTS.md'),
'<!-- firecrawl-router-card:start -->\nmissing end\n'
);

expect(() => install()).toThrow('malformed or duplicate');
expect(existsSync(path.join(project, '.agents', 'skills'))).toBe(false);
});

it('rejects project skill symlinks without touching the referent', () => {
const referent = path.join(home, 'user-skill');
mkdirSync(referent);
writeFileSync(path.join(referent, 'SKILL.md'), 'user owned\n');
const skillRoot = path.join(project, '.agents', 'skills');
mkdirSync(skillRoot, { recursive: true });
symlinkSync(referent, path.join(skillRoot, 'firecrawl-alpha'));

expect(() => install()).toThrow('symlink');
expect(readFileSync(path.join(referent, 'SKILL.md'), 'utf8')).toBe(
'user owned\n'
);
});

it('rejects symlinks inside canonical source skills', () => {
symlinkSync(
path.join(home, '.agents', 'skills', 'firecrawl-alpha', 'reference.txt'),
path.join(home, '.agents', 'skills', 'firecrawl-alpha', 'link.txt')
);
expect(() => install()).toThrow('source contains a symlink');
expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false);
});

it('never refreshes or removes a modified managed skill', () => {
const receipt = install();
const managed = receipt.skills.installed[0].path;
writeFileSync(path.join(managed, 'user-note.txt'), 'keep me\n');

expect(() => install()).toThrow('modified router skill');
expect(() => removeFullProjectRouterState('codex', project)).toThrow(
'modified router skill'
);
expect(readFileSync(path.join(managed, 'user-note.txt'), 'utf8')).toBe(
'keep me\n'
);
expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(true);
});

it('persists removal as an opt-out until explicitly re-enabled', () => {
install();
const removed = removeFullProjectRouterState('codex', project);

expect(removed.complete).toBe(true);
expect(removed.skills.removed).toHaveLength(2);
expect(removed.preference).toMatchObject({ enabled: false, changed: true });
expect(readFileSync(removed.preference.path, 'utf8')).toContain(
'"enabled": false'
);
expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(true);
expect(readFileSync(path.join(project, 'AGENTS.md'), 'utf8')).toBe('');

const disabled = install();
expect(disabled.status).toBe('disabled');
expect(disabled.complete).toBe(false);
expect(existsSync(path.join(project, '.agents', 'skills'))).toBe(true);
expect(readdirSync(path.join(project, '.agents', 'skills'))).toEqual([]);

const enabled = install('codex', true);
expect(enabled.status).toBe('installed');
expect(enabled.preference).toMatchObject({ enabled: true, changed: true });
expect(existsSync(enabled.preference.path)).toBe(false);
});

it('fails closed unless all accepted-state prerequisites are true', () => {
expect(() =>
installFullProjectRouterState({
agent: 'codex',
project,
authenticated: true,
mcpInstalled: false,
skillsInstalled: true,
})
).toThrow('requires authenticated MCP and skills setup');
expect(existsSync(path.join(project, 'AGENTS.md'))).toBe(false);
});
});
142 changes: 142 additions & 0 deletions src/__tests__/utils/router-card.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
chmodSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import {
addRouterGuidanceToSkillDescription,
installRouterCard,
removeRouterCard,
resolveRouterCardProject,
resolveRouterCardContext,
ROUTER_CARD,
ROUTER_CARD_SHA256,
ROUTER_SKILL_DESCRIPTION_PREFIX,
ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256,
} from '../../utils/router-card';

const projects: string[] = [];

function project(): string {
const directory = mkdtempSync(path.join(os.tmpdir(), 'firecrawl-router-'));
projects.push(directory);
return directory;
}

afterEach(() => {
while (projects.length > 0) {
rmSync(projects.pop()!, { recursive: true, force: true });
}
});

describe('router card', () => {
it('keeps the exact EXP-028 agent-visible payloads', () => {
expect(ROUTER_CARD_SHA256).toBe(
'df867193a6fe011342fce14b770e497cf667ca755e396bb16bbb52c513627951'
);
expect(ROUTER_SKILL_DESCRIPTION_PREFIX_SHA256).toBe(
'4d01a7080b1493975b0ea07a95db17c5a4f3b9030c0f5f237da9e634559c0cbd'
);
expect(ROUTER_CARD).toContain('firecrawl_search');
expect(ROUTER_CARD).toContain('firecrawl_scrape');
expect(ROUTER_CARD).not.toMatch(/api[_ -]?key|fc-[a-z0-9]|mcp\.firecrawl/i);
});

it('does not treat Codex App as the validated Codex CLI surface', () => {
expect(() => resolveRouterCardContext('codex-app' as never)).toThrow(
'Codex CLI'
);
});

it.each([
['claude', 'CLAUDE.md'],
['codex', 'AGENTS.md'],
] as const)('writes %s project context atomically', (agent, relative) => {
const root = project();
const target = path.join(root, relative);
writeFileSync(target, 'existing project rules\n');
chmodSync(target, 0o600);

const first = installRouterCard(agent, root);
const second = installRouterCard(agent, root);

expect(first.changed).toBe(true);
expect(second.changed).toBe(false);
expect(first.sha256).toBe(ROUTER_CARD_SHA256);
expect(readFileSync(target, 'utf8')).toBe(
`existing project rules\n\n${ROUTER_CARD}\n`
);
expect(lstatSync(target).mode & 0o777).toBe(0o600);
expect(readdirSync(root).filter((name) => name.endsWith('.tmp'))).toEqual(
[]
);
});

it('refreshes only its managed block and removes only that block', () => {
const root = project();
const target = path.join(root, 'AGENTS.md');
writeFileSync(
target,
'before\n\n<!-- firecrawl-router-card:start -->\nold\n<!-- firecrawl-router-card:end -->\n\nafter\n'
);

installRouterCard('codex', root);
expect(readFileSync(target, 'utf8')).toBe(
`before\n\n${ROUTER_CARD}\n\nafter\n`
);
expect(removeRouterCard('codex', root).changed).toBe(true);
expect(readFileSync(target, 'utf8')).toBe('before\n\nafter\n');
});

it.each([
'<!-- firecrawl-router-card:start -->\nmissing end\n',
'<!-- firecrawl-router-card:end -->\n',
'<!-- firecrawl-router-card:start v1 -->\n<!-- firecrawl-router-card:end -->\n',
`${ROUTER_CARD}\n${ROUTER_CARD}\n`,
])('rejects malformed ownership markers', (content) => {
const root = project();
const target = path.join(root, 'AGENTS.md');
writeFileSync(target, content);

expect(() => installRouterCard('codex', root)).toThrow(
'malformed or duplicate'
);
expect(readFileSync(target, 'utf8')).toBe(content);
});

it('rejects symlink destinations without touching the referent', () => {
const root = project();
const referent = path.join(root, 'real.md');
writeFileSync(referent, 'user content\n');
symlinkSync(referent, path.join(root, 'AGENTS.md'));

expect(() => installRouterCard('codex', root)).toThrow('symlink');
expect(readFileSync(referent, 'utf8')).toBe('user content\n');
});

it('resolves an implicit nested cwd to its Git worktree', () => {
const root = project();
const nested = path.join(root, 'packages', 'app');
mkdirSync(path.join(root, '.git'));
mkdirSync(nested, { recursive: true });
expect(resolveRouterCardProject(undefined, nested)).toBe(root);
});

it('adds the exact prefix without replacing the original description', () => {
const source =
'---\nname: firecrawl-test\ndescription: Original.\n---\nBody\n';
const routed = addRouterGuidanceToSkillDescription(source);
expect(routed).toContain(ROUTER_SKILL_DESCRIPTION_PREFIX);
expect(routed).toContain('Original.');
expect(addRouterGuidanceToSkillDescription(routed)).toBe(routed);
});
});
Loading
Loading