next.js/packages/next-codemod/lib/__tests__/agents-md-e2e.test.js
agents-md-e2e.test.js327 lines10.0 KB
/* global jest */
jest.autoMockOff()

const fs = require('fs')
const path = require('path')
const os = require('os')
const { runAgentsMd } = require('../../bin/agents-md')
const { getNextjsVersion } = require('../../lib/agents-md')

/**
 * TRUE E2E TESTS
 * These tests invoke the actual CLI entry point (runAgentsMd),
 * simulating what happens when a user runs:
 * npx @next/codemod agents-md --version 15.0.0 --output CLAUDE.md
 */
describe('agents-md e2e (CLI invocation)', () => {
  let testProjectDir
  let originalConsoleLog
  let consoleOutput

  beforeEach(() => {
    // Create isolated test project directory
    const tmpBase = process.env.NEXT_TEST_DIR || os.tmpdir()
    testProjectDir = path.join(
      tmpBase,
      `agents-md-e2e-${Date.now()}-${(Math.random() * 1000) | 0}`
    )
    fs.mkdirSync(testProjectDir, { recursive: true })

    // Mock console.log to capture CLI output
    originalConsoleLog = console.log
    consoleOutput = []
    console.log = (...args) => {
      consoleOutput.push(args.join(' '))
    }
  })

  afterEach(() => {
    // Restore console.log
    console.log = originalConsoleLog

    // Clean up test directory
    if (testProjectDir && fs.existsSync(testProjectDir)) {
      fs.rmSync(testProjectDir, { recursive: true, force: true })
    }
  })

  it('creates CLAUDE.md and .next-docs directory when run with --version and --output', async () => {
    // Create a minimal package.json (not required, but realistic)
    const packageJson = {
      name: 'test-project',
      version: '1.0.0',
      dependencies: {
        next: '15.0.0',
      },
    }
    fs.writeFileSync(
      path.join(testProjectDir, 'package.json'),
      JSON.stringify(packageJson, null, 2)
    )

    // Change to test directory
    const originalCwd = process.cwd()
    process.chdir(testProjectDir)

    try {
      // Run the actual CLI command
      await runAgentsMd({
        version: '15.0.0',
        output: 'CLAUDE.md',
      })

      // Verify .next-docs directory was created and populated
      const docsDir = path.join(testProjectDir, '.next-docs')
      expect(fs.existsSync(docsDir)).toBe(true)

      const docFiles = fs.readdirSync(docsDir, { recursive: true })
      expect(docFiles.length).toBeGreaterThan(0)

      // Should contain mdx/md files
      const mdxFiles = docFiles.filter(
        (f) => f.endsWith('.mdx') || f.endsWith('.md')
      )
      expect(mdxFiles.length).toBeGreaterThan(0)

      // Verify CLAUDE.md was created
      const claudeMdPath = path.join(testProjectDir, 'CLAUDE.md')
      expect(fs.existsSync(claudeMdPath)).toBe(true)

      const claudeMdContent = fs.readFileSync(claudeMdPath, 'utf-8')

      // Verify content structure
      expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-START -->')
      expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-END -->')
      expect(claudeMdContent).toContain('[Next.js Docs Index]')
      expect(claudeMdContent).toContain('root: ./.next-docs')

      // Verify paths are normalized to forward slashes (cross-platform)
      const lines = claudeMdContent.split('|')
      const pathLines = lines.filter((line) => line.includes(':'))
      pathLines.forEach((line) => {
        // Should not contain Windows backslashes in the output
        const pathPart = line.split(':')[0]
        if (pathPart && pathPart.includes('/')) {
          expect(line).not.toMatch(/[^:]\\/)
        }
      })

      // Verify .gitignore was updated
      const gitignorePath = path.join(testProjectDir, '.gitignore')
      if (fs.existsSync(gitignorePath)) {
        const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8')
        expect(gitignoreContent).toContain('.next-docs')
      }

      // Verify console output
      const output = consoleOutput.join('\n')
      expect(output).toContain('Downloading Next.js')
      expect(output).toContain('15.0.0')
      expect(output).toContain('CLAUDE.md')
    } finally {
      // Restore original directory
      process.chdir(originalCwd)
    }
  })

  it('updates existing CLAUDE.md without losing content', async () => {
    const originalCwd = process.cwd()
    process.chdir(testProjectDir)

    try {
      // Create existing CLAUDE.md with custom content
      const existingContent = `# My Project

This is my project documentation.

## Features
- Feature 1
- Feature 2
`
      fs.writeFileSync(
        path.join(testProjectDir, 'CLAUDE.md'),
        existingContent
      )

      // Run CLI
      await runAgentsMd({
        version: '15.0.0',
        output: 'CLAUDE.md',
      })

      // Verify file was updated, not replaced
      const claudeMdContent = fs.readFileSync(
        path.join(testProjectDir, 'CLAUDE.md'),
        'utf-8'
      )

      // Original content should still be there
      expect(claudeMdContent).toContain('# My Project')
      expect(claudeMdContent).toContain('This is my project documentation.')
      expect(claudeMdContent).toContain('## Features')

      // New index should be injected
      expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-START -->')
      expect(claudeMdContent).toContain('[Next.js Docs Index]')
    } finally {
      process.chdir(originalCwd)
    }
  })

  it('handles custom output filename', async () => {
    const originalCwd = process.cwd()
    process.chdir(testProjectDir)

    try {
      // Run with custom output file
      await runAgentsMd({
        version: '15.0.0',
        output: 'AGENTS.md',
      })

      // Verify AGENTS.md was created (not CLAUDE.md)
      expect(fs.existsSync(path.join(testProjectDir, 'AGENTS.md'))).toBe(true)
      expect(fs.existsSync(path.join(testProjectDir, 'CLAUDE.md'))).toBe(false)

      const agentsMdContent = fs.readFileSync(
        path.join(testProjectDir, 'AGENTS.md'),
        'utf-8'
      )
      expect(agentsMdContent).toContain('[Next.js Docs Index]')
    } finally {
      process.chdir(originalCwd)
    }
  })

  it('works when run from a subdirectory', async () => {
    const originalCwd = process.cwd()

    // Create a subdirectory
    const subDir = path.join(testProjectDir, 'packages', 'app')
    fs.mkdirSync(subDir, { recursive: true })

    // Create package.json in root
    const packageJson = {
      dependencies: { next: '15.0.0' },
    }
    fs.writeFileSync(
      path.join(testProjectDir, 'package.json'),
      JSON.stringify(packageJson)
    )

    // Change to subdirectory
    process.chdir(subDir)

    try {
      // Run from subdirectory - should create files in CWD (subdirectory)
      await runAgentsMd({
        version: '15.0.0',
        output: 'CLAUDE.md',
      })

      // Verify files created in subdirectory
      expect(fs.existsSync(path.join(subDir, 'CLAUDE.md'))).toBe(true)
      expect(fs.existsSync(path.join(subDir, '.next-docs'))).toBe(true)
    } finally {
      process.chdir(originalCwd)
    }
  })

  it('normalizes paths on Windows (cross-platform test)', async () => {
    const originalCwd = process.cwd()
    process.chdir(testProjectDir)

    try {
      await runAgentsMd({
        version: '15.0.0',
        output: 'CLAUDE.md',
      })

      const claudeMdContent = fs.readFileSync(
        path.join(testProjectDir, 'CLAUDE.md'),
        'utf-8'
      )

      // Extract the index content between markers
      const startMarker = '<!-- NEXT-AGENTS-MD-START -->'
      const endMarker = '<!-- NEXT-AGENTS-MD-END -->'
      const startIdx = claudeMdContent.indexOf(startMarker) + startMarker.length
      const endIdx = claudeMdContent.indexOf(endMarker)
      const indexContent = claudeMdContent.slice(startIdx, endIdx)

      // Parse the index (format: "dir:{file1,file2}|dir2:{file3}")
      const sections = indexContent.split('|').filter((s) => s.includes(':'))

      sections.forEach((section) => {
        const [dirPath, filesStr] = section.split(':')
        if (dirPath && dirPath.trim() && !dirPath.includes('root')) {
          // Verify no Windows backslashes in directory paths
          expect(dirPath).not.toContain('\\')
          // Verify uses forward slashes
          if (dirPath.includes('/')) {
            expect(dirPath).toMatch(/^[^\\]+$/)
          }
        }
      })
    } finally {
      process.chdir(originalCwd)
    }
  })

  it('handles version that requires git clone from GitHub', async () => {
    const originalCwd = process.cwd()
    process.chdir(testProjectDir)

    try {
      // Use a known stable version
      await runAgentsMd({
        version: '14.2.0',
        output: 'CLAUDE.md',
      })

      // Verify docs were downloaded
      const docsDir = path.join(testProjectDir, '.next-docs')
      expect(fs.existsSync(docsDir)).toBe(true)

      const docFiles = fs.readdirSync(docsDir, { recursive: true })
      const mdxFiles = docFiles.filter(
        (f) => f.endsWith('.mdx') || f.endsWith('.md')
      )
      expect(mdxFiles.length).toBeGreaterThan(50) // Should have many doc files
    } finally {
      process.chdir(originalCwd)
    }
  }, 30000) // Increase timeout for git clone

  describe('getNextjsVersion', () => {
    const fixturesDir = path.join(__dirname, 'fixtures/agents-md')

    it('returns the installed Next.js version from node_modules', () => {
      const fixture = path.join(fixturesDir, 'next-specific-version')
      const result = getNextjsVersion(fixture)

      expect(result.version).toBe('15.4.0')
      expect(result.error).toBeUndefined()
    })

    it('returns actual installed version, not the tag from package.json', () => {
      // package.json has "next": "latest", but node_modules has version "16.0.0"
      const fixture = path.join(fixturesDir, 'next-tag')
      const result = getNextjsVersion(fixture)

      // Should return the actual installed version, not "latest"
      expect(result.version).toBe('16.0.0')
      expect(result.error).toBeUndefined()
    })

    it('returns error when Next.js is not installed', () => {
      // Use a directory where next is not installed
      const nonNextDir = '/tmp'
      const result = getNextjsVersion(nonNextDir)

      expect(result.version).toBeNull()
      expect(result.error).toBe('Next.js is not installed in this project.')
    })
  })
})
Quest for Codev2.0.0
/
SIGN IN