diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..ef1ba16 --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,45 @@ +name: Integration Tests + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + integration: + runs-on: ubuntu-latest + env: + OVERLEAF_GIT_AUTHOR_NAME: OverleafMCP CI + OVERLEAF_GIT_AUTHOR_EMAIL: ci@overleafmcp.test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - name: Write projects.json from secrets + run: | + cat > projects.json << 'EOF' + { + "projects": { + "example_project": { + "name": "Example Project", + "projectId": "${{ secrets.EXAMPLE_PROJECT_ID }}", + "gitToken": "${{ secrets.EXAMPLE_PROJECT_TOKEN }}", + "readOnly": false + }, + "readonly_example_project": { + "name": "ReadOnly Example Project", + "projectId": "${{ secrets.READONLY_PROJECT_ID }}", + "gitToken": "${{ secrets.READONLY_PROJECT_TOKEN }}", + "readOnly": true + } + } + } + EOF + - name: Run integration tests + run: node --test test/integration.test.js + - name: Restore test files on failure + if: failure() + run: node test/restore.js \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..687e805 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,18 @@ +name: Unit Tests + +on: + pull_request: + branches: [dev, main] + workflow_dispatch: + +jobs: + unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - run: node --test test/unit.test.js \ No newline at end of file diff --git a/.gitignore b/.gitignore index 06d5507..4d6f085 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ temp/ +spec/ projects.json .env *.log \ No newline at end of file diff --git a/README.md b/README.md index 951417b..0a3e8cb 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ ![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen) ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg) ![MCP](https://img.shields.io/badge/MCP-compatible-purple) +![Unit Tests](https://github.com/SemPlaatsman/OverleafMCP/actions/workflows/unit-tests.yml/badge.svg) +![Integration Tests](https://github.com/SemPlaatsman/OverleafMCP/actions/workflows/integration-tests.yml/badge.svg) > Give any MCP-compatible AI assistant direct access to your Overleaf projects. @@ -284,6 +286,26 @@ Resolution order: `readOnly: true` takes precedence over everything and blocks a --- +## Testing + +OverleafMCP has a two-layer test suite built on Node.js's built-in `node:test` module — no extra test dependencies required. + +**Unit tests** cover pure logic: section and BibTeX parsing, path traversal protection, and anchor uniqueness. They run in under a second with no credentials or network access: + +```bash +npm test +``` + +**Integration tests** run the full tool stack against a live Overleaf project via git, verifying every tool end-to-end including push verification via `listHistory`. They require a `projects.json` with writable and read-only test projects configured: + +```bash +npm run test:integration +``` + +CI runs unit tests on every PR to `dev` and `main`. Integration tests run on PRs to `main` only. + +--- + ## Attribution See [ATTRIBUTION.md](./ATTRIBUTION.md) for credits to the open-source projects that informed this work. diff --git a/overleaf-git-client.js b/overleaf-git-client.js index b357a47..852d045 100644 --- a/overleaf-git-client.js +++ b/overleaf-git-client.js @@ -17,6 +17,8 @@ const SECTION_LEVELS = { subparagraph: 6, }; +export const PREVIEW_MAX_LENGTH = 100; + export class OverleafGitClient { constructor(projectId, gitToken, tempDir) { this.projectId = projectId; @@ -275,7 +277,7 @@ export class OverleafGitClient { const contentStart = entry._cmdEndIndex; const contentEnd = i + 1 < flat.length ? flat[i + 1].startIndex : content.length; entry.content = content.substring(contentStart, contentEnd).trim(); - entry.preview = entry.content.substring(0, 100).replace(/\s+/g, ' '); + entry.preview = entry.content.substring(0, PREVIEW_MAX_LENGTH).replace(/\s+/g, ' '); delete entry._cmdEndIndex; }); diff --git a/overleaf-mcp-server.js b/overleaf-mcp-server.js index b1c50e8..249cf03 100644 --- a/overleaf-mcp-server.js +++ b/overleaf-mcp-server.js @@ -26,14 +26,6 @@ const TEMP_DIR = process.env.OVERLEAF_TEMP_DIR : path.join(__dirname, 'temp'); let projectsConfig; -try { - const raw = await readFile(PROJECTS_FILE, 'utf-8'); - projectsConfig = JSON.parse(raw); -} catch (err) { - console.error(`[OverleafMCP] Failed to load projects config from "${PROJECTS_FILE}": ${err.message}`); - console.error('[OverleafMCP] Please create projects.json from projects.example.json'); - process.exit(1); -} // ─── Write-tool registry ────────────────────────────────────────────────────── // @@ -52,27 +44,6 @@ const WRITE_TOOLS = new Set([ 'remove_bib_entry', ]); -// Validate disallowedTools entries at startup so typos surface immediately -// rather than silently doing nothing at runtime. -for (const [key, project] of Object.entries(projectsConfig.projects ?? {})) { - for (const toolName of (project.disallowedTools ?? [])) { - if (!WRITE_TOOLS.has(toolName)) { - console.warn( - `[OverleafMCP] Warning: unknown tool "${toolName}" in disallowedTools for project "${key}". ` + - `Valid tools are: ${[...WRITE_TOOLS].join(', ')}` - ); - } - } -} -for (const toolName of (projectsConfig.defaults?.disallowedTools ?? [])) { - if (!WRITE_TOOLS.has(toolName)) { - console.warn( - `[OverleafMCP] Warning: unknown tool "${toolName}" in defaults.disallowedTools. ` + - `Valid tools are: ${[...WRITE_TOOLS].join(', ')}` - ); - } -} - // ─── In-process per-project mutex ──────────────────────────────────────────── // // Serializes all git operations for a given project within this process. @@ -98,8 +69,8 @@ async function withProjectLock(projectId, fn) { // ─── Project resolution ─────────────────────────────────────────────────────── -function getProject(projectName) { - const projects = projectsConfig.projects ?? {}; +function getProject(projectName, config = projectsConfig) { + const projects = config.projects ?? {}; const keys = Object.keys(projects); if (keys.length === 0) { @@ -130,7 +101,7 @@ function getProject(projectName) { // Resolve disallowedTools: per-project list takes precedence over the global // defaults block. readOnly: true is a shorthand for disallowing all write tools // and takes precedence over everything else. - const globalDefaults = projectsConfig.defaults?.disallowedTools ?? []; + const globalDefaults = config.defaults?.disallowedTools ?? []; const projectDisallowed = project.disallowedTools ?? globalDefaults; const disallowedTools = new Set( project.readOnly === true ? [...WRITE_TOOLS] : projectDisallowed @@ -153,614 +124,624 @@ const server = new Server( ); // ─── Tool definitions ───────────────────────────────────────────────────────── - -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'list_projects', - description: 'List all configured Overleaf projects', - inputSchema: { - type: 'object', - properties: {}, - additionalProperties: false, - }, +// +// TOOLS is the single source of truth for all tool names and schemas. +// TOOL_NAMES is exported so integration tests can assert that every tool +// has been exercised without maintaining a separate hardcoded list. +// +const TOOLS = [ + { + name: 'list_projects', + description: 'List all configured Overleaf projects', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, }, - { - name: 'list_files', - description: 'List files in an Overleaf project', - inputSchema: { - type: 'object', - properties: { - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', - }, - extension: { - type: 'string', - description: 'File extension filter (optional, e.g. ".tex"). Defaults to ".tex"', - }, - }, - additionalProperties: false, + }, + { + name: 'list_files', + description: 'List files in an Overleaf project', + inputSchema: { + type: 'object', + properties: { + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', + }, + extension: { + type: 'string', + description: 'File extension filter (optional, e.g. ".tex"). Defaults to ".tex"', + }, }, + additionalProperties: false, }, - { - name: 'read_file', - description: 'Read a file from an Overleaf project', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the file', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', - }, - }, - required: ['filePath'], - additionalProperties: false, + }, + { + name: 'read_file', + description: 'Read a file from an Overleaf project', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', + }, }, + required: ['filePath'], + additionalProperties: false, }, - { - name: 'get_sections', - description: - 'Get all sections from a .tex file as a hierarchical tree. Each node includes ' + - 'its type (section, subsection, etc.), title, character offset, the text content ' + - 'immediately following that heading (not including children), a 100-character ' + - 'preview, and a children array of nested sections. Use this for document structure ' + - 'overview and to identify which section to read or edit next. ' + - 'Only applicable to .tex files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the LaTeX file', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath'], - additionalProperties: false, + }, + { + name: 'get_sections', + description: + 'Get all sections from a .tex file as a hierarchical tree. Each node includes ' + + 'its type (section, subsection, etc.), title, character offset, the text content ' + + 'immediately following that heading (not including children), a 100-character ' + + 'preview, and a children array of nested sections. Use this for document structure ' + + 'overview and to identify which section to read or edit next. ' + + 'Only applicable to .tex files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the LaTeX file', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath'], + additionalProperties: false, }, - { - name: 'get_section_content', - description: - 'Get the full content of a specific section in a .tex file. ' + - 'If the same section title appears under multiple parent sections, supply ' + - 'parentTitle to disambiguate. Only applicable to .tex files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the LaTeX file', - }, - sectionTitle: { - type: 'string', - description: 'Title of the section (must match exactly)', - }, - parentTitle: { - type: 'string', - description: - 'Title of the parent section, used to disambiguate when the same ' + - 'sectionTitle appears under multiple parents (optional)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'sectionTitle'], - additionalProperties: false, + }, + { + name: 'get_section_content', + description: + 'Get the full content of a specific section in a .tex file. ' + + 'If the same section title appears under multiple parent sections, supply ' + + 'parentTitle to disambiguate. Only applicable to .tex files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the LaTeX file', + }, + sectionTitle: { + type: 'string', + description: 'Title of the section (must match exactly)', + }, + parentTitle: { + type: 'string', + description: + 'Title of the parent section, used to disambiguate when the same ' + + 'sectionTitle appears under multiple parents (optional)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'sectionTitle'], + additionalProperties: false, }, - { - name: 'get_preamble', - description: - 'Get everything before the first sectioning command in a .tex file: ' + - 'the document class declaration, package imports, and custom command definitions. ' + - 'Returns the full file content if the file contains no sections. ' + - 'Only applicable to .tex files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the LaTeX file', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath'], - additionalProperties: false, + }, + { + name: 'get_preamble', + description: + 'Get everything before the first sectioning command in a .tex file: ' + + 'the document class declaration, package imports, and custom command definitions. ' + + 'Returns the full file content if the file contains no sections. ' + + 'Only applicable to .tex files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the LaTeX file', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath'], + additionalProperties: false, }, - { - name: 'get_postamble', - description: - 'Get everything from \\end{document} (inclusive) to the end of the file. ' + - 'Returns an empty string if \\end{document} is absent (e.g. \\input\'d files). ' + - 'Note: bibliography commands (\\bibliography{}, \\bibliographystyle{}, ' + - '\\printbibliography) appear before \\end{document} and are part of the last ' + - "section's content range — use str_replace to edit them. " + - 'Only applicable to .tex files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the LaTeX file', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath'], - additionalProperties: false, + }, + { + name: 'get_postamble', + description: + 'Get everything from \\end{document} (inclusive) to the end of the file. ' + + 'Returns an empty string if \\end{document} is absent (e.g. \\input\'d files). ' + + 'Note: bibliography commands (\\bibliography{}, \\bibliographystyle{}, ' + + '\\printbibliography) appear before \\end{document} and are part of the last ' + + "section's content range — use str_replace to edit them. " + + 'Only applicable to .tex files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the LaTeX file', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath'], + additionalProperties: false, }, - { - name: 'list_history', - description: - 'Show recent git commits for the project, optionally filtered by file path ' + - 'and/or time range. Each entry includes the commit hash, date, author, and subject.', - inputSchema: { - type: 'object', - properties: { - limit: { - type: 'integer', - description: 'Maximum number of commits to return (optional, default 20, max 200)', - }, - filePath: { - type: 'string', - description: 'Restrict history to a specific file path (optional)', - }, - since: { - type: 'string', - description: 'git --since filter, e.g. "2.weeks" or "2025-01-01" (optional)', - }, - until: { - type: 'string', - description: 'git --until filter (optional)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - additionalProperties: false, + }, + { + name: 'list_history', + description: + 'Show recent git commits for the project, optionally filtered by file path ' + + 'and/or time range. Each entry includes the commit hash, date, author, and subject.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'integer', + description: 'Maximum number of commits to return (optional, default 20, max 200)', + }, + filePath: { + type: 'string', + description: 'Restrict history to a specific file path (optional)', + }, + since: { + type: 'string', + description: 'git --since filter, e.g. "2.weeks" or "2025-01-01" (optional)', + }, + until: { + type: 'string', + description: 'git --until filter (optional)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + additionalProperties: false, }, - { - name: 'get_diff', - description: - 'Get a unified diff for the project. By default returns all changes since the ' + - 'last commit (working tree vs HEAD). Supply fromRef and/or toRef to diff between ' + - 'specific commits or branches.', - inputSchema: { - type: 'object', - properties: { - fromRef: { - type: 'string', - description: 'Base ref (commit hash, branch, or tag). Omit to diff working tree vs HEAD (optional)', - }, - toRef: { - type: 'string', - description: 'Target ref. Omit to use the working tree (optional)', - }, - filePaths: { - type: 'array', - items: { type: 'string' }, - description: 'Restrict the diff to these file paths (optional)', - }, - contextLines: { - type: 'integer', - description: 'Lines of context around each hunk (optional, default 3, max 10)', - }, - maxOutputChars: { - type: 'integer', - description: 'Truncate output to this many characters (optional, default 120000)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - additionalProperties: false, + }, + { + name: 'get_diff', + description: + 'Get a unified diff for the project. By default returns all changes since the ' + + 'last commit (working tree vs HEAD). Supply fromRef and/or toRef to diff between ' + + 'specific commits or branches.', + inputSchema: { + type: 'object', + properties: { + fromRef: { + type: 'string', + description: 'Base ref (commit hash, branch, or tag). Omit to diff working tree vs HEAD (optional)', + }, + toRef: { + type: 'string', + description: 'Target ref. Omit to use the working tree (optional)', + }, + filePaths: { + type: 'array', + items: { type: 'string' }, + description: 'Restrict the diff to these file paths (optional)', + }, + contextLines: { + type: 'integer', + description: 'Lines of context around each hunk (optional, default 3, max 10)', + }, + maxOutputChars: { + type: 'integer', + description: 'Truncate output to this many characters (optional, default 120000)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + additionalProperties: false, }, - { - name: 'status_summary', - description: 'Get a high-level status summary of an Overleaf project', - inputSchema: { - type: 'object', - properties: { - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', - }, - }, - additionalProperties: false, + }, + { + name: 'status_summary', + description: 'Get a high-level status summary of an Overleaf project', + inputSchema: { + type: 'object', + properties: { + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', + }, }, + additionalProperties: false, }, - { - name: 'write_file', - description: - 'Overwrite an entire file in an Overleaf project. ' + - 'Prefer str_replace, insert_before, or insert_after for targeted edits, ' + - 'or write_section for full section replacements. ' + - 'Use this for new file creation or full-file replacements only.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the file', - }, - content: { - type: 'string', - description: 'Full file content to write', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Update via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: - 'If true, report existing and new file sizes without writing anything ' + - '(optional, defaults to false)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', - }, - }, - required: ['filePath', 'content'], - additionalProperties: false, + }, + { + name: 'write_file', + description: + 'Overwrite an entire file in an Overleaf project. ' + + 'Prefer str_replace, insert_before, or insert_after for targeted edits, ' + + 'or write_section for full section replacements. ' + + 'Use this for new file creation or full-file replacements only.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file', + }, + content: { + type: 'string', + description: 'Full file content to write', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Update via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: + 'If true, report existing and new file sizes without writing anything ' + + '(optional, defaults to false)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', + }, }, + required: ['filePath', 'content'], + additionalProperties: false, }, - { - name: 'write_section', - description: - 'Replace a single named section in a .tex file and optionally push to Overleaf. ' + - 'Only the named section is replaced; the rest of the file is untouched. ' + - 'The boundary is level-aware: the section ends where the next command of equal or ' + - 'higher level begins, or at \\end{document} if there is none. ' + - 'Only applicable to .tex files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the LaTeX file', - }, - sectionTitle: { - type: 'string', - description: 'Title of the section to replace (must match exactly)', - }, - newContent: { - type: 'string', - description: 'Replacement content for the section, including the section heading', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Update section via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: 'If true, verify the section exists and return its size without writing anything', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', - }, - }, - required: ['filePath', 'sectionTitle', 'newContent'], - additionalProperties: false, + }, + { + name: 'write_section', + description: + 'Replace a single named section in a .tex file and optionally push to Overleaf. ' + + 'Only the named section is replaced; the rest of the file is untouched. ' + + 'The boundary is level-aware: the section ends where the next command of equal or ' + + 'higher level begins, or at \\end{document} if there is none. ' + + 'Only applicable to .tex files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the LaTeX file', + }, + sectionTitle: { + type: 'string', + description: 'Title of the section to replace (must match exactly)', + }, + newContent: { + type: 'string', + description: 'Replacement content for the section, including the section heading', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Update section via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: 'If true, verify the section exists and return its size without writing anything', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; optional when exactly one project exists.', + }, }, + required: ['filePath', 'sectionTitle', 'newContent'], + additionalProperties: false, }, - { - name: 'str_replace', - description: - 'Replace the single unique occurrence of oldStr with newStr in a file. ' + - 'oldStr must appear exactly once — if it matches zero or multiple locations, ' + - 'an error is returned with the occurrence count so you can add more surrounding ' + - 'context to make it unambiguous. ' + - 'This is the preferred tool for targeted edits anywhere in a file, including ' + - 'the preamble, bibliography commands, and inline content.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the file', - }, - oldStr: { - type: 'string', - description: - 'The exact string to find and replace. Must appear exactly once in the file. ' + - 'Include enough surrounding context to make it unique.', - }, - newStr: { - type: 'string', - description: 'The replacement string. May be empty to delete oldStr.', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Edit via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: - 'If true, verify oldStr is unique and return its position without writing anything ' + - '(optional, defaults to false)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'oldStr', 'newStr'], - additionalProperties: false, + }, + { + name: 'str_replace', + description: + 'Replace the single unique occurrence of oldStr with newStr in a file. ' + + 'oldStr must appear exactly once — if it matches zero or multiple locations, ' + + 'an error is returned with the occurrence count so you can add more surrounding ' + + 'context to make it unambiguous. ' + + 'This is the preferred tool for targeted edits anywhere in a file, including ' + + 'the preamble, bibliography commands, and inline content.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file', + }, + oldStr: { + type: 'string', + description: + 'The exact string to find and replace. Must appear exactly once in the file. ' + + 'Include enough surrounding context to make it unique.', + }, + newStr: { + type: 'string', + description: 'The replacement string. May be empty to delete oldStr.', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Edit via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: + 'If true, verify oldStr is unique and return its position without writing anything ' + + '(optional, defaults to false)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'oldStr', 'newStr'], + additionalProperties: false, }, - { - name: 'insert_before', - description: - 'Insert newContent immediately before the single unique occurrence of anchorStr in a file. ' + - 'anchorStr must appear exactly once — same uniqueness rules as str_replace. ' + - 'Use this for pure insertions where no existing content should be removed.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the file', - }, - anchorStr: { - type: 'string', - description: - 'The exact string to insert before. Must appear exactly once in the file.', - }, - newContent: { - type: 'string', - description: 'The content to insert immediately before anchorStr.', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Edit via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: 'If true, verify anchorStr is unique without writing anything (optional, defaults to false)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'anchorStr', 'newContent'], - additionalProperties: false, + }, + { + name: 'insert_before', + description: + 'Insert newContent immediately before the single unique occurrence of anchorStr in a file. ' + + 'anchorStr must appear exactly once — same uniqueness rules as str_replace. ' + + 'Use this for pure insertions where no existing content should be removed.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file', + }, + anchorStr: { + type: 'string', + description: + 'The exact string to insert before. Must appear exactly once in the file.', + }, + newContent: { + type: 'string', + description: 'The content to insert immediately before anchorStr.', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Edit via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: 'If true, verify anchorStr is unique without writing anything (optional, defaults to false)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'anchorStr', 'newContent'], + additionalProperties: false, }, - { - name: 'insert_after', - description: - 'Insert newContent immediately after the single unique occurrence of anchorStr in a file. ' + - 'anchorStr must appear exactly once — same uniqueness rules as str_replace. ' + - 'Use this for pure insertions where no existing content should be removed, ' + - 'for example appending a new \\usepackage line after the last existing one.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the file', - }, - anchorStr: { - type: 'string', - description: - 'The exact string to insert after. Must appear exactly once in the file.', - }, - newContent: { - type: 'string', - description: 'The content to insert immediately after anchorStr.', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Edit via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: 'If true, verify anchorStr is unique without writing anything (optional, defaults to false)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'anchorStr', 'newContent'], - additionalProperties: false, + }, + { + name: 'insert_after', + description: + 'Insert newContent immediately after the single unique occurrence of anchorStr in a file. ' + + 'anchorStr must appear exactly once — same uniqueness rules as str_replace. ' + + 'Use this for pure insertions where no existing content should be removed, ' + + 'for example appending a new \\usepackage line after the last existing one.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the file', + }, + anchorStr: { + type: 'string', + description: + 'The exact string to insert after. Must appear exactly once in the file.', + }, + newContent: { + type: 'string', + description: 'The content to insert immediately after anchorStr.', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Edit via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: 'If true, verify anchorStr is unique without writing anything (optional, defaults to false)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'anchorStr', 'newContent'], + additionalProperties: false, }, - { - name: 'get_bib_entry', - description: - 'Get the raw BibTeX block for a single cite key from a .bib file. ' + - 'Returns the full entry string including the @type{key, ...} wrapper. ' + - 'Only applicable to .bib files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the .bib file', - }, - citeKey: { - type: 'string', - description: 'The citation key to look up (e.g. "smith2024")', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'citeKey'], - additionalProperties: false, + }, + { + name: 'get_bib_entry', + description: + 'Get the raw BibTeX block for a single cite key from a .bib file. ' + + 'Returns the full entry string including the @type{key, ...} wrapper. ' + + 'Only applicable to .bib files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the .bib file', + }, + citeKey: { + type: 'string', + description: 'The citation key to look up (e.g. "smith2024")', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'citeKey'], + additionalProperties: false, }, - { - name: 'add_bib_entry', - description: - 'Append a new BibTeX entry to a .bib file. ' + - 'The entry parameter must be a complete raw BibTeX string, e.g.: ' + - '@inproceedings{smith2024, author = {Smith, John}, title = {A Paper}, booktitle = {NeurIPS}, year = {2024}}. ' + - 'Any valid BibTeX entry type is accepted (@article, @book, @inproceedings, @misc, etc.). ' + - 'The cite key is extracted server-side — an error is returned if it already exists. ' + - 'Only applicable to .bib files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the .bib file', - }, - entry: { - type: 'string', - description: 'Complete raw BibTeX entry string including the @type{key, ...} wrapper', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Add bib entry via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: 'If true, validate the entry and check for duplicate cite key without writing anything (optional, defaults to false)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'entry'], - additionalProperties: false, + }, + { + name: 'add_bib_entry', + description: + 'Append a new BibTeX entry to a .bib file. ' + + 'The entry parameter must be a complete raw BibTeX string, e.g.: ' + + '@inproceedings{smith2024, author = {Smith, John}, title = {A Paper}, booktitle = {NeurIPS}, year = {2024}}. ' + + 'Any valid BibTeX entry type is accepted (@article, @book, @inproceedings, @misc, etc.). ' + + 'The cite key is extracted server-side — an error is returned if it already exists. ' + + 'Only applicable to .bib files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the .bib file', + }, + entry: { + type: 'string', + description: 'Complete raw BibTeX entry string including the @type{key, ...} wrapper', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Add bib entry via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: 'If true, validate the entry and check for duplicate cite key without writing anything (optional, defaults to false)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'entry'], + additionalProperties: false, }, - { - name: 'replace_bib_entry', - description: - 'Replace the BibTeX entry with the given cite key with a new raw BibTeX block. ' + - 'The new entry may use a different cite key if desired. ' + - 'Only applicable to .bib files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the .bib file', - }, - citeKey: { - type: 'string', - description: 'The citation key of the entry to replace', - }, - newEntry: { - type: 'string', - description: 'Complete replacement raw BibTeX entry string', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Update bib entry via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: 'If true, verify the cite key exists without writing anything (optional, defaults to false)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'citeKey', 'newEntry'], - additionalProperties: false, + }, + { + name: 'replace_bib_entry', + description: + 'Replace the BibTeX entry with the given cite key with a new raw BibTeX block. ' + + 'The new entry may use a different cite key if desired. ' + + 'Only applicable to .bib files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the .bib file', + }, + citeKey: { + type: 'string', + description: 'The citation key of the entry to replace', + }, + newEntry: { + type: 'string', + description: 'Complete replacement raw BibTeX entry string', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Update bib entry via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: 'If true, verify the cite key exists without writing anything (optional, defaults to false)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'citeKey', 'newEntry'], + additionalProperties: false, }, - { - name: 'remove_bib_entry', - description: - 'Remove the BibTeX entry with the given cite key from a .bib file. ' + - 'Also removes the preceding blank line to keep the file tidy. ' + - 'Only applicable to .bib files.', - inputSchema: { - type: 'object', - properties: { - filePath: { - type: 'string', - description: 'Path to the .bib file', - }, - citeKey: { - type: 'string', - description: 'The citation key of the entry to remove', - }, - commitMessage: { - type: 'string', - description: 'Git commit message (optional, defaults to "Remove bib entry via Overleaf MCP")', - }, - push: { - type: 'boolean', - description: 'Whether to push to Overleaf after committing (optional, defaults to true)', - }, - dryRun: { - type: 'boolean', - description: 'If true, verify the cite key exists without writing anything (optional, defaults to false)', - }, - projectName: { - type: 'string', - description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', - }, - }, - required: ['filePath', 'citeKey'], - additionalProperties: false, + }, + { + name: 'remove_bib_entry', + description: + 'Remove the BibTeX entry with the given cite key from a .bib file. ' + + 'Also removes the preceding blank line to keep the file tidy. ' + + 'Only applicable to .bib files.', + inputSchema: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: 'Path to the .bib file', + }, + citeKey: { + type: 'string', + description: 'The citation key of the entry to remove', + }, + commitMessage: { + type: 'string', + description: 'Git commit message (optional, defaults to "Remove bib entry via Overleaf MCP")', + }, + push: { + type: 'boolean', + description: 'Whether to push to Overleaf after committing (optional, defaults to true)', + }, + dryRun: { + type: 'boolean', + description: 'If true, verify the cite key exists without writing anything (optional, defaults to false)', + }, + projectName: { + type: 'string', + description: 'Project identifier. Required when multiple projects are configured; can be omitted when exactly one project exists.', + }, }, + required: ['filePath', 'citeKey'], + additionalProperties: false, }, - ], -})); + }, +]; + +// Exported so integration tests can assert every tool has coverage without +// maintaining a separate hardcoded list. Adding a tool to TOOLS above +// automatically makes it required in the integration test suite. +export const TOOL_NAMES = new Set(TOOLS.map(t => t.name)); +export { getProject }; + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); // ─── Tool handlers ──────────────────────────────────────────────────────────── @@ -1032,7 +1013,39 @@ async function main() { console.error('[OverleafMCP] Server running on stdio'); } -main().catch(err => { - console.error('[OverleafMCP] Fatal error:', err); - process.exit(1); -}); \ No newline at end of file +// This code is only executed when running the server as a script, not when +// importing it as a module. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const raw = await readFile(PROJECTS_FILE, 'utf-8'); + projectsConfig = JSON.parse(raw); + } catch (err) { + console.error(`[OverleafMCP] Failed to load projects config from "${PROJECTS_FILE}": ${err.message}`); + console.error('[OverleafMCP] Please create projects.json from projects.example.json'); + process.exit(1); + } + + for (const [key, project] of Object.entries(projectsConfig.projects ?? {})) { + for (const toolName of (project.disallowedTools ?? [])) { + if (!WRITE_TOOLS.has(toolName)) { + console.warn( + `[OverleafMCP] Warning: unknown tool "${toolName}" in disallowedTools for project "${key}". ` + + `Valid tools are: ${[...WRITE_TOOLS].join(', ')}` + ); + } + } + } + for (const toolName of (projectsConfig.defaults?.disallowedTools ?? [])) { + if (!WRITE_TOOLS.has(toolName)) { + console.warn( + `[OverleafMCP] Warning: unknown tool "${toolName}" in defaults.disallowedTools. ` + + `Valid tools are: ${[...WRITE_TOOLS].join(', ')}` + ); + } + } + + main().catch(err => { + console.error('[OverleafMCP] Fatal error:', err); + process.exit(1); + }); +} \ No newline at end of file diff --git a/package.json b/package.json index 827dcc6..b328c54 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "overleaf-mcp": "./overleaf-mcp-server.js" }, "scripts": { - "start": "node overleaf-mcp-server.js" + "start": "node overleaf-mcp-server.js", + "test": "node --test test/unit.test.js", + "test:unit": "node --test test/unit.test.js", + "test:integration": "node --test test/integration.test.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.21.0" diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..e1e0eb4 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,24 @@ +// Shared test helpers for integration tests and restore script. + +// Retries a function on transient Overleaf git errors (403, 5xx, network timeouts). +// If sustained rate limiting causes repeated failures, increase `attempts` or `delayMs`. +// Current window: attempts=5, delays of 5s/10s/15s/20s (~50s total). +export async function withRetry(fn, { attempts = 5, delayMs = 5000 } = {}) { + let lastErr; + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + const isTransient = + err.message?.includes('403') || + err.message?.includes('502') || + err.message?.includes('504') || + err.message?.includes('unable to access') || + err.message?.includes('timed out'); + if (!isTransient || i === attempts - 1) throw err; + await new Promise(r => setTimeout(r, delayMs * (i + 1))); + } + } + throw lastErr; +} \ No newline at end of file diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 0000000..df54d80 --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,1116 @@ +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { OverleafGitClient, PREVIEW_MAX_LENGTH } from '../overleaf-git-client.js'; +import { TOOL_NAMES, getProject } from '../overleaf-mcp-server.js'; +import { withRetry } from './helpers.js'; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- +const TEMP_DIR = process.env.OVERLEAF_TEMP_DIR + ? path.resolve(process.env.OVERLEAF_TEMP_DIR) + : path.resolve('./temp'); + +const PROJECTS_FILE = process.env.PROJECTS_FILE + ? path.resolve(process.env.PROJECTS_FILE) + : path.resolve('./projects.json'); + +const config = JSON.parse(readFileSync(PROJECTS_FILE, 'utf8')); +const { projectId: exProjectId, gitToken: exToken } = config.projects.example_project; +const { projectId: roProjectId, gitToken: roToken } = config.projects.readonly_example_project; + +// --------------------------------------------------------------------------- +// Clients +// --------------------------------------------------------------------------- +const exClient = new OverleafGitClient( + exProjectId, + exToken, + path.join(TEMP_DIR, 'integration') +); +const roClient = new OverleafGitClient( + roProjectId, + roToken, + path.join(TEMP_DIR, 'integration') +); + +// --------------------------------------------------------------------------- +// Fixture content (authoritative source for setup and teardown) +// --------------------------------------------------------------------------- +const FIXTURE_TEX = `\\documentclass{article} +\\usepackage{amsmath} +\\begin{document} + +\\section{Alpha} +Alpha section content. + +\\subsection{Alpha Sub} +Alpha subsection content. + +\\section{Beta} +Beta section content. + +\\end{document} +`; + +const FIXTURE_BIB = `@article{fixture2024, + author = {Fixture, Author}, + title = {The {F}ixture {P}aper}, + journal = {Test Journal}, + year = {2024} +} +`; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +const sleep = ms => new Promise(r => setTimeout(r, ms)); +// Pause between test groups to avoid Overleaf git rate limiting. +// Increase this value if you see transient 403 errors at the start of a group. +const INTER_GROUP_DELAY_MS = 5000; + +// --------------------------------------------------------------------------- +// Tool coverage tracking +// --------------------------------------------------------------------------- +const testedTools = new Set(); + +// --------------------------------------------------------------------------- +// Global setup: push fixture files to known state before any test runs +// --------------------------------------------------------------------------- +before(async () => { + await withRetry(() => exClient.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: true, + commitMessage: 'test: restore test_fixture.tex to known state', + })); + await withRetry(() => exClient.writeFile('test_fixture.bib', FIXTURE_BIB, { + push: true, + commitMessage: 'test: restore test_fixture.bib to known state', + })); +}); + +// --------------------------------------------------------------------------- +// Global teardown: restore fixture files regardless of test outcome, +// then enforce tool coverage. +// --------------------------------------------------------------------------- +after(async () => { + await withRetry(() => exClient.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: true, + commitMessage: 'test: restore test_fixture.tex to known state', + })); + await withRetry(() => exClient.writeFile('test_fixture.bib', FIXTURE_BIB, { + push: true, + commitMessage: 'test: restore test_fixture.bib to known state', + })); + + // Tool coverage enforcer. + // Tools in SERVER_LAYER_TOOLS are excluded from the strict per-tool coverage check + // because they have no direct client-method equivalent and are tested through other + // means (e.g. list_projects logic is exercised by Group 8 via getProject; status_summary + // composes listFiles + getSections which are individually tested). + // Any future tool added to this set requires explicit justification and a corresponding + // test of its server-layer logic, even if it cannot be tested through the client. + const SERVER_LAYER_TOOLS = new Set(['list_projects', 'status_summary']); + for (const name of TOOL_NAMES) { + if (SERVER_LAYER_TOOLS.has(name)) continue; + assert.ok( + testedTools.has(name), + `Tool "${name}" has no integration test coverage` + ); + } +}); + +// --------------------------------------------------------------------------- +// Group 1: Project navigation +// --------------------------------------------------------------------------- +describe('Group 1: Project navigation', () => { + test('1.1 listFiles: .tex extension returns array containing main.tex', async () => { + testedTools.add('list_files'); + const files = await withRetry(() => exClient.listFiles('.tex')); + assert.ok(Array.isArray(files), 'listFiles should return an array'); + assert.ok( + files.some(f => f === 'main.tex' || f.endsWith('/main.tex')), + `Expected main.tex in results, got: ${JSON.stringify(files)}` + ); + }); + + test('1.2 listFiles: .bib extension returns array containing sample.bib', async () => { + testedTools.add('list_files'); + const files = await withRetry(() => exClient.listFiles('.bib')); + assert.ok(Array.isArray(files)); + assert.ok( + files.some(f => f === 'sample.bib' || f.endsWith('/sample.bib')), + `Expected sample.bib in results, got: ${JSON.stringify(files)}` + ); + }); + + test('1.3 listFiles: no extension filter returns all files', async () => { + testedTools.add('list_files'); + const files = await withRetry(() => exClient.listFiles()); + assert.ok(Array.isArray(files)); + assert.ok(files.length > 0, 'Expected at least one file'); + // All-files result should be a superset of .tex-only result + const texFiles = await withRetry(() => exClient.listFiles('.tex')); + for (const f of texFiles) { + assert.ok(files.includes(f), `All-files result missing ${f}`); + } + }); +}); + +// --------------------------------------------------------------------------- +// Group 2: LaTeX read tools (against test_fixture.tex) +// --------------------------------------------------------------------------- +describe('Group 2: LaTeX read tools', () => { + before(() => sleep(INTER_GROUP_DELAY_MS)); + + test('2.1 readFile: content matches known initial fixture content exactly', async () => { + testedTools.add('read_file'); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + assert.equal(content, FIXTURE_TEX); + }); + + test('2.2 getSections: 2 top-level sections; Alpha has 1 child (Alpha Sub), Beta has none', async () => { + testedTools.add('get_sections'); + const tree = await withRetry(() => exClient.getSectionTree('test_fixture.tex')); + assert.equal(tree.length, 2, `Expected 2 top-level sections, got ${tree.length}`); + assert.equal(tree[0].title, 'Alpha'); + assert.equal(tree[0].children.length, 1, 'Alpha should have 1 child'); + assert.equal(tree[0].children[0].title, 'Alpha Sub'); + assert.equal(tree[1].title, 'Beta'); + assert.equal(tree[1].children.length, 0, 'Beta should have no children'); + }); + + test('2.3 getSections: content fields match fixture text', async () => { + testedTools.add('get_sections'); + const tree = await withRetry(() => exClient.getSectionTree('test_fixture.tex')); + assert.ok( + tree[0].content.includes('Alpha section content.'), + `Alpha content: "${tree[0].content}"` + ); + assert.ok( + tree[0].children[0].content.includes('Alpha subsection content.'), + `Alpha Sub content: "${tree[0].children[0].content}"` + ); + }); + + test('2.4 getSections: preview is within PREVIEW_MAX_LENGTH and non-empty for sections with content', async () => { + testedTools.add('get_sections'); + const tree = await withRetry(() => exClient.getSectionTree('test_fixture.tex')); + const allNodes = [tree[0], tree[0].children[0], tree[1]]; + for (const node of allNodes) { + assert.ok( + node.preview.length <= PREVIEW_MAX_LENGTH, + `${node.title}: preview length ${node.preview.length} > ${PREVIEW_MAX_LENGTH}` + ); + if (node.content.trim().length > 0) { + assert.ok( + node.preview.length > 0, + `${node.title}: expected non-empty preview for non-empty content` + ); + } + } + }); + + test('2.5 getSection: returns section content for "Alpha" without parentTitle', async () => { + testedTools.add('get_section_content'); + const result = await withRetry(() => exClient.getSection('test_fixture.tex', 'Alpha')); + assert.notEqual(result, null, 'getSection should return a result for an existing section'); + const content = typeof result === 'string' ? result : result?.content; + assert.ok( + content?.includes('Alpha section content.'), + `Expected fixture text in result, got: "${content}"` + ); + }); + + test('2.6 getSection: with parentTitle resolves correct subsection in main.tex', async () => { + testedTools.add('get_section_content'); + // Discover structure dynamically via the tree to avoid hardcoding subsection titles + const tree = await withRetry(() => exClient.getSectionTree('main.tex')); + const parent = tree.find( + s => s.title === 'Some examples to get started' && s.children?.length > 0 + ); + assert.ok( + parent, + 'Expected a section titled "Some examples to get started" with subsections in main.tex' + ); + const child = parent.children[0]; + const result = await withRetry(() => + exClient.getSection('main.tex', child.title, parent.title) + ); + assert.notEqual(result, null, 'getSection with parentTitle should return a result'); + }); + + test('2.7 getSection: returns null for nonexistent section title', async () => { + testedTools.add('get_section_content'); + const result = await withRetry(() => + exClient.getSection('test_fixture.tex', 'NonexistentSection_xyz') + ); + assert.equal(result, null); + }); + + test('2.8 getPreamble: returns content before first section, including \\documentclass and \\usepackage{amsmath}', async () => { + testedTools.add('get_preamble'); + const preamble = await withRetry(() => exClient.getPreamble('test_fixture.tex')); + assert.ok(preamble.includes('\\documentclass'), 'Preamble should include \\documentclass'); + assert.ok(preamble.includes('\\usepackage{amsmath}'), 'Preamble should include \\usepackage{amsmath}'); + assert.ok(!preamble.includes('\\section{Alpha}'), 'Preamble should not include the first section heading'); + }); + + test('2.9 getPostamble: returns \\end{document} for test_fixture.tex', async () => { + testedTools.add('get_postamble'); + const postamble = await withRetry(() => exClient.getPostamble('test_fixture.tex')); + assert.equal( + postamble.trim(), + '\\end{document}', + `Expected postamble to be "\\end{document}", got: "${postamble}"` + ); + }); + + test('2.10 getPostamble: returns empty string for file without \\end{document}', async () => { + testedTools.add('get_postamble'); + const postamble = await withRetry(() => exClient.getPostamble('test_fixture.bib')); + assert.equal(postamble, '', 'Expected empty postamble for a .bib file'); + }); +}); + +// --------------------------------------------------------------------------- +// Group 3: Git inspection +// --------------------------------------------------------------------------- +describe('Group 3: Git inspection', () => { + before(() => sleep(INTER_GROUP_DELAY_MS)); + + test('3.1 listHistory: default returns commits with hash, date, author, subject fields', async () => { + testedTools.add('list_history'); + const history = await withRetry(() => exClient.listHistory()); + assert.ok(Array.isArray(history), 'listHistory should return an array'); + assert.ok(history.length > 0, 'Expected at least one commit'); + const entry = history[0]; + assert.ok('hash' in entry, 'Entry missing hash field'); + assert.ok('date' in entry, 'Entry missing date field'); + assert.ok('author' in entry, 'Entry missing author field'); + assert.ok('subject' in entry, 'Entry missing subject field'); + }); + + test('3.2 listHistory: limit:1 returns exactly 1 entry', async () => { + testedTools.add('list_history'); + const history = await withRetry(() => exClient.listHistory({ limit: 1 })); + assert.equal(history.length, 1); + }); + + test('3.3 listHistory: filePath filter returns only commits touching that file', async () => { + testedTools.add('list_history'); + const history = await withRetry(() => + exClient.listHistory({ filePath: 'test_fixture.tex' }) + ); + assert.ok(Array.isArray(history)); + // Setup push guarantees at least one commit touching test_fixture.tex + assert.ok( + history.length > 0, + 'Expected at least one commit for test_fixture.tex after setup push' + ); + }); + + test('3.4 listHistory: until date in far past returns empty array', async () => { + testedTools.add('list_history'); + const history = await withRetry(() => exClient.listHistory({ until: '2020-01-01' })); + assert.deepEqual(history, []); + }); + + test('3.5 getDiff: no refs returns { diff, truncated } with no differences on clean repo', async () => { + testedTools.add('get_diff'); + const result = await withRetry(() => exClient.getDiff()); + assert.ok('diff' in result, 'Result should have a diff field'); + assert.ok('truncated' in result, 'Result should have a truncated field'); + assert.equal(typeof result.diff, 'string'); + assert.equal(typeof result.truncated, 'boolean'); + assert.equal(result.diff.trim(), '', 'Expected empty diff on a clean repo after setup push'); + }); + + test('3.6 getDiff: fromRef HEAD~1 toRef HEAD returns non-empty diff', async () => { + testedTools.add('get_diff'); + const result = await withRetry(() => + exClient.getDiff({ fromRef: 'HEAD~1', toRef: 'HEAD' }) + ); + assert.ok('diff' in result); + assert.ok(result.diff.length > 0, 'Expected a non-empty diff between HEAD~1 and HEAD'); + }); + + test('3.7 getDiff: contextLines:0 produces diff with no context lines', async () => { + testedTools.add('get_diff'); + const result = await withRetry(() => + exClient.getDiff({ fromRef: 'HEAD~1', toRef: 'HEAD', contextLines: 0 }) + ); + assert.ok('diff' in result); + // In unified diff format, context lines are prefixed with a single space. + // With contextLines:0 there should be none. + const contextLines = result.diff + .split('\n') + .filter(l => l.startsWith(' ')); + assert.equal( + contextLines.length, + 0, + `Expected no context lines with contextLines:0, found: ${JSON.stringify(contextLines)}` + ); + }); +}); + +// --------------------------------------------------------------------------- +// Group 4: Write tools — dryRun (no actual writes) +// --------------------------------------------------------------------------- +describe('Group 4: Write tools — dryRun', () => { + before(() => sleep(INTER_GROUP_DELAY_MS)); + + test('4.1 writeFile dryRun: returns { dryRun: true, existingSize, newSize } with existingSize > 0', async () => { + testedTools.add('write_file'); + const result = await withRetry(() => + exClient.writeFile( + 'test_fixture.tex', + '\\documentclass{article}\n\\begin{document}\nReplaced.\n\\end{document}\n', + { dryRun: true } + ) + ); + assert.equal(result.dryRun, true); + assert.ok( + typeof result.existingSize === 'number' && result.existingSize > 0, + `Expected existingSize > 0, got: ${result.existingSize}` + ); + assert.ok(typeof result.newSize === 'number', 'Expected newSize to be a number'); + }); + + test('4.2 writeSection dryRun: existing section "Alpha" returns { dryRun: true, sectionFound: true, newContentSize }', async () => { + testedTools.add('write_section'); + const result = await withRetry(() => + exClient.writeSection('test_fixture.tex', 'Alpha', 'New Alpha content.\n', { dryRun: true }) + ); + assert.equal(result.dryRun, true); + assert.equal(result.sectionFound, true); + assert.ok(typeof result.newContentSize === 'number', 'Expected newContentSize to be a number'); + }); + + test('4.3 writeSection dryRun: nonexistent section throws "Section not found"', async () => { + testedTools.add('write_section'); + await assert.rejects( + () => withRetry(() => + exClient.writeSection( + 'test_fixture.tex', + 'NonexistentSection_xyz', + 'content', + { dryRun: true } + ) + ), + /Section.*not found/i + ); + }); + + test('4.4 strReplace dryRun: unique oldStr returns { dryRun: true, anchorIndex >= 0 }', async () => { + testedTools.add('str_replace'); + const result = await withRetry(() => + exClient.strReplace( + 'test_fixture.tex', + 'Alpha section content.', + 'Replacement text.', + { dryRun: true } + ) + ); + assert.equal(result.dryRun, true); + assert.ok( + typeof result.anchorIndex === 'number' && result.anchorIndex >= 0, + `Expected non-negative anchorIndex, got: ${result.anchorIndex}` + ); + }); + + test('4.5 insertBefore dryRun: unique anchor returns { dryRun: true, anchorIndex >= 0 }', async () => { + testedTools.add('insert_before'); + const result = await withRetry(() => + exClient.insertBefore( + 'test_fixture.tex', + '\\section{Beta}', + 'Inserted before Beta.\n', + { dryRun: true } + ) + ); + assert.equal(result.dryRun, true); + assert.ok( + typeof result.anchorIndex === 'number' && result.anchorIndex >= 0, + `Expected non-negative anchorIndex, got: ${result.anchorIndex}` + ); + }); + + test('4.6 insertAfter dryRun: unique anchor returns { dryRun: true, anchorIndex >= 0 }', async () => { + testedTools.add('insert_after'); + const result = await withRetry(() => + exClient.insertAfter( + 'test_fixture.tex', + '\\usepackage{amsmath}', + '\n\\usepackage{amssymb}', + { dryRun: true } + ) + ); + assert.equal(result.dryRun, true); + assert.ok( + typeof result.anchorIndex === 'number' && result.anchorIndex >= 0, + `Expected non-negative anchorIndex, got: ${result.anchorIndex}` + ); + }); + + test('4.7 addBibEntry dryRun: returns { dryRun: true, citeKey: "test2024", action: "add" }', async () => { + testedTools.add('add_bib_entry'); + const result = await withRetry(() => + exClient.addBibEntry( + 'test_fixture.bib', + '@inproceedings{test2024,\n author = {Test, Author},\n title = {A Test Paper},\n year = {2024}\n}', + { dryRun: true } + ) + ); + assert.equal(result.dryRun, true); + assert.equal(result.citeKey, 'test2024'); + assert.equal(result.action, 'add'); + }); + + test('4.8 replaceBibEntry dryRun: existing key returns { dryRun: true, citeKey: "fixture2024", action: "replace" }', async () => { + testedTools.add('replace_bib_entry'); + const result = await withRetry(() => + exClient.replaceBibEntry( + 'test_fixture.bib', + 'fixture2024', + '@article{fixture2024,\n author = {Fixture, Author},\n title = {Updated Title},\n journal = {Test Journal},\n year = {2024}\n}', + { dryRun: true } + ) + ); + assert.equal(result.dryRun, true); + assert.equal(result.citeKey, 'fixture2024'); + assert.equal(result.action, 'replace'); + }); + + test('4.9 removeBibEntry dryRun: existing key returns { dryRun: true, citeKey: "fixture2024", action: "remove" }', async () => { + testedTools.add('remove_bib_entry'); + const result = await withRetry(() => + exClient.removeBibEntry('test_fixture.bib', 'fixture2024', { dryRun: true }) + ); + assert.equal(result.dryRun, true); + assert.equal(result.citeKey, 'fixture2024'); + assert.equal(result.action, 'remove'); + }); +}); + +// --------------------------------------------------------------------------- +// Group 5: Write tools — actual writes (against test_fixture.tex) +// +// State flow: +// 5.1/5.2 replace the full file (push:true each time). +// Before 5.3, the fixture is restored with push:false so that the restore +// commit is bundled into 5.3's push:true. +// 5.4 replaces only Alpha Sub (requires it to exist from 5.3); 5.5 restores +// the fixture and then replaces the entire Alpha section, consuming the subsection. +// Before 5.6 the fixture is restored again (push:false, bundled into 5.6). +// Before 5.8 the fixture is restored again (push:false, bundled into 5.8). +// --------------------------------------------------------------------------- +describe('Group 5: Write tools — actual writes', () => { + before(() => sleep(INTER_GROUP_DELAY_MS)); + + test('5.1 writeFile: replace test_fixture.tex content (push:true)', async () => { + testedTools.add('write_file'); + const newContent = '\\documentclass{article}\n\\begin{document}\nReplaced in 5.1.\n\\end{document}\n'; + await withRetry(() => + exClient.writeFile('test_fixture.tex', newContent, { + push: true, + commitMessage: 'test 5.1: writeFile replacement', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + assert.equal(content, newContent, 'readFile should return the new content'); + const history = await withRetry(() => exClient.listHistory({ limit: 1 })); + assert.ok( + history[0].subject.includes('5.1'), + `Expected 5.1 commit in history, got: ${history[0].subject}` + ); + }); + + test('5.2 writeFile: push:false then identical content push:true flushes pending commit', async () => { + testedTools.add('write_file'); + const newContent = '\\documentclass{article}\n\\begin{document}\nReplaced in 5.2.\n\\end{document}\n'; + // First call: create a local-only commit + await withRetry(() => + exClient.writeFile('test_fixture.tex', newContent, { + push: false, + commitMessage: 'test 5.2a: writeFile push:false', + }) + ); + // Second call: same content — should flush the pending local commit + const result = await withRetry(() => + exClient.writeFile('test_fixture.tex', newContent, { + push: true, + commitMessage: 'test 5.2b: writeFile identical content push:true', + }) + ); + const resultStr = typeof result === 'string' ? result : JSON.stringify(result); + assert.ok( + resultStr.includes('pushed any pending local commits'), + `Expected "pushed any pending local commits" in result, got: ${resultStr}` + ); + const history = await withRetry(() => exClient.listHistory({ limit: 3 })); + assert.ok( + history.some(h => h.subject.includes('5.2')), + `Expected a 5.2 commit in recent history, got: ${JSON.stringify(history.map(h => h.subject))}` + ); + }); + + test('5.3 writeSection: replace "Alpha" section (push:true)', async () => { + testedTools.add('write_section'); + // Restore fixture with push:false — bundled into this test's push:true + await withRetry(() => + exClient.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: false, + commitMessage: 'test: restore fixture for 5.3', + }) + ); + const newAlpha = 'New Alpha intro.\n\n\\subsection{Alpha Sub}\nNew Alpha sub content.\n'; + await withRetry(() => + exClient.writeSection('test_fixture.tex', 'Alpha', newAlpha, { + push: true, + commitMessage: 'test 5.3: writeSection Alpha replacement', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + assert.ok(content.includes('New Alpha intro.'), 'Alpha intro should be replaced'); + assert.ok(!content.includes('Alpha section content.'), 'Old Alpha content should be gone'); + assert.ok(content.includes('Beta section content.'), 'Beta should be untouched'); + const history = await withRetry(() => exClient.listHistory({ limit: 1 })); + assert.ok( + history[0].subject.includes('5.3'), + `Expected 5.3 commit in history, got: ${history[0].subject}` + ); + }); + + + test('5.4 writeSection: replace "Alpha Sub" subsection only (push:false)', async () => { + testedTools.add('write_section'); + // Alpha Sub exists from 5.3 + await withRetry(() => + exClient.writeSection( + 'test_fixture.tex', + 'Alpha Sub', + 'Standalone subsection replacement.\n', + { + push: false, + commitMessage: 'test 5.4: writeSection Alpha Sub only', + } + ) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + assert.ok( + content.includes('Standalone subsection replacement.'), + 'Alpha Sub content should be replaced' + ); + assert.ok( + content.includes('New Alpha intro.'), + 'Alpha intro from 5.3 should be untouched' + ); + assert.ok(content.includes('Beta section content.'), 'Beta should be untouched'); + }); + + test('5.5 writeSection: level-aware boundary for "Alpha" consumes subsection (push:false)', async () => { + testedTools.add('write_section'); + // writeSection replaces FROM the section heading itself. After 5.3, \section{Alpha} was + // consumed. Restore the fixture first so \section{Alpha} and \subsection{Alpha Sub} both + // exist, then verify that writing 'Alpha' consumes the subsection up to \section{Beta}. + await withRetry(() => + exClient.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: false, + commitMessage: 'test: restore fixture for 5.5', + }) + ); + await withRetry(() => + exClient.writeSection( + 'test_fixture.tex', + 'Alpha', + 'Level-aware replacement, no subsection.\n', + { + push: false, + commitMessage: 'test 5.5: writeSection Alpha consumes subsection', + } + ) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + assert.ok( + content.includes('Level-aware replacement, no subsection.'), + 'New Alpha content should be present' + ); + assert.ok( + !content.includes('Alpha subsection content.'), + 'Alpha Sub content should be consumed' + ); + assert.ok(content.includes('Beta section content.'), 'Beta should be untouched'); + }); + + test('5.6 strReplace: replace unique string (push:true)', async () => { + testedTools.add('str_replace'); + // Restore fixture with push:false — bundled into this test's push:true + await withRetry(() => + exClient.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: false, + commitMessage: 'test: restore fixture for 5.6', + }) + ); + await withRetry(() => + exClient.strReplace( + 'test_fixture.tex', + 'Alpha section content.', + 'strReplace was here.', + { + push: true, + commitMessage: 'test 5.6: strReplace unique string', + } + ) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + assert.ok(content.includes('strReplace was here.'), 'Replacement should be present'); + assert.ok(!content.includes('Alpha section content.'), 'Original string should be gone'); + const history = await withRetry(() => exClient.listHistory({ limit: 1 })); + assert.ok( + history[0].subject.includes('5.6'), + `Expected 5.6 commit in history, got: ${history[0].subject}` + ); + }); + + test('5.7 strReplace: empty newStr deletes the string (push:false)', async () => { + testedTools.add('str_replace'); + await withRetry(() => + exClient.strReplace('test_fixture.tex', 'strReplace was here.', '', { + push: false, + commitMessage: 'test 5.7: strReplace deletion', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + assert.ok(!content.includes('strReplace was here.'), 'Deleted string should be absent'); + }); + + test('5.8 insertBefore: insert before \\section{Beta} (push:true)', async () => { + testedTools.add('insert_before'); + // Restore fixture with push:false — bundled into this test's push:true + await withRetry(() => + exClient.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: false, + commitMessage: 'test: restore fixture for 5.8', + }) + ); + await withRetry(() => + exClient.insertBefore( + 'test_fixture.tex', + '\\section{Beta}', + 'Inserted before Beta.\n\n', + { + push: true, + commitMessage: 'test 5.8: insertBefore Beta', + } + ) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + const insertIdx = content.indexOf('Inserted before Beta.'); + const betaIdx = content.indexOf('\\section{Beta}'); + assert.ok(insertIdx >= 0, 'Inserted content should be present'); + assert.ok(insertIdx < betaIdx, 'Inserted content should appear before \\section{Beta}'); + const history = await withRetry(() => exClient.listHistory({ limit: 1 })); + assert.ok( + history[0].subject.includes('5.8'), + `Expected 5.8 commit in history, got: ${history[0].subject}` + ); + }); + + test('5.9 insertAfter: insert after \\usepackage{amsmath} (push:true)', async () => { + testedTools.add('insert_after'); + await withRetry(() => + exClient.insertAfter( + 'test_fixture.tex', + '\\usepackage{amsmath}', + '\n\\usepackage{amssymb}', + { + push: true, + commitMessage: 'test 5.9: insertAfter amsmath', + } + ) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.tex')); + const amsmathIdx = content.indexOf('\\usepackage{amsmath}'); + const amssymbIdx = content.indexOf('\\usepackage{amssymb}'); + assert.ok(amssymbIdx >= 0, 'Inserted \\usepackage{amssymb} should be present'); + assert.ok(amssymbIdx > amsmathIdx, '\\usepackage{amssymb} should appear after \\usepackage{amsmath}'); + const history = await withRetry(() => exClient.listHistory({ limit: 1 })); + assert.ok( + history[0].subject.includes('5.9'), + `Expected 5.9 commit in history, got: ${history[0].subject}` + ); + }); +}); + +// --------------------------------------------------------------------------- +// Group 6: Write tool error paths (no actual writes) +// +// Restores FIXTURE_TEX with push:false in before() so that the file is in +// a known state for the anchor-counting assertions. The pending restore commit +// is pushed by the first push:true in Group 7. +// --------------------------------------------------------------------------- +describe('Group 6: Write tool error paths', () => { + before(async () => { + await sleep(INTER_GROUP_DELAY_MS); + // Restore fixture to known state — push:false so no extra push budget consumed + await withRetry(() => + exClient.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: false, + commitMessage: 'test: restore fixture for Group 6', + }) + ); + }); + + test('6.1 writeSection: nonexistent section throws "Section … not found"', async () => { + testedTools.add('write_section'); + await assert.rejects( + () => withRetry(() => exClient.writeSection('test_fixture.tex', 'NonexistentSection_xyz', 'content')), + /Section.*not found/i + ); + }); + + test('6.2 strReplace: oldStr not in file throws "not found in file"', async () => { + testedTools.add('str_replace'); + await assert.rejects( + () => withRetry(() => exClient.strReplace( + 'test_fixture.tex', + 'this_string_is_absolutely_not_in_the_fixture', + 'replacement' + )), + /not found in file/i + ); + }); + + test('6.3 strReplace: oldStr matches 3 places — error message contains "3"', async () => { + testedTools.add('str_replace'); + // 'content.' appears in FIXTURE_TEX exactly 3 times: + // "Alpha section content." + // "Alpha subsection content." + // "Beta section content." + await assert.rejects( + () => withRetry(() => exClient.strReplace('test_fixture.tex', 'content.', 'replacement')), + /matches 3 locations/ + ); + }); + + test('6.4 insertBefore: anchor not found throws "not found in file"', async () => { + testedTools.add('insert_before'); + await assert.rejects( + () => withRetry(() => exClient.insertBefore('test_fixture.tex', 'anchor_not_in_fixture_xyz', 'content')), + /not found in file/i + ); + }); + + test('6.5 insertBefore: anchor matches 4 places — error message contains "4"', async () => { + testedTools.add('insert_before'); + // 'Alpha' appears in FIXTURE_TEX exactly 4 times: + // \section{Alpha}, Alpha section content., \subsection{Alpha Sub}, Alpha subsection content. + await assert.rejects( + () => withRetry(() => exClient.insertBefore('test_fixture.tex', 'Alpha', 'content')), + /matches 4 locations/ + ); + }); + + test('6.6 insertAfter: anchor not found throws "not found in file"', async () => { + testedTools.add('insert_after'); + await assert.rejects( + () => withRetry(() => exClient.insertAfter('test_fixture.tex', 'anchor_not_in_fixture_xyz', 'content')), + /not found in file/i + ); + }); + + test('6.7 insertAfter: anchor matches 2 places — error message contains "2"', async () => { + testedTools.add('insert_after'); + // '\\section{' appears in FIXTURE_TEX exactly 2 times: \section{Alpha} and \section{Beta}. + // Wrapped in withRetry so transient 403s from cloneOrPull are retried before the + // anchor-count validation error propagates to assert.rejects. + await assert.rejects( + () => withRetry(() => exClient.insertAfter('test_fixture.tex', '\\section{', 'content')), + /matches 2 locations/ + ); + }); +}); + +// --------------------------------------------------------------------------- +// Group 7: BibTeX tools (against test_fixture.bib) +// +// Tests build sequentially on each other's state. State after each test: +// 7.3: [fixture2024, test2024] +// 7.5: [fixture2024, test2024, test2024b] (push:false) +// 7.6: [fixture2024, test2024(replaced), test2024b] (push:true — also pushes 7.5) +// 7.7: [fixture2024, test2024(replaced), test2024new] (push:false, key rename) +// 7.9: [fixture2024, test2024new] (push:true — also pushes 7.7) +// 7.10: [fixture2024] (push:false) +// 7.11: [] (push:true — also pushes 7.10) +// --------------------------------------------------------------------------- +describe('Group 7: BibTeX tools', () => { + before(() => sleep(INTER_GROUP_DELAY_MS)); + + test('7.1 getBibEntry: existing key "fixture2024" returns raw BibTeX with nested braces intact', async () => { + testedTools.add('get_bib_entry'); + const entry = await withRetry(() => exClient.getBibEntry('test_fixture.bib', 'fixture2024')); + assert.ok(entry !== null, 'getBibEntry should return a value for existing key'); + assert.ok(entry.includes('fixture2024'), 'Entry should contain the cite key'); + assert.ok(entry.includes('{F}'), 'Nested braces in title should be intact'); + }); + + test('7.2 getBibEntry: nonexistent key returns null', async () => { + testedTools.add('get_bib_entry'); + const entry = await withRetry(() => exClient.getBibEntry('test_fixture.bib', 'nonexistent_key_xyz')); + assert.equal(entry, null); + }); + + test('7.3 addBibEntry: add @inproceedings{test2024} with nested braces (push:true)', async () => { + testedTools.add('add_bib_entry'); + const newEntry = '@inproceedings{test2024,\n author = {Test, Author},\n title = {The {T}est {P}aper},\n year = {2024}\n}'; + await withRetry(() => + exClient.addBibEntry('test_fixture.bib', newEntry, { + push: true, + commitMessage: 'test 7.3: addBibEntry test2024', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.bib')); + assert.ok(content.includes('test2024'), 'New entry should appear in file'); + assert.ok(content.includes('{T}'), 'Nested braces should be preserved'); + assert.ok(content.includes('}\n\n@'), 'Blank line separator should be present between entries'); + const history = await withRetry(() => + exClient.listHistory({ limit: 1, filePath: 'test_fixture.bib' }) + ); + assert.ok(history.length > 0, 'Expected a commit touching test_fixture.bib'); + }); + + test('7.4 addBibEntry: duplicate key throws "already exists"', async () => { + testedTools.add('add_bib_entry'); + await assert.rejects( + () => exClient.addBibEntry( + 'test_fixture.bib', + '@article{test2024,\n author = {Dup, Author},\n year = {2024}\n}' + ), + /already exists/i + ); + }); + + test('7.5 addBibEntry: add second entry test2024b — one blank line between entries (push:false)', async () => { + testedTools.add('add_bib_entry'); + const newEntry = '@inproceedings{test2024b,\n author = {Another, Author},\n title = {Another {P}aper},\n year = {2024}\n}'; + await withRetry(() => + exClient.addBibEntry('test_fixture.bib', newEntry, { + push: false, + commitMessage: 'test 7.5: addBibEntry test2024b', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.bib')); + assert.ok(content.includes('test2024b'), 'test2024b should be in file'); + assert.ok(!content.includes('\n\n\n'), 'No double blank lines between entries'); + assert.ok(!content.endsWith('\n\n'), 'No trailing blank line at end of file'); + }); + + test('7.6 replaceBibEntry: replace "test2024" with new content (push:true)', async () => { + testedTools.add('replace_bib_entry'); + const newContent = '@article{test2024,\n author = {Replaced, Author},\n title = {The {R}eplaced {P}aper},\n journal = {Test Journal},\n year = {2024}\n}'; + await withRetry(() => + exClient.replaceBibEntry('test_fixture.bib', 'test2024', newContent, { + push: true, + commitMessage: 'test 7.6: replaceBibEntry test2024', + }) + ); + const entry = await withRetry(() => exClient.getBibEntry('test_fixture.bib', 'test2024')); + assert.ok(entry !== null, 'test2024 should still exist'); + assert.ok(entry.includes('Replaced, Author'), 'Entry should contain the new author'); + const history = await withRetry(() => + exClient.listHistory({ limit: 1, filePath: 'test_fixture.bib' }) + ); + assert.ok(history.length > 0, 'Expected a commit touching test_fixture.bib'); + }); + + test('7.7 replaceBibEntry: rename key from "test2024b" to "test2024new" (push:false)', async () => { + testedTools.add('replace_bib_entry'); + const renamedEntry = '@inproceedings{test2024new,\n author = {Renamed, Author},\n title = {The {R}enamed {P}aper},\n year = {2024}\n}'; + await withRetry(() => + exClient.replaceBibEntry('test_fixture.bib', 'test2024b', renamedEntry, { + push: false, + commitMessage: 'test 7.7: replaceBibEntry rename test2024b to test2024new', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.bib')); + assert.ok(!content.includes('test2024b'), 'Old key test2024b should be gone'); + assert.ok(content.includes('test2024new'), 'New key test2024new should be present'); + }); + + test('7.8 replaceBibEntry: nonexistent key throws "not found"', async () => { + testedTools.add('replace_bib_entry'); + await assert.rejects( + () => exClient.replaceBibEntry( + 'test_fixture.bib', + 'nonexistent_key_xyz', + '@article{nonexistent_key_xyz,\n year = {2024}\n}' + ), + /not found/i + ); + }); + + test('7.9 removeBibEntry: remove middle entry "test2024" with 3 entries present (push:true)', async () => { + testedTools.add('remove_bib_entry'); + // State: [fixture2024, test2024(replaced), test2024new] + await withRetry(() => + exClient.removeBibEntry('test_fixture.bib', 'test2024', { + push: true, + commitMessage: 'test 7.9: removeBibEntry test2024 (middle entry)', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.bib')); + assert.ok(!content.includes('test2024,'), 'test2024 should be removed'); + assert.ok(content.includes('fixture2024'), 'fixture2024 should still be present'); + assert.ok(content.includes('test2024new'), 'test2024new should still be present'); + assert.ok(!content.includes('\n\n\n'), 'No double blank lines between remaining entries'); + const history = await withRetry(() => + exClient.listHistory({ limit: 1, filePath: 'test_fixture.bib' }) + ); + assert.ok(history.length > 0, 'Expected a commit touching test_fixture.bib'); + }); + + test('7.10 removeBibEntry: remove "test2024new" (push:false) — file ends with single newline', async () => { + testedTools.add('remove_bib_entry'); + await withRetry(() => + exClient.removeBibEntry('test_fixture.bib', 'test2024new', { + push: false, + commitMessage: 'test 7.10: removeBibEntry test2024new', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.bib')); + assert.ok(!content.includes('test2024new'), 'test2024new should be removed'); + assert.ok(content.includes('fixture2024'), 'fixture2024 should still be present'); + assert.ok(content.endsWith('\n'), 'File should end with a newline'); + assert.ok(!content.endsWith('\n\n'), 'File should not end with a blank line'); + }); + + test('7.11 removeBibEntry: remove last remaining entry "fixture2024" (push:true)', async () => { + testedTools.add('remove_bib_entry'); + await withRetry(() => + exClient.removeBibEntry('test_fixture.bib', 'fixture2024', { + push: true, + commitMessage: 'test 7.11: removeBibEntry fixture2024 (last entry)', + }) + ); + const content = await withRetry(() => exClient.readFile('test_fixture.bib')); + assert.ok(!content.includes('fixture2024'), 'fixture2024 should be removed'); + assert.equal(content.trim(), '', 'File should be empty after removing last entry'); + const history = await withRetry(() => + exClient.listHistory({ limit: 1, filePath: 'test_fixture.bib' }) + ); + assert.ok(history.length > 0, 'Expected a commit touching test_fixture.bib'); + }); + + test('7.12 removeBibEntry: nonexistent key throws "not found"', async () => { + testedTools.add('remove_bib_entry'); + await assert.rejects( + () => exClient.removeBibEntry('test_fixture.bib', 'nonexistent_key_xyz'), + /not found/i + ); + }); +}); + +// --------------------------------------------------------------------------- +// Group 8: Permission guard +// +// Uses the exported getProject(projectName, config) function directly with +// inline config objects. No subprocess, no MCP protocol overhead. Tests +// verify that the config resolution logic (readOnly precedence, per-project +// vs global defaults inheritance) produces the correct disallowedTools set. +// +// list_projects and status_summary are excluded from the tool coverage enforcer +// (see after() hook) because they are server-layer-only with no client method +// equivalent. +// --------------------------------------------------------------------------- +describe('Group 8: Permission guard', () => { + before(() => sleep(INTER_GROUP_DELAY_MS)); + + const WRITE_TOOL_NAMES = [ + 'write_file', 'write_section', 'str_replace', + 'insert_before', 'insert_after', + 'add_bib_entry', 'replace_bib_entry', 'remove_bib_entry', + ]; + + test('8.1 readOnly: true puts all write tools in disallowedTools set', () => { + const cfg = { + projects: { + ro: { projectId: roProjectId, gitToken: roToken, readOnly: true }, + }, + }; + const { readOnly, disallowedTools } = getProject('ro', cfg); + assert.equal(readOnly, true, 'readOnly should be true'); + for (const tool of WRITE_TOOL_NAMES) { + assert.ok( + disallowedTools.has(tool), + `Expected "${tool}" to be in disallowedTools for readOnly project` + ); + } + }); + + test('8.2 read from readonly_example_project succeeds', async () => { + const files = await withRetry(() => roClient.listFiles('.tex')); + assert.ok(Array.isArray(files), 'listFiles should return an array'); + assert.ok(files.length > 0, 'Expected at least one .tex file in readonly project'); + }); + + test('8.3 per-project disallowedTools: [write_file] blocks write_file only', () => { + const cfg = { + projects: { + example_project: { + projectId: exProjectId, + gitToken: exToken, + readOnly: false, + disallowedTools: ['write_file'], + }, + }, + }; + const { readOnly, disallowedTools } = getProject('example_project', cfg); + assert.equal(readOnly, false); + assert.ok(disallowedTools.has('write_file'), '"write_file" should be disallowed'); + }); + + test('8.4 per-project disallowedTools: [write_file] does not block str_replace', () => { + const cfg = { + projects: { + example_project: { + projectId: exProjectId, + gitToken: exToken, + readOnly: false, + disallowedTools: ['write_file'], + }, + }, + }; + const { disallowedTools } = getProject('example_project', cfg); + assert.ok(!disallowedTools.has('str_replace'), '"str_replace" should not be disallowed'); + }); + + test('8.5 global defaults.disallowedTools: [write_file] blocks when no per-project override', () => { + const cfg = { + defaults: { disallowedTools: ['write_file'] }, + projects: { + example_project: { projectId: exProjectId, gitToken: exToken }, + }, + }; + const { disallowedTools } = getProject('example_project', cfg); + assert.ok(disallowedTools.has('write_file'), '"write_file" should be blocked via global default'); + }); + + test('8.6 per-project disallowedTools: [] overrides global default', () => { + const cfg = { + defaults: { disallowedTools: ['write_file'] }, + projects: { + example_project: { + projectId: exProjectId, + gitToken: exToken, + disallowedTools: [], + }, + }, + }; + const { disallowedTools } = getProject('example_project', cfg); + assert.ok( + !disallowedTools.has('write_file'), + '"write_file" should NOT be disallowed (per-project empty list overrides global)' + ); + }); +}); diff --git a/test/restore.js b/test/restore.js new file mode 100644 index 0000000..d579f2a --- /dev/null +++ b/test/restore.js @@ -0,0 +1,65 @@ +// Standalone fixture restore script for CI failure recovery. +// Restores test_fixture.tex and test_fixture.bib to their known initial state. +// Run as: node test/restore.js +// Exits 0 on success, 1 on failure. + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { OverleafGitClient } from '../overleaf-git-client.js'; +import { withRetry } from './helpers.js'; + +const TEMP_DIR = process.env.OVERLEAF_TEMP_DIR + ? path.resolve(process.cwd(), process.env.OVERLEAF_TEMP_DIR) + : path.resolve('./temp'); + +const PROJECTS_FILE = process.env.PROJECTS_FILE + ? path.resolve(process.cwd(), process.env.PROJECTS_FILE) + : path.resolve('./projects.json'); + +const FIXTURE_TEX = `\\documentclass{article} +\\usepackage{amsmath} +\\begin{document} + +\\section{Alpha} +Alpha section content. + +\\subsection{Alpha Sub} +Alpha subsection content. + +\\section{Beta} +Beta section content. + +\\end{document} +`; + +const FIXTURE_BIB = `@article{fixture2024, + author = {Fixture, Author}, + title = {The {F}ixture {P}aper}, + journal = {Test Journal}, + year = {2024} +} +`; + +try { + const config = JSON.parse(readFileSync(PROJECTS_FILE, 'utf8')); + const { projectId, gitToken } = config.projects.example_project; + const client = new OverleafGitClient(projectId, gitToken, path.join(TEMP_DIR, 'integration')); + + console.error('[restore] Restoring test_fixture.tex...'); + await withRetry(() => client.writeFile('test_fixture.tex', FIXTURE_TEX, { + push: true, + commitMessage: 'test: restore test_fixture.tex to known state', + })); + + console.error('[restore] Restoring test_fixture.bib...'); + await withRetry(() => client.writeFile('test_fixture.bib', FIXTURE_BIB, { + push: true, + commitMessage: 'test: restore test_fixture.bib to known state', + })); + + console.error('[restore] Done.'); + process.exit(0); +} catch (err) { + console.error('[restore] Failed:', err.message); + process.exit(1); +} \ No newline at end of file diff --git a/test/unit.test.js b/test/unit.test.js new file mode 100644 index 0000000..cf316a2 --- /dev/null +++ b/test/unit.test.js @@ -0,0 +1,449 @@ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { OverleafGitClient, PREVIEW_MAX_LENGTH } from '../overleaf-git-client.js'; + +// Use a resolved path so assertions are platform-safe (Windows/Linux) +const REPO_PATH = path.resolve('/tmp/overleaf-unit-test'); +const client = new OverleafGitClient('dummy-id', 'dummy-token', REPO_PATH); + +// --------------------------------------------------------------------------- +// Method-access tracking via Proxy +// +// The Proxy's `get` trap fires when a property is *accessed* via `tracked`. +// `accessedMethods` therefore records which methods were accessed, not which +// were invoked. The `after()` enforcer below asserts that every method on +// OverleafGitClient.prototype was accessed at least once during the test run, +// ensuring no method silently escapes the test suite. +// --------------------------------------------------------------------------- +const ALL_METHODS = Object.getOwnPropertyNames(OverleafGitClient.prototype) + .filter(name => name !== 'constructor'); + +const accessedMethods = new Set(); +const tracked = new Proxy(client, { + get(target, prop) { + if (typeof target[prop] === 'function') accessedMethods.add(prop); + return typeof target[prop] === 'function' + ? target[prop].bind(target) + : target[prop]; + } +}); + +// --------------------------------------------------------------------------- +// _safePath +// --------------------------------------------------------------------------- +describe('_safePath', () => { + test('valid relative path returns resolved absolute path inside repoPath', () => { + const result = tracked._safePath('main.tex'); + assert.ok( + result.startsWith(REPO_PATH), + `Expected path to start with ${REPO_PATH}, got ${result}` + ); + assert.ok(result.endsWith('main.tex')); + }); + + test('nested valid path returns resolved absolute path inside repoPath', () => { + const result = tracked._safePath('sections/intro.tex'); + assert.ok(result.startsWith(REPO_PATH)); + assert.ok(result.endsWith(path.join('sections', 'intro.tex'))); + }); + + test('path traversal attempt throws', () => { + assert.throws( + () => tracked._safePath('../../etc/passwd'), + /must stay within the repository directory/ + ); + }); + + test('traversal with encoding throws', () => { + assert.throws( + () => tracked._safePath('../outside'), + /must stay within the repository directory/ + ); + }); + + test('root path (empty string) does not throw', () => { + assert.doesNotThrow(() => tracked._safePath('')); + }); + + test('root path (dot) does not throw', () => { + assert.doesNotThrow(() => tracked._safePath('.')); + }); +}); + +// --------------------------------------------------------------------------- +// _parseSections +// --------------------------------------------------------------------------- +describe('_parseSections', () => { + test('empty string returns empty array', () => { + const result = tracked._parseSections(''); + assert.deepEqual(result, []); + }); + + test('no sections returns empty array', () => { + const result = tracked._parseSections('Hello world\nSome text here.'); + assert.deepEqual(result, []); + }); + + test('single section: type, title, and content', () => { + const result = tracked._parseSections('\\section{Intro}\nhello'); + assert.equal(result.length, 1); + assert.equal(result[0].type, 'section'); + assert.equal(result[0].title, 'Intro'); + assert.ok(result[0].content.includes('hello')); + }); + + test('two sections: correct titles and content', () => { + const result = tracked._parseSections('\\section{A}\nfoo\n\\section{B}\nbar'); + assert.equal(result.length, 2); + assert.equal(result[0].title, 'A'); + assert.ok(result[0].content.includes('foo')); + assert.equal(result[1].title, 'B'); + assert.ok(result[1].content.includes('bar')); + }); + + test('subsection inside section produces 2 flat entries', () => { + const result = tracked._parseSections('\\section{S}\n\\subsection{Sub}\ncontent'); + assert.equal(result.length, 2); + assert.equal(result[0].type, 'section'); + assert.equal(result[0].title, 'S'); + assert.equal(result[0].content.trim(), '', 'Section content before subsection should be empty'); + assert.equal(result[1].type, 'subsection'); + assert.equal(result[1].title, 'Sub'); + assert.ok(result[1].content.includes('content')); + }); + + test('starred section: type is still "section", title without asterisk', () => { + const result = tracked._parseSections('\\section*{Unnumbered}\ncontent'); + assert.equal(result.length, 1); + assert.equal(result[0].type, 'section'); + assert.equal(result[0].title, 'Unnumbered'); + }); + + test('all 7 LaTeX levels produce correct types', () => { + const input = [ + '\\part{P}', 'part content', + '\\chapter{C}', 'chapter content', + '\\section{S}', 'section content', + '\\subsection{Sub}', 'subsection content', + '\\subsubsection{SubSub}', 'subsubsection content', + '\\paragraph{Para}', 'paragraph content', + '\\subparagraph{SubPara}', 'subparagraph content', + ].join('\n'); + const result = tracked._parseSections(input); + assert.equal(result.length, 7); + assert.deepEqual(result.map(e => e.type), [ + 'part', 'chapter', 'section', 'subsection', + 'subsubsection', 'paragraph', 'subparagraph', + ]); + }); + + test('preview is truncated to at most PREVIEW_MAX_LENGTH chars', () => { + // Content longer than the maximum — ensures truncation actually occurs + const longContent = 'a '.repeat(PREVIEW_MAX_LENGTH + 1); + const result = tracked._parseSections(`\\section{Long}\n${longContent}`); + assert.equal(result.length, 1); + assert.ok( + result[0].preview.length <= PREVIEW_MAX_LENGTH, + `Expected preview.length <= ${PREVIEW_MAX_LENGTH}, got ${result[0].preview.length}` + ); + }); + + test('last section content runs to EOF', () => { + const result = tracked._parseSections('\\section{Only}\nsome content at the end'); + assert.equal(result.length, 1); + assert.ok(result[0].content.includes('some content at the end')); + }); + + test('CRLF normalised to LF in section content', () => { + const result = tracked._parseSections('\\section{A}\r\nsome content\r\n'); + assert.equal(result.length, 1); + assert.ok(!result[0].content.includes('\r'), 'Expected no CR in content after normalisation'); + }); +}); + +// --------------------------------------------------------------------------- +// _buildSectionTree +// --------------------------------------------------------------------------- +describe('_buildSectionTree', () => { + test('flat input (all same level): each entry is root node with empty children', () => { + const sections = tracked._parseSections('\\section{A}\nfoo\n\\section{B}\nbar'); + const tree = tracked._buildSectionTree(sections); + assert.equal(tree.length, 2); + assert.deepEqual(tree[0].children, []); + assert.deepEqual(tree[1].children, []); + }); + + test('section with one subsection: section has 1 child, subsection has empty children', () => { + const sections = tracked._parseSections('\\section{S}\n\\subsection{Sub}\ncontent'); + const tree = tracked._buildSectionTree(sections); + assert.equal(tree.length, 1); + assert.equal(tree[0].title, 'S'); + assert.equal(tree[0].children.length, 1); + assert.equal(tree[0].children[0].title, 'Sub'); + assert.deepEqual(tree[0].children[0].children, []); + }); + + test('section → subsection → subsubsection: 3-level nesting', () => { + const input = '\\section{S}\n\\subsection{Sub}\n\\subsubsection{SubSub}\ncontent'; + const tree = tracked._buildSectionTree(tracked._parseSections(input)); + assert.equal(tree.length, 1); + assert.equal(tree[0].children.length, 1); + assert.equal(tree[0].children[0].children.length, 1); + assert.equal(tree[0].children[0].children[0].title, 'SubSub'); + }); + + test('two sections each with their own subsections: no cross-contamination', () => { + const input = [ + '\\section{A}', '\\subsection{A1}', 'a1 content', + '\\section{B}', '\\subsection{B1}', 'b1 content', + ].join('\n'); + const tree = tracked._buildSectionTree(tracked._parseSections(input)); + assert.equal(tree.length, 2); + assert.equal(tree[0].children.length, 1); + assert.equal(tree[0].children[0].title, 'A1'); + assert.equal(tree[1].children.length, 1); + assert.equal(tree[1].children[0].title, 'B1'); + }); + + test('subsection followed by section (dedents): subsection is child of first section, second section is root', () => { + const input = '\\section{First}\n\\subsection{Child}\ncontent\n\\section{Second}\nmore'; + const tree = tracked._buildSectionTree(tracked._parseSections(input)); + assert.equal(tree.length, 2); + assert.equal(tree[0].title, 'First'); + assert.equal(tree[0].children.length, 1); + assert.equal(tree[0].children[0].title, 'Child'); + assert.equal(tree[1].title, 'Second'); + assert.deepEqual(tree[1].children, []); + }); +}); + +// --------------------------------------------------------------------------- +// _parseBibEntries +// --------------------------------------------------------------------------- +describe('_parseBibEntries', () => { + test('empty string returns empty array', () => { + const result = tracked._parseBibEntries(''); + assert.deepEqual(result, []); + }); + + test('single @article entry: citeKey and raw are correct', () => { + const bib = '@article{smith2024,\n author = {Smith, John},\n year = {2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 1); + assert.equal(result[0].citeKey, 'smith2024'); + assert.ok(result[0].raw.includes('smith2024')); + }); + + test('entry with nested braces in title is parsed correctly', () => { + const bib = '@article{key2024,\n title = {The {N}ested},\n year = {2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 1); + assert.equal(result[0].citeKey, 'key2024'); + }); + + test('two entries: both citeKeys returned', () => { + const bib = '@article{a2024,\n year = {2024}\n}\n\n@book{b2024,\n year = {2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 2); + assert.equal(result[0].citeKey, 'a2024'); + assert.equal(result[1].citeKey, 'b2024'); + }); + + test('@comment entry is skipped', () => { + const bib = '@comment{ignored}\n@article{real2024,\n year = {2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 1); + assert.equal(result[0].citeKey, 'real2024'); + }); + + test('@string entry is skipped', () => { + const bib = '@string{abbrev = {Journal of Things}}\n@article{real2024,\n year = {2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 1); + assert.equal(result[0].citeKey, 'real2024'); + }); + + test('@preamble entry is skipped', () => { + const bib = '@preamble{"\\newcommand{\\foo}{bar}"}\n@article{real2024,\n year = {2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 1); + assert.equal(result[0].citeKey, 'real2024'); + }); + + test('mixed article + comment + book returns 2 entries (article, book), comment skipped', () => { + const bib = '@article{a2024,\n year={2024}\n}\n@comment{skip}\n@book{b2024,\n year={2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 2); + const keys = result.map(e => e.citeKey); + assert.ok(keys.includes('a2024')); + assert.ok(keys.includes('b2024')); + }); + + test('entry using () delimiters is parsed correctly', () => { + const bib = '@article(paren2024,\n year = {2024}\n)'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 1); + assert.equal(result[0].citeKey, 'paren2024'); + }); + + test('malformed entry with unclosed brace does not crash', () => { + const bib = '@article{broken,\n title = {unclosed\n@article{valid2024,\n year = {2024}\n}'; + assert.doesNotThrow(() => tracked._parseBibEntries(bib)); + }); + + test('start and end offsets: content.slice(start, end) === raw', () => { + const bib = '@article{key2024,\n year = {2024}\n}'; + const result = tracked._parseBibEntries(bib); + assert.equal(result.length, 1); + assert.equal(bib.slice(result[0].start, result[0].end), result[0].raw); + }); +}); + +// --------------------------------------------------------------------------- +// _findUniqueAnchor +// --------------------------------------------------------------------------- +describe('_findUniqueAnchor', () => { + test('zero matches throws "not found in file"', () => { + assert.throws( + () => tracked._findUniqueAnchor('hello world', 'xyz'), + /not found in file/ + ); + }); + + test('zero matches error includes whitespace hint', () => { + assert.throws( + () => tracked._findUniqueAnchor('hello world', 'xyz'), + /Check for whitespace or line-ending differences/ + ); + }); + + test('exactly one match returns correct index', () => { + const content = 'hello world foo'; + const idx = tracked._findUniqueAnchor(content, 'world'); + assert.equal(idx, content.indexOf('world')); + }); + + test('two matches throws with count 2', () => { + assert.throws( + () => tracked._findUniqueAnchor('foo foo', 'foo'), + /matches 2 locations/ + ); + }); + + test('five matches throws with count 5', () => { + assert.throws( + () => tracked._findUniqueAnchor('x x x x x', 'x'), + /matches 5 locations/ + ); + }); + + test('empty anchorStr throws "must not be empty"', () => { + assert.throws( + () => tracked._findUniqueAnchor('some content', ''), + /must not be empty/ + ); + }); + + test('anchor at position 0 returns 0', () => { + const idx = tracked._findUniqueAnchor('anchor at start', 'anchor'); + assert.equal(idx, 0); + }); + + test('anchor at end of string returns correct index', () => { + const content = 'some content end'; + const idx = tracked._findUniqueAnchor(content, 'end'); + assert.equal(idx, content.lastIndexOf('end')); + }); + + test('two non-overlapping matches throws with count 2', () => { + assert.throws( + () => tracked._findUniqueAnchor('foo bar foo', 'foo'), + /matches 2 locations/ + ); + }); + + test('overlapping occurrences are each counted: "aa" in "aaa" is 2 by contract', () => { + // The contract specifies strict counting: each position where the anchor + // starts is counted independently, including overlapping positions. + // "aa" starts at index 0 and index 1 in "aaa", so the count is 2. + assert.throws( + () => tracked._findUniqueAnchor('aaa', 'aa'), + /matches 2 locations/ + ); + }); + + test('match count in error message is accurate', () => { + let errorMessage = ''; + try { + tracked._findUniqueAnchor('foo foo foo', 'foo'); + } catch (err) { + errorMessage = err.message; + } + assert.ok(errorMessage.includes('3'), `Expected count 3 in error message, got: "${errorMessage}"`); + }); +}); + +// --------------------------------------------------------------------------- +// Coverage registration: I/O and git-touching methods +// +// The Proxy registers a method when its property is *accessed* via `tracked`, +// not when the method is called. Accessing `tracked.methodName` is enough to +// satisfy the coverage enforcer and avoids spawning real git processes (which +// would each take ~500 ms to fail, violating the <1 s run-time target). +// +// Each test asserts the method is a function, which is a meaningful and fast +// check. Behavioural testing of all these methods is in integration tests. +// --------------------------------------------------------------------------- +describe('coverage registration (I/O methods)', () => { + const ioMethods = [ + 'cloneOrPull', + 'listFiles', + 'readFile', + 'getSections', + 'getSection', + 'getPreamble', + 'getPostamble', + 'listHistory', + 'getDiff', + 'writeFile', + 'writeSection', + 'strReplace', + 'insertBefore', + 'insertAfter', + 'getBibEntry', + 'addBibEntry', + 'replaceBibEntry', + 'removeBibEntry', + '_gitEnv', + '_git', + '_commitAndPush', + '_recoverDirtyState', + 'getSectionTree', + ]; + + for (const method of ioMethods) { + test(`${method} is a function on the client`, () => { + assert.equal( + typeof tracked[method], + 'function', + `Expected ${method} to be a function` + ); + }); + } +}); + +// --------------------------------------------------------------------------- +// Access enforcer +// Every method on OverleafGitClient.prototype must be accessed via `tracked` +// at least once during the test run. Adding a new method to the class +// automatically makes it required here. +// --------------------------------------------------------------------------- +after(() => { + for (const method of ALL_METHODS) { + assert.ok( + accessedMethods.has(method), + `Method "${method}" has no unit test coverage` + ); + } +});