Skip to content
Merged
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
89 changes: 89 additions & 0 deletions packages/mcp/tests/support/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { cp, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { simpleGit } from 'simple-git'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createServer } from '../../src/server.js'

/**
* Shared test-support for the MCP write-path suites.
*
* Why this exists: on macOS every `git` subprocess spawned from inside a
* loaded vitest worker costs ~150ms (≈14× the standalone cost) because the
* fork/posix_spawn work scales with the worker's address space — not with
* git itself, whose own runtime per command is ~1–20ms. A single
* `contentrain_init` transaction spawns ~28 git processes, so re-running it
* in every `beforeEach` dominates suite wall-clock.
*
* The fix is to spawn git as few times as possible:
* - read-only suites build ONE fixture in `beforeAll` and share it;
* - mutating suites build ONE inited template in `beforeAll` and give each
* test an isolated copy via {@link cloneTemplate} — a recursive file copy
* spawns zero git processes, versus ~28 for a fresh init.
*/

/** Build an MCP client wired to a fresh in-memory server over the given root. */
export async function createClient(projectRoot: string): Promise<Client> {
const server = createServer(projectRoot)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
const client = new Client({ name: 'test-client', version: '1.0.0' })
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
])
return client
}

/** Parse the JSON payload out of a tool result's first text content block. */
export function parseResult(result: unknown): Record<string, unknown> {
const content = (result as { content: Array<{ text: string }> }).content
return JSON.parse(content[0]!.text) as Record<string, unknown>
}

/** git init + identity + one empty commit. The minimal committable repo. */
export async function initGitRepo(dir: string): Promise<void> {
const git = simpleGit(dir)
await git.init()
await git.addConfig('user.name', 'Test')
await git.addConfig('user.email', 'test@test.com')
await git.commit('initial', { '--allow-empty': null, '--no-verify': null })
}

/**
* Create a temp git repo and run `contentrain_init` once, returning the path.
* Call this in `beforeAll`; hand each test a private copy with
* {@link cloneTemplate}. `prepare` runs after the repo is committed but
* before init — use it to lay down source files that must exist at init time.
*/
export async function makeInitedTemplate(opts?: {
locales?: string[]
prepare?: (dir: string) => Promise<void>
}): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'cr-template-'))
await initGitRepo(dir)
if (opts?.prepare) {
await opts.prepare(dir)
const git = simpleGit(dir)
await git.add('.')
await git.commit('fixture sources', { '--no-verify': null })
}
const client = await createClient(dir)
await client.callTool({
name: 'contentrain_init',
arguments: opts?.locales ? { locales: opts.locales } : {},
})
return dir
}

/**
* Copy an inited template into a fresh temp dir — an isolated, ready-to-use
* project with zero git subprocesses. The `.git` directory copies as plain
* files; the template leaves no worktrees behind, so the copy is a fully
* functional repo at its new path.
*/
export async function cloneTemplate(template: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'cr-clone-'))
await cp(template, dir, { recursive: true })
return dir
}
74 changes: 25 additions & 49 deletions packages/mcp/tests/tools/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,19 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
import { describe, expect, it, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'

vi.setConfig({ testTimeout: 120000, hookTimeout: 120000 })
import { join } from 'node:path'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { simpleGit } from 'simple-git'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createServer } from '../../src/server.js'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { createClient, cloneTemplate, initGitRepo, makeInitedTemplate } from '../support/project.js'
import { pathExists, readJson } from '../../src/util/fs.js'
import type { ModelDefinition } from '@contentrain/types'

let template: string
let testDir: string
let client: Client

async function initProject(dir: string): Promise<void> {
const git = simpleGit(dir)
await git.init()
await git.addConfig('user.name', 'Test')
await git.addConfig('user.email', 'test@test.com')
await writeFile(join(dir, '.gitkeep'), '')
await git.add('.')
await git.commit('initial')
}

async function createTestClient(projectRoot: string): Promise<Client> {
const server = createServer(projectRoot)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()

const c = new Client({ name: 'test-client', version: '1.0.0' })
await Promise.all([
c.connect(clientTransport),
server.connect(serverTransport),
])

return c
}

function parseResult(result: unknown): Record<string, unknown> {
const content = (result as { content: Array<{ text: string }> }).content
const text = content[0]!.text
Expand All @@ -47,14 +24,19 @@ function parseResult(result: unknown): Record<string, unknown> {
}
}

beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), 'cr-model-test-'))
await initProject(testDir)
client = await createTestClient(testDir)
// Init the project ONCE; each test gets an isolated copy via a file-copy
// (zero git spawns) instead of re-running the ~28-spawn init transaction.
beforeAll(async () => {
template = await makeInitedTemplate()
})

// Initialize project first
await client.callTool({ name: 'contentrain_init', arguments: {} })
client = await createTestClient(testDir)
afterAll(async () => {
await rm(template, { recursive: true, force: true })
})

