import { describe, it, expect } from 'vitest'; import { writeSurface } from './surfaceFixture.js'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ADDRESSABLE } from '../mcpCommand.js'; /** * Story 7.2 — stable documents are addressable. * * The line the epic draws: a RESOURCE takes no arguments and does not change between calls, so a client may cache it; * anything argument-dependent is a TOOL (Story 7.3). These tests hold that line as well as the content. */ const CLI = join(process.cwd(), 'apps/cli/dist/main.js'); /** One JSON-RPC exchange against a freshly spawned server, as a client would. */ function rpc(cwd: string, calls: object[]): Record[] { const input = [ { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' }, }, }, { jsonrpc: '2.0', method: 'notifications/initialized' }, ...calls, ] .map((c) => JSON.stringify(c)) .join('\n') + '\n'; const out = execFileSync('node', [CLI, 'mcp'], { cwd, input, encoding: 'utf8', timeout: 25_000, }); return out .split('\n') .filter(Boolean) .map((line) => JSON.parse(line) as Record); } /** A workspace with an installed surface and one real puller. */ function project(): string { const dir = mkdtempSync(join(tmpdir(), 'mcp-res-')); writeSurface(dir); mkdirSync(join(dir, 'partials'), { recursive: true }); writeFileSync( join(dir, 'partials', 'Puller.yaml'), [ 'pullers:', ' - id: ORDERS_PULLER', ' objectId: T', ' pullSteps:', ' - key: LOAD', ' displayType: SQL', ' next: INDEX', ' - key: INDEX', ' displayType: DIRECT_INDEX', '', ].join('\n'), ); return dir; } describe('⛔ addresses are public surface, so they are pinned (PS-5)', () => { it('every fixed document keeps its name and its URI', () => { /** * ⛔ Every other test here drives its requests FROM `ADDRESSABLE`, so a rename moved both sides together and * nothing failed. The epic review renamed `hexasync://connectors` and `hexasync://examples`, and the `agent-index` * and `component-flow-nodes` resource names, rebuilt, and watched 39 tests pass. * * PS-5 makes these public: an agent's memory, a saved prompt and a client configuration all refer to them by * address. Pinned literally, because the cost of finding out later is paid by someone else's setup. */ expect(ADDRESSABLE.map((doc) => [doc.name, doc.uri])).toEqual([ ['agent-index', 'hexasync://index'], ['rule-index', 'hexasync://rules'], ['connectors', 'hexasync://connectors'], ['examples', 'hexasync://examples'], ]); }); }); describe('each address returns, and returns the same thing (AC-1)', () => { it('every declared document is fetchable, and stable across two calls', () => { expect(existsSync(CLI), 'run `yarn build`').toBe(true); const dir = project(); try { const first = rpc( dir, ADDRESSABLE.map((doc, at) => ({ jsonrpc: '2.0', id: at + 1, method: 'resources/read', params: { uri: doc.uri }, })), ); const bodies = ADDRESSABLE.map((doc, at) => { const reply = first.find((r) => r['id'] === at + 1) as | { result?: { contents?: { text?: string }[] }; error?: unknown } | undefined; expect(reply?.error, doc.uri).toBeUndefined(); const text = reply?.result?.contents?.[0]?.text ?? ''; expect(text.length, doc.uri).toBeGreaterThan(20); return text; }); // "The same address returns the same content until the underlying data changes" — nothing changed between them. const second = rpc(dir, [ { jsonrpc: '2.0', id: 99, method: 'resources/read', params: { uri: ADDRESSABLE[0]!.uri }, }, ]); const again = ( second.find((r) => r['id'] === 99) as { result?: { contents?: { text?: string }[] }; } )?.result?.contents?.[0]?.text; expect(again).toBe(bodies[0]); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('the rule index really is the rule section, not the whole document', () => { const dir = project(); try { const replies = rpc(dir, [ { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'hexasync://rules' }, }, ]); const text = ( replies.find((r) => r['id'] === 1) as { result?: { contents?: { text?: string }[] }; } )?.result?.contents?.[0]?.text ?? ''; expect(text).toMatch(/Validation rule ids/); expect(text).toMatch(/`STEP-1`/); // Bounded: it must not be the entire index wearing a different address. expect(text).not.toMatch(/## Worker step types/); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe('a flow and its node index come from the shared model (AC-2)', () => { it('the diagram is the model’s mermaid, and the nodes carry a source location', () => { const dir = project(); try { const replies = rpc(dir, [ { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'hexasync://flow/ORDERS_PULLER' }, }, { jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'hexasync://flow/ORDERS_PULLER/nodes' }, }, ]); const body = (id: number): string => ( replies.find((r) => r['id'] === id) as { result?: { contents?: { text?: string }[] }; } )?.result?.contents?.[0]?.text ?? ''; expect(body(1)).toContain('```mermaid'); expect(body(1)).toMatch(/LOAD/); const nodes = JSON.parse(body(2)) as { componentId: string; nodes: { address: string; file?: string; range?: unknown; stage: string; }[]; }; expect(nodes.componentId).toBe('ORDERS_PULLER'); expect(nodes.nodes.length).toBeGreaterThan(1); // ⛔ AC-2's real requirement: each node knows WHERE it was written. Without `locate`, the flow still renders and // this is the only assertion that would notice. const located = nodes.nodes.filter((node) => node.file && node.range); expect( located.length, 'no node carries a source location', ).toBeGreaterThan(0); expect(located[0]!.file).toMatch(/partials\/Puller\.yaml$/); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe('an address for something that does not exist NAMES it (AC-3)', () => { it('a missing component errors rather than returning an empty flow', () => { const dir = project(); try { const replies = rpc(dir, [ { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'hexasync://flow/NO_SUCH_PULLER' }, }, ]); const reply = replies.find((r) => r['id'] === 1) as { error?: { message?: string }; result?: unknown; }; expect(reply?.result).toBeUndefined(); expect(reply?.error?.message ?? '').toContain('NO_SUCH_PULLER'); // ⛔ Why an error and not empty content: an empty diagram reads as "this exists and does nothing". expect(reply?.error?.message ?? '').toMatch(/exists and does nothing/); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('an uninstalled surface says so, per document, rather than serving nothing', () => { const bare = mkdtempSync(join(tmpdir(), 'mcp-bare-')); try { const replies = rpc(bare, [ { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'hexasync://index' }, }, ]); const reply = replies.find((r) => r['id'] === 1) as { error?: { message?: string }; }; expect(reply?.error?.message ?? '').toMatch(/intellisense install/); expect(reply?.error?.message ?? '').toMatch(/says nothing/); } finally { rmSync(bare, { recursive: true, force: true }); } }); }); describe('a connector and a guide are addressable, one at a time (AC-1)', () => { /** * ⛔ AC-1 names five kinds of document, and two had no address at all: a connector ENTRY (only the whole catalogue * was addressable) and a best-practice guide (no guide was reachable by address anywhere). The epic review recorded * the AC as PARTIAL for exactly this. */ it('one connector by code, and an uncatalogued system stated as a request', () => { const dir = project(); try { const replies = rpc(dir, [ { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'hexasync://connectors/shopify-public-app' }, }, { jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'hexasync://connectors/A System With No Connector' }, }, ]); const at = (id: number) => replies.find((message) => message.id === id) as { result?: { contents?: { text?: string }[] }; error?: { message?: string }; }; expect(at(1).result?.contents?.[0]?.text).toMatch(/shopify-public-app/); /** * ⚠️ The name has a SPACE, on purpose. A URI variable arrives percent-encoded, and undecoded * `A%20System%20With%20No%20Connector` matched no catalogue row — so the answer degraded from the * unsupported-system policy to a bare "no such connector", losing the distinction the policy exists to make. */ expect(at(2).error?.message ?? '').toMatch( /request rather than a template change/, ); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('the ROUTING guide by name — `next`, in both runtimes', () => { /** * Added because nothing documented `next` at all: the only prose was two paragraphs of hover text for the * worker, one of whose sentences said a dangling target fails silently — which `StepIterator` stopped * doing at review L1. The guide is authored in `hexasync-templates-vscode-ext` and installs through the * documentation half of the bundle, like the other two. */ const dir = project(); try { const replies = rpc(dir, [ { jsonrpc: '2.0', id: 7, method: 'resources/read', params: { uri: 'hexasync://guides/routing' }, }, ]); // By ID: the transcript opens with the handshake reply, so the first line is never the answer. const guide = replies.find((reply) => reply.id === 7) as { result?: { contents?: { text?: string }[] }; }; expect(guide.result?.contents?.[0]?.text).toMatch( /how a step chooses its successor/, ); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('the QUERY DSL guide by name — one language, four hosts', () => { /** * Its own address because it has four hosts and used to live inside one of them, where the other three * were invisible. A name with a HYPHEN, which is also worth pinning: the guide map is keyed by the name * an agent types, and `query-dsl` is the first key that is not a bare word. */ const dir = project(); try { const replies = rpc(dir, [ { jsonrpc: '2.0', id: 8, method: 'resources/read', params: { uri: 'hexasync://guides/query-dsl' }, }, ]); const guide = replies.find((reply) => reply.id === 8) as { result?: { contents?: { text?: string }[] }; }; expect(guide.result?.contents?.[0]?.text).toMatch(/The Query DSL/); } finally { rmSync(dir, { recursive: true, force: true }); } }); it('a guide by name, and an unknown name LISTS the real ones (NFR-7)', () => { const dir = project(); try { const replies = rpc(dir, [ { jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'hexasync://guides/objects' }, }, { jsonrpc: '2.0', id: 1, method: 'resources/read', params: { uri: 'hexasync://guides/nope' }, }, ]); const guide = replies.find((reply) => reply.id === 2) as { result?: { contents?: { text?: string }[] }; }; expect(guide.result?.contents?.[0]?.text).toMatch(/The object model/); const message = ( replies.find((reply) => reply.id === 1) as { error?: { message?: string }; } ).error?.message ?? ''; // An agent that guesses a name learns the real ones rather than being told "no". expect(message).toMatch(/`objects`/); expect(message).toMatch(/`metrics`/); expect(message).toMatch(/`routing`/); expect(message).toMatch(/`query-dsl`/); } finally { rmSync(dir, { recursive: true, force: true }); } }); });