Implements FED-010: Agent Spawn via Federation feature that enables spawning and managing Claude agents on remote federated Mosaic Stack instances via COMMAND message type. Features: - Federation agent command types (spawn, status, kill) - FederationAgentService for handling agent operations - Integration with orchestrator's agent spawner/lifecycle services - API endpoints for spawning, querying status, and killing agents - Full command routing through federation COMMAND infrastructure - Comprehensive test coverage (12/12 tests passing) Architecture: - Hub → Spoke: Spawn agents on remote instances - Command flow: FederationController → FederationAgentService → CommandService → Remote Orchestrator - Response handling: Remote orchestrator returns agent status/results - Security: Connection validation, signature verification Files created: - apps/api/src/federation/types/federation-agent.types.ts - apps/api/src/federation/federation-agent.service.ts - apps/api/src/federation/federation-agent.service.spec.ts Files modified: - apps/api/src/federation/command.service.ts (agent command routing) - apps/api/src/federation/federation.controller.ts (agent endpoints) - apps/api/src/federation/federation.module.ts (service registration) - apps/orchestrator/src/api/agents/agents.controller.ts (status endpoint) - apps/orchestrator/src/api/agents/agents.module.ts (lifecycle integration) Testing: - 12/12 tests passing for FederationAgentService - All command service tests passing - TypeScript compilation successful - Linting passed Refs #93 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { parseWikiLinks, WikiLink } from "./wiki-link-parser";
|
|
|
|
describe("Wiki Link Parser", () => {
|
|
describe("Basic Parsing", () => {
|
|
it("should parse a simple wiki link", () => {
|
|
const content = "This is a [[Page Name]] in text.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0]).toEqual({
|
|
raw: "[[Page Name]]",
|
|
target: "Page Name",
|
|
displayText: "Page Name",
|
|
start: 10,
|
|
end: 23,
|
|
});
|
|
});
|
|
|
|
it("should parse multiple wiki links", () => {
|
|
const content = "Link to [[First Page]] and [[Second Page]].";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(2);
|
|
expect(links[0].target).toBe("First Page");
|
|
expect(links[0].start).toBe(8);
|
|
expect(links[0].end).toBe(22);
|
|
expect(links[1].target).toBe("Second Page");
|
|
expect(links[1].start).toBe(27);
|
|
expect(links[1].end).toBe(42);
|
|
});
|
|
|
|
it("should handle empty content", () => {
|
|
const links = parseWikiLinks("");
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should handle content without links", () => {
|
|
const content = "This is just plain text with no wiki links.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should parse link by slug (kebab-case)", () => {
|
|
const content = "Reference to [[page-slug-name]].";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("page-slug-name");
|
|
expect(links[0].displayText).toBe("page-slug-name");
|
|
});
|
|
});
|
|
|
|
describe("Display Text Variation", () => {
|
|
it("should parse link with custom display text", () => {
|
|
const content = "See [[Page Name|custom display]] for details.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0]).toEqual({
|
|
raw: "[[Page Name|custom display]]",
|
|
target: "Page Name",
|
|
displayText: "custom display",
|
|
start: 4,
|
|
end: 32,
|
|
});
|
|
});
|
|
|
|
it("should parse multiple links with display text", () => {
|
|
const content = "[[First|One]] and [[Second|Two]]";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(2);
|
|
expect(links[0].target).toBe("First");
|
|
expect(links[0].displayText).toBe("One");
|
|
expect(links[1].target).toBe("Second");
|
|
expect(links[1].displayText).toBe("Two");
|
|
});
|
|
|
|
it("should handle display text with special characters", () => {
|
|
const content = "[[Page|Click here! (details)]]";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].displayText).toBe("Click here! (details)");
|
|
});
|
|
|
|
it("should handle pipe character in target but default display", () => {
|
|
const content = "[[Page Name]]";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links[0].target).toBe("Page Name");
|
|
expect(links[0].displayText).toBe("Page Name");
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases - Brackets", () => {
|
|
it("should not parse single brackets", () => {
|
|
const content = "This [is not] a wiki link.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should not parse three or more opening brackets", () => {
|
|
const content = "This [[[is not]]] a wiki link.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should not parse unmatched brackets", () => {
|
|
const content = "This [[is incomplete";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should not parse reversed brackets", () => {
|
|
const content = "This ]]not a link[[ text.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should handle nested brackets inside link text", () => {
|
|
const content = "[[Page [with] brackets]]";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("Page [with] brackets");
|
|
});
|
|
|
|
it("should handle nested double brackets", () => {
|
|
// This is tricky - we should parse the outer link
|
|
const content = "[[Outer [[inner]] link]]";
|
|
const links = parseWikiLinks(content);
|
|
|
|
// Should not parse nested double brackets - only the first valid one
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].raw).toBe("[[Outer [[inner]]");
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases - Escaped Brackets", () => {
|
|
it("should not parse escaped opening brackets", () => {
|
|
const content = "This \\[[is not a link]] text.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should parse link after escaped brackets", () => {
|
|
const content = "Escaped \\[[not link]] but [[real link]] here.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("real link");
|
|
});
|
|
|
|
it("should handle backslash before brackets in various positions", () => {
|
|
const content = "Text \\[[ and [[valid link]] more \\]].";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("valid link");
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases - Code Blocks", () => {
|
|
it("should not parse links in inline code", () => {
|
|
const content = "Use `[[WikiLink]]` syntax for linking.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should not parse links in fenced code blocks", () => {
|
|
const content = `
|
|
Here is some text.
|
|
|
|
\`\`\`
|
|
[[Link in code block]]
|
|
\`\`\`
|
|
|
|
End of text.
|
|
`;
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should not parse links in indented code blocks", () => {
|
|
const content = `
|
|
Normal text here.
|
|
|
|
[[Link in indented code]]
|
|
More code here
|
|
|
|
Normal text again.
|
|
`;
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should parse links outside code blocks but not inside", () => {
|
|
const content = `
|
|
[[Valid Link]]
|
|
|
|
\`\`\`
|
|
[[Invalid Link]]
|
|
\`\`\`
|
|
|
|
[[Another Valid Link]]
|
|
`;
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(2);
|
|
expect(links[0].target).toBe("Valid Link");
|
|
expect(links[1].target).toBe("Another Valid Link");
|
|
});
|
|
|
|
it("should not parse links in code blocks with language", () => {
|
|
const content = `
|
|
\`\`\`typescript
|
|
const link = "[[Not A Link]]";
|
|
\`\`\`
|
|
`;
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should handle multiple inline code sections", () => {
|
|
const content = "Use `[[link1]]` or `[[link2]]` but [[real link]] works.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("real link");
|
|
});
|
|
|
|
it("should handle unclosed code backticks correctly", () => {
|
|
const content = "Start `code [[link1]] still in code [[link2]]";
|
|
const links = parseWikiLinks(content);
|
|
// If backtick is unclosed, we shouldn't parse any links after it
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should handle adjacent code blocks", () => {
|
|
const content = "`[[code1]]` text [[valid]] `[[code2]]`";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("valid");
|
|
});
|
|
});
|
|
|
|
describe("Edge Cases - Empty and Malformed", () => {
|
|
it("should not parse empty link brackets", () => {
|
|
const content = "Empty [[]] brackets.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should not parse whitespace-only links", () => {
|
|
const content = "Whitespace [[ ]] link.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should trim whitespace from link targets", () => {
|
|
const content = "Link [[ Page Name ]] here.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("Page Name");
|
|
expect(links[0].displayText).toBe("Page Name");
|
|
});
|
|
|
|
it("should trim whitespace from display text", () => {
|
|
const content = "Link [[Target| display text ]] here.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("Target");
|
|
expect(links[0].displayText).toBe("display text");
|
|
});
|
|
|
|
it("should not parse link with empty target but display text", () => {
|
|
const content = "Link [[|display only]] here.";
|
|
const links = parseWikiLinks(content);
|
|
expect(links).toEqual([]);
|
|
});
|
|
|
|
it("should handle link with empty display text", () => {
|
|
const content = "Link [[Target|]] here.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("Target");
|
|
expect(links[0].displayText).toBe("Target");
|
|
});
|
|
|
|
it("should handle multiple pipes", () => {
|
|
const content = "Link [[Target|display|extra]] here.";
|
|
const links = parseWikiLinks(content);
|
|
|
|
// Should use first pipe as separator
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe("Target");
|
|
expect(links[0].displayText).toBe("display|extra");
|
|
});
|
|
});
|
|
|
|
describe("Position Tracking", () => {
|
|
it("should track correct positions for single link", () => {
|
|
const content = "Start [[Link]] end";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links[0].start).toBe(6);
|
|
expect(links[0].end).toBe(14);
|
|
expect(content.substring(links[0].start, links[0].end)).toBe("[[Link]]");
|
|
});
|
|
|
|
it("should track correct positions for multiple links", () => {
|
|
const content = "[[First]] middle [[Second]] end";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links[0].start).toBe(0);
|
|
expect(links[0].end).toBe(9);
|
|
expect(links[1].start).toBe(17);
|
|
expect(links[1].end).toBe(27);
|
|
|
|
expect(content.substring(links[0].start, links[0].end)).toBe("[[First]]");
|
|
expect(content.substring(links[1].start, links[1].end)).toBe("[[Second]]");
|
|
});
|
|
|
|
it("should track positions with display text", () => {
|
|
const content = "Text [[Target|Display]] more";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links[0].start).toBe(5);
|
|
expect(links[0].end).toBe(23);
|
|
expect(content.substring(links[0].start, links[0].end)).toBe("[[Target|Display]]");
|
|
});
|
|
|
|
it("should track positions in multiline content", () => {
|
|
const content = `Line 1
|
|
Line 2 [[Link]]
|
|
Line 3`;
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links[0].start).toBe(14);
|
|
expect(content.substring(links[0].start, links[0].end)).toBe("[[Link]]");
|
|
});
|
|
});
|
|
|
|
describe("Complex Scenarios", () => {
|
|
it("should handle realistic markdown content", () => {
|
|
const content = `
|
|
# Knowledge Base
|
|
|
|
This is a reference to [[Main Page]] and [[Getting Started|start here]].
|
|
|
|
You can also check [[FAQ]] for common questions.
|
|
|
|
\`\`\`typescript
|
|
// This [[should not parse]]
|
|
const link = "[[also not parsed]]";
|
|
\`\`\`
|
|
|
|
But [[this works]] after code block.
|
|
`;
|
|
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(4);
|
|
expect(links[0].target).toBe("Main Page");
|
|
expect(links[1].target).toBe("Getting Started");
|
|
expect(links[1].displayText).toBe("start here");
|
|
expect(links[2].target).toBe("FAQ");
|
|
expect(links[3].target).toBe("this works");
|
|
});
|
|
|
|
it("should handle links at start and end of content", () => {
|
|
const content = "[[Start]] middle [[End]]";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(2);
|
|
expect(links[0].start).toBe(0);
|
|
expect(links[1].end).toBe(content.length);
|
|
});
|
|
|
|
it("should handle consecutive links", () => {
|
|
const content = "[[First]][[Second]][[Third]]";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(3);
|
|
expect(links[0].target).toBe("First");
|
|
expect(links[1].target).toBe("Second");
|
|
expect(links[2].target).toBe("Third");
|
|
});
|
|
|
|
it("should handle links with unicode characters", () => {
|
|
const content = "Link to [[日本語]] and [[Émojis 🚀]].";
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(2);
|
|
expect(links[0].target).toBe("日本語");
|
|
expect(links[1].target).toBe("Émojis 🚀");
|
|
});
|
|
|
|
it("should handle very long link text", () => {
|
|
const longText = "A".repeat(1000);
|
|
const content = `Start [[${longText}]] end`;
|
|
const links = parseWikiLinks(content);
|
|
|
|
expect(links).toHaveLength(1);
|
|
expect(links[0].target).toBe(longText);
|
|
});
|
|
});
|
|
|
|
describe("Type Safety", () => {
|
|
it("should return correctly typed WikiLink objects", () => {
|
|
const content = "[[Test Link]]";
|
|
const links: WikiLink[] = parseWikiLinks(content);
|
|
|
|
expect(links[0]).toHaveProperty("raw");
|
|
expect(links[0]).toHaveProperty("target");
|
|
expect(links[0]).toHaveProperty("displayText");
|
|
expect(links[0]).toHaveProperty("start");
|
|
expect(links[0]).toHaveProperty("end");
|
|
|
|
expect(typeof links[0].raw).toBe("string");
|
|
expect(typeof links[0].target).toBe("string");
|
|
expect(typeof links[0].displayText).toBe("string");
|
|
expect(typeof links[0].start).toBe("number");
|
|
expect(typeof links[0].end).toBe("number");
|
|
});
|
|
});
|
|
});
|