beforeEach(async () => {
testDir = await cloneTemplate(template)
client = await createClient(testDir)
})

afterEach(async () => {
Expand Down Expand Up @@ -113,7 +95,7 @@ describe('contentrain_model_save', () => {
},
})

client = await createTestClient(testDir)
client = await createClient(testDir)

// Update
const result = await client.callTool({
Expand Down Expand Up @@ -168,15 +150,9 @@ describe('contentrain_model_save', () => {

it('returns error when not initialized', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'cr-empty-'))
const git = simpleGit(emptyDir)
await git.init()
await git.addConfig('user.name', 'Test')
await git.addConfig('user.email', 'test@test.com')
await writeFile(join(emptyDir, '.gitkeep'), '')
await git.add('.')
await git.commit('initial')

const emptyClient = await createTestClient(emptyDir)
await initGitRepo(emptyDir)

const emptyClient = await createClient(emptyDir)
const result = await emptyClient.callTool({
name: 'contentrain_model_save',
arguments: {
Expand Down Expand Up @@ -210,7 +186,7 @@ describe('contentrain_model_delete', () => {
},
})

client = await createTestClient(testDir)
client = await createClient(testDir)

const result = await client.callTool({
name: 'contentrain_model_delete',
Expand Down Expand Up @@ -241,7 +217,7 @@ describe('contentrain_model_delete', () => {
},
})

client = await createTestClient(testDir)
client = await createClient(testDir)

// Create referencing model
await client.callTool({
Expand All @@ -259,7 +235,7 @@ describe('contentrain_model_delete', () => {
},
})

client = await createTestClient(testDir)
client = await createClient(testDir)

// Try to delete authors — should be blocked
const result = await client.callTool({
Expand Down
73 changes: 12 additions & 61 deletions packages/mcp/tests/tools/normalize.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,15 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
import { describe, expect, it, beforeAll, afterAll, vi } from 'vitest'

vi.setConfig({ testTimeout: 120000, hookTimeout: 120000 })
import { join } from 'node:path'
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { simpleGit } from 'simple-git'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createServer } from '../../src/server.js'
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { createClient, initGitRepo, makeInitedTemplate, parseResult } from '../support/project.js'

let testDir: string
let client: Client

async function initGitRepo(dir: string): Promise<void> {
const git = simpleGit(dir)
await git.init()
await git.addConfig('user.name', 'Test')
await git.addConfig('user.email', 'test@test.com')
await writeFile(join(dir, '.gitkeep'), '')
await git.add('.')
await git.commit('initial')
}

async function createTestClient(projectRoot: string): Promise<Client> {
const server = createServer(projectRoot)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()

const c = new Client({ name: 'test-client', version: '1.0.0' })
await Promise.all([
c.connect(clientTransport),
server.connect(serverTransport),
])

return c
}

function parseResult(result: unknown): Record<string, unknown> {
const content = (result as { content: Array<{ text: string }> }).content
return JSON.parse(content[0]!.text) as Record<string, unknown>
}

async function createSourceFiles(dir: string): Promise<void> {
// Create directory structure
await mkdir(join(dir, 'src', 'pages'), { recursive: true })
Expand Down Expand Up @@ -126,27 +96,14 @@ export function MainLayout({ children }: { children: React.ReactNode }) {
`)
}

beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), 'cr-scan-test-'))
await initGitRepo(testDir)

// Create source files before init so they exist during project setup
await createSourceFiles(testDir)

// Commit source files so git is clean
const git = simpleGit(testDir)
await git.add('.')
await git.commit('add source files')

// Create client and initialize project
client = await createTestClient(testDir)
await client.callTool({ name: 'contentrain_init', arguments: {} })

// Re-create client after init
client = await createTestClient(testDir)
// contentrain_scan is read-only (no disk/git writes), so all tests share ONE
// inited fixture built once — instead of paying a ~28-git-spawn init per test.
beforeAll(async () => {
testDir = await makeInitedTemplate({ prepare: createSourceFiles })
client = await createClient(testDir)
})

afterEach(async () => {
afterAll(async () => {
await rm(testDir, { recursive: true, force: true })
})

Expand Down Expand Up @@ -396,15 +353,9 @@ describe('contentrain_scan mode:candidates', () => {

it('returns error if not initialized', async () => {
const emptyDir = await mkdtemp(join(tmpdir(), 'cr-scan-empty-'))
const git = simpleGit(emptyDir)
await git.init()
await git.addConfig('user.name', 'Test')
await git.addConfig('user.email', 'test@test.com')
await writeFile(join(emptyDir, '.gitkeep'), '')
await git.add('.')
await git.commit('initial')

const emptyClient = await createTestClient(emptyDir)
await initGitRepo(emptyDir)

const emptyClient = await createClient(emptyDir)
const result = await emptyClient.callTool({
name: 'contentrain_scan',
arguments: { mode: 'candidates' },
Expand Down
Loading
Loading