import { buildGraph, getBrokenExternalLinks } from '@mintlify/link-rot'; import { MdxPath } from '@mintlify/link-rot/dist/graph.js'; import { addLog, clearLogs } from '@mintlify/previewing'; import path from 'node:path'; import { mockProcessExit } from 'vitest-mock-process'; import { checkForMintJson, CMD_EXEC_PATH, findDocsRoot } from '../src/helpers.js'; import { resolveExplicitFiles } from '../src/resolveFiles.js'; import { runCommand } from './utils.js'; vi.mock('@mintlify/previewing', async (importOriginal) => { const mod = await importOriginal(); return { ...mod, addLog: vi.fn(), clearLogs: vi.fn() }; }); vi.mock('../src/helpers.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, checkForMintJson: vi.fn(), findDocsRoot: vi.fn(), }; }); vi.mock('../src/resolveFiles.js', () => ({ resolveExplicitFiles: vi.fn(), })); const mockGraph = { precomputeFileResolutions: vi.fn(), getBrokenInternalLinks: vi.fn().mockReturnValue([]), getExternalPathsByNode: vi.fn().mockReturnValue(new Map()), }; vi.mock('@mintlify/link-rot', async () => { const actual = await import('@mintlify/link-rot'); return { ...actual, buildGraph: vi.fn(), getBrokenExternalLinks: vi.fn(), }; }); const addLogSpy = vi.mocked(addLog); const clearLogsSpy = vi.mocked(clearLogs); const processExitMock = mockProcessExit(); describe('brokenLinks', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(buildGraph).mockResolvedValue(mockGraph as never); vi.mocked(resolveExplicitFiles).mockResolvedValue([]); vi.mocked(findDocsRoot).mockResolvedValue(CMD_EXEC_PATH); mockGraph.getBrokenInternalLinks.mockReturnValue([]); }); it('success with no broken links', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); await runCommand('broken-links'); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { message: 'checking for broken links...' }, }) ); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { message: 'no broken links found' }, }) ); expect(processExitMock).toHaveBeenCalledWith(0); }); it('fails with broken links', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); mockGraph.getBrokenInternalLinks.mockReturnValue([ { relativeDir: '.', filename: 'introduction.mdx', originalPath: '/api/invalid-path', pathType: 'internal', } as MdxPath, ]); await runCommand('broken-links'); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { message: 'checking for broken links...' }, }) ); expect(clearLogsSpy).toHaveBeenCalled(); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { brokenLinksByFile: { 'introduction.mdx': ['/api/invalid-path'], }, }, }) ); expect(processExitMock).toHaveBeenCalledWith(1); }); it('checks only files matching the provided option', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(resolveExplicitFiles).mockResolvedValueOnce(['guides/selected.mdx']); mockGraph.getBrokenInternalLinks.mockReturnValue([ { relativeDir: 'guides', filename: 'selected.mdx', originalPath: '/api/selected-invalid', pathType: 'internal', } as MdxPath, { relativeDir: 'guides', filename: 'other.mdx', originalPath: '/api/other-invalid', pathType: 'internal', } as MdxPath, ]); await runCommand('broken-links', '--files', 'guides/selected.mdx', 'guides/**/*.mdx'); expect(resolveExplicitFiles).toHaveBeenCalledWith(CMD_EXEC_PATH, [ 'guides/selected.mdx', 'guides/**/*.mdx', ]); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { brokenLinksByFile: { [path.join('guides', 'selected.mdx')]: ['/api/selected-invalid'], }, }, }) ); expect(processExitMock).toHaveBeenCalledWith(1); }); it('builds the graph from the docs root when run inside a content folder', async () => { const docsRoot = path.resolve(CMD_EXEC_PATH, '..', '..'); vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(findDocsRoot).mockResolvedValue(docsRoot); vi.mocked(resolveExplicitFiles).mockResolvedValueOnce(['apis.mdx']); mockGraph.getBrokenInternalLinks.mockReturnValue([]); await runCommand('broken-links', '--files', 'apis.mdx'); expect(buildGraph).toHaveBeenCalledWith(docsRoot, expect.anything()); expect(processExitMock).toHaveBeenCalledWith(0); }); it('rebases matched files onto the docs root so nested links still resolve', async () => { const docsRoot = path.resolve(CMD_EXEC_PATH, '..', '..'); const nested = path.relative(docsRoot, path.join(CMD_EXEC_PATH, 'apis.mdx')); vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(findDocsRoot).mockResolvedValue(docsRoot); vi.mocked(resolveExplicitFiles).mockResolvedValueOnce(['apis.mdx']); mockGraph.getBrokenInternalLinks.mockReturnValue([ { relativeDir: path.dirname(nested), filename: 'apis.mdx', originalPath: '/docs/authenticate', pathType: 'internal', } as MdxPath, ]); await runCommand('broken-links', '--files', 'apis.mdx'); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { brokenLinksByFile: { [nested]: ['/docs/authenticate'] } }, }) ); expect(processExitMock).toHaveBeenCalledWith(1); }); it('fails when checking throws error', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(buildGraph).mockRejectedValueOnce(new Error('some error')); await runCommand('broken-links'); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { message: 'checking for broken links...' }, }) ); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { message: 'some error' }, }) ); expect(processExitMock).toHaveBeenCalledWith(1); }); }); describe('brokenLinks --check-external', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(buildGraph).mockResolvedValue(mockGraph as never); vi.mocked(resolveExplicitFiles).mockResolvedValue([]); vi.mocked(findDocsRoot).mockResolvedValue(CMD_EXEC_PATH); mockGraph.getBrokenInternalLinks.mockReturnValue([]); }); it('success with no broken external links', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(getBrokenExternalLinks).mockResolvedValueOnce([]); await runCommand('broken-links', '--check-external'); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { message: 'no broken links found' }, }) ); expect(processExitMock).toHaveBeenCalledWith(0); }); it('fails with broken external links', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(getBrokenExternalLinks).mockResolvedValueOnce([ { url: 'https://example.com/broken', status: 404, sources: [{ file: 'introduction.mdx', originalPath: 'https://example.com/broken' }], }, ]); await runCommand('broken-links', '--check-external'); expect(clearLogsSpy).toHaveBeenCalled(); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { brokenLinksByFile: { 'introduction.mdx': ['https://example.com/broken (404)'], }, }, }) ); expect(processExitMock).toHaveBeenCalledWith(1); }); it('checks external links only from matching files', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(resolveExplicitFiles).mockResolvedValueOnce(['selected.mdx']); vi.mocked(getBrokenExternalLinks).mockResolvedValueOnce([]); await runCommand('broken-links', '--files', 'selected.mdx', '--check-external'); expect(getBrokenExternalLinks).toHaveBeenCalledWith(mockGraph, { sourceFiles: new Set(['selected.mdx']), }); expect(processExitMock).toHaveBeenCalledWith(0); }); it('fails when external link checking throws error', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); vi.mocked(getBrokenExternalLinks).mockRejectedValueOnce(new Error('network error')); await runCommand('broken-links', '--check-external'); expect(addLogSpy).toHaveBeenCalledWith( expect.objectContaining({ props: { message: 'network error' }, }) ); expect(processExitMock).toHaveBeenCalledWith(1); }); it('does not check external links without --check-external flag', async () => { vi.mocked(checkForMintJson).mockResolvedValueOnce(true); await runCommand('broken-links'); expect(getBrokenExternalLinks).not.toHaveBeenCalled(); expect(processExitMock).toHaveBeenCalledWith(0); }); });