diff --git a/tests/core/doctor/doctor-checks.test.js b/tests/core/doctor/doctor-checks.test.js new file mode 100644 index 000000000..c44174c22 --- /dev/null +++ b/tests/core/doctor/doctor-checks.test.js @@ -0,0 +1,803 @@ +/** + * Testes unitarios para os checks do Doctor + * + * Cobre todos os 15 checks modulares: node-version, core-config, + * rules-files, agent-memory, entity-registry, git-hooks, ide-sync, + * settings-json, skills-count, commands-count, hooks-claude-count. + * + * @see .aiox-core/core/doctor/checks/ + * @issue #52 + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +// ============================================================================ +// Helpers +// ============================================================================ + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-check-')); +} + +function makeContext(projectRoot) { + return { + projectRoot, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; +} + +// ============================================================================ +// node-version +// ============================================================================ + +describe('Doctor Check: node-version', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/node-version'); + + it('deve exportar nome correto', () => { + expect(name).toBe('node-version'); + }); + + it('deve retornar PASS quando Node >= 18', async () => { + // Rodando neste ambiente, Node deve ser >= 18 + const result = await run(); + const major = parseInt(process.version.replace('v', '').split('.')[0], 10); + + if (major >= 18) { + expect(result.status).toBe('PASS'); + expect(result.check).toBe('node-version'); + expect(result.message).toContain('Node.js'); + expect(result.fixCommand).toBeNull(); + } + }); +}); + +// ============================================================================ +// core-config +// ============================================================================ + +describe('Doctor Check: core-config', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/core-config'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('core-config'); + }); + + it('deve retornar FAIL quando core-config.yaml nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + expect(result.fixCommand).toBeTruthy(); + }); + + it('deve retornar PASS quando todas as secoes obrigatorias estao presentes', async () => { + const configDir = path.join(tmpDir, '.aiox-core'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'core-config.yaml'), + 'boundary:\n protection: true\nproject:\n name: test\nide:\n sync: true\n', + ); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.fixCommand).toBeNull(); + }); + + it('deve retornar FAIL quando faltam secoes obrigatorias', async () => { + const configDir = path.join(tmpDir, '.aiox-core'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'core-config.yaml'), + 'boundary:\n protection: true\n', + ); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('Missing sections'); + expect(result.message).toContain('project'); + expect(result.message).toContain('ide'); + }); +}); + +// ============================================================================ +// rules-files +// ============================================================================ + +describe('Doctor Check: rules-files', () => { + const { run, name, EXPECTED_RULES } = require('../../../.aiox-core/core/doctor/checks/rules-files'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome e lista de regras esperadas', () => { + expect(name).toBe('rules-files'); + expect(EXPECTED_RULES).toBeInstanceOf(Array); + expect(EXPECTED_RULES.length).toBeGreaterThan(0); + }); + + it('deve retornar FAIL quando diretorio de regras nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar PASS quando todos os arquivos de regras existem', async () => { + const rulesDir = path.join(tmpDir, '.claude', 'rules'); + fs.mkdirSync(rulesDir, { recursive: true }); + for (const rule of EXPECTED_RULES) { + fs.writeFileSync(path.join(rulesDir, rule), '# rule\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.fixCommand).toBeNull(); + }); + + it('deve retornar WARN quando faltam ate 3 regras', async () => { + const rulesDir = path.join(tmpDir, '.claude', 'rules'); + fs.mkdirSync(rulesDir, { recursive: true }); + // Cria todos exceto os 2 ultimos + for (const rule of EXPECTED_RULES.slice(0, -2)) { + fs.writeFileSync(path.join(rulesDir, rule), '# rule\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('Missing'); + }); + + it('deve retornar FAIL quando faltam mais de 3 regras', async () => { + const rulesDir = path.join(tmpDir, '.claude', 'rules'); + fs.mkdirSync(rulesDir, { recursive: true }); + // Cria apenas a primeira regra + fs.writeFileSync(path.join(rulesDir, EXPECTED_RULES[0]), '# rule\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + }); +}); + +// ============================================================================ +// agent-memory +// ============================================================================ + +describe('Doctor Check: agent-memory', () => { + const { run, name, EXPECTED_AGENTS } = require('../../../.aiox-core/core/doctor/checks/agent-memory'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome e lista de agentes esperados', () => { + expect(name).toBe('agent-memory'); + expect(EXPECTED_AGENTS).toContain('dev'); + expect(EXPECTED_AGENTS).toContain('qa'); + expect(EXPECTED_AGENTS).toContain('devops'); + }); + + it('deve retornar FAIL quando diretorio de agentes nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar PASS quando todos os MEMORY.md existem', async () => { + for (const agent of EXPECTED_AGENTS) { + const agentDir = path.join(tmpDir, '.aiox-core', 'development', 'agents', agent); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'MEMORY.md'), `# ${agent}\n`); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain(`${EXPECTED_AGENTS.length}/${EXPECTED_AGENTS.length}`); + }); + + it('deve retornar WARN quando faltam alguns MEMORY.md', async () => { + // Cria apenas para os 3 primeiros agentes + for (const agent of EXPECTED_AGENTS.slice(0, 3)) { + const agentDir = path.join(tmpDir, '.aiox-core', 'development', 'agents', agent); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'MEMORY.md'), `# ${agent}\n`); + } + // Garante que o diretorio base existe + const baseDir = path.join(tmpDir, '.aiox-core', 'development', 'agents'); + fs.mkdirSync(baseDir, { recursive: true }); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('missing'); + }); +}); + +// ============================================================================ +// entity-registry +// ============================================================================ + +describe('Doctor Check: entity-registry', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/entity-registry'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('entity-registry'); + }); + + it('deve retornar FAIL quando entity-registry.yaml nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar PASS quando entity-registry.yaml existe e e recente', async () => { + const dataDir = path.join(tmpDir, '.aiox-core', 'data'); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(path.join(dataDir, 'entity-registry.yaml'), 'entities:\n - agent: dev\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain('lines'); + }); + + it('deve retornar WARN quando entity-registry.yaml e antigo (>48h)', async () => { + const dataDir = path.join(tmpDir, '.aiox-core', 'data'); + fs.mkdirSync(dataDir, { recursive: true }); + const registryPath = path.join(dataDir, 'entity-registry.yaml'); + fs.writeFileSync(registryPath, 'entities:\n - agent: dev\n'); + + // Define mtime para 72h atras + const oldTime = new Date(Date.now() - 72 * 60 * 60 * 1000); + fs.utimesSync(registryPath, oldTime, oldTime); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('old'); + }); +}); + +// ============================================================================ +// git-hooks +// ============================================================================ + +describe('Doctor Check: git-hooks', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/git-hooks'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('git-hooks'); + }); + + it('deve retornar WARN quando .husky nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('.husky'); + }); + + it('deve retornar PASS quando todos os hooks existem', async () => { + const huskyDir = path.join(tmpDir, '.husky'); + fs.mkdirSync(huskyDir, { recursive: true }); + fs.writeFileSync(path.join(huskyDir, 'pre-commit'), '#!/bin/sh\n'); + fs.writeFileSync(path.join(huskyDir, 'pre-push'), '#!/bin/sh\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain('pre-commit'); + expect(result.message).toContain('pre-push'); + }); + + it('deve retornar WARN quando faltam hooks', async () => { + const huskyDir = path.join(tmpDir, '.husky'); + fs.mkdirSync(huskyDir, { recursive: true }); + fs.writeFileSync(path.join(huskyDir, 'pre-commit'), '#!/bin/sh\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('pre-push'); + }); +}); + +// ============================================================================ +// skills-count +// ============================================================================ + +describe('Doctor Check: skills-count', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/skills-count'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('skills-count'); + }); + + it('deve retornar FAIL quando diretorio de skills nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar FAIL quando nenhuma skill e encontrada', async () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('No skills found'); + }); + + it('deve retornar WARN quando skills < 7', async () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + for (let i = 0; i < 3; i++) { + const skillDir = path.join(skillsDir, `skill-${i}`); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# skill\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('3/7'); + }); + + it('deve retornar PASS quando skills >= 7', async () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + for (let i = 0; i < 8; i++) { + const skillDir = path.join(skillsDir, `skill-${i}`); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# skill\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain('8'); + }); + + it('deve ignorar diretorios sem SKILL.md', async () => { + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + + // Diretorio sem SKILL.md + fs.mkdirSync(path.join(skillsDir, 'empty-skill'), { recursive: true }); + // Arquivo solto (nao diretorio) + fs.writeFileSync(path.join(skillsDir, 'readme.txt'), 'not a skill\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('No skills found'); + }); +}); + +// ============================================================================ +// commands-count +// ============================================================================ + +describe('Doctor Check: commands-count', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/commands-count'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('commands-count'); + }); + + it('deve retornar FAIL quando diretorio de commands nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar FAIL quando commands < 12', async () => { + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + + for (let i = 0; i < 5; i++) { + fs.writeFileSync(path.join(commandsDir, `cmd-${i}.md`), '# cmd\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('5'); + }); + + it('deve retornar WARN quando commands >= 12 e < 20', async () => { + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + + for (let i = 0; i < 15; i++) { + fs.writeFileSync(path.join(commandsDir, `cmd-${i}.md`), '# cmd\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('15/20'); + }); + + it('deve retornar PASS quando commands >= 20', async () => { + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + + for (let i = 0; i < 25; i++) { + fs.writeFileSync(path.join(commandsDir, `cmd-${i}.md`), '# cmd\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + }); + + it('deve contar .md recursivamente em subdiretorios', async () => { + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + const subDir = path.join(commandsDir, 'agents'); + fs.mkdirSync(subDir, { recursive: true }); + + for (let i = 0; i < 12; i++) { + fs.writeFileSync(path.join(commandsDir, `cmd-${i}.md`), '# cmd\n'); + } + for (let i = 0; i < 10; i++) { + fs.writeFileSync(path.join(subDir, `agent-${i}.md`), '# agent\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain('22'); + }); +}); + +// ============================================================================ +// hooks-claude-count +// ============================================================================ + +describe('Doctor Check: hooks-claude-count', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/hooks-claude-count'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('hooks-claude-count'); + }); + + it('deve retornar FAIL quando diretorio de hooks nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar FAIL quando nenhum .cjs encontrado', async () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('No hook files'); + }); + + it('deve retornar WARN quando < 2 hooks', async () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'pre-tool.cjs'), '// hook\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('1/2'); + }); + + it('deve retornar WARN quando hooks existem mas nao estao registrados', async () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'pre-tool.cjs'), '// hook\n'); + fs.writeFileSync(path.join(hooksDir, 'post-tool.cjs'), '// hook\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('not registered'); + }); + + it('deve retornar PASS quando hooks >= 2 e registrados em settings.local.json', async () => { + const hooksDir = path.join(tmpDir, '.claude', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'pre-tool.cjs'), '// hook\n'); + fs.writeFileSync(path.join(hooksDir, 'post-tool.cjs'), '// hook\n'); + + const settingsLocal = { + hooks: { + PreToolUse: [ + { + matcher: '*', + hooks: [{ type: 'command', command: 'node .claude/hooks/pre-tool.cjs' }], + }, + ], + PostToolUse: [ + { + matcher: '*', + hooks: [{ type: 'command', command: 'node .claude/hooks/post-tool.cjs' }], + }, + ], + }, + }; + fs.writeFileSync( + path.join(tmpDir, '.claude', 'settings.local.json'), + JSON.stringify(settingsLocal), + ); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain('registered'); + }); +}); + +// ============================================================================ +// settings-json +// ============================================================================ + +describe('Doctor Check: settings-json', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/settings-json'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('settings-json'); + }); + + it('deve retornar FAIL quando settings.json nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar FAIL quando settings.json e JSON invalido', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{ invalid json'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('invalid JSON'); + }); + + it('deve retornar WARN quando deny rules < 40', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const settings = { + permissions: { + deny: Array.from({ length: 10 }, (_, i) => `deny-rule-${i}`), + allow: [], + }, + }; + fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify(settings)); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('10'); + }); + + it('deve retornar PASS quando deny rules >= 40', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const settings = { + permissions: { + deny: Array.from({ length: 45 }, (_, i) => `deny-rule-${i}`), + allow: ['allow-1'], + }, + }; + fs.writeFileSync(path.join(claudeDir, 'settings.json'), JSON.stringify(settings)); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain('45'); + }); +}); + +// ============================================================================ +// ide-sync +// ============================================================================ + +describe('Doctor Check: ide-sync', () => { + const { run, name } = require('../../../.aiox-core/core/doctor/checks/ide-sync'); + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar nome correto', () => { + expect(name).toBe('ide-sync'); + }); + + it('deve retornar FAIL quando diretorio source nao existe', async () => { + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('FAIL'); + expect(result.message).toContain('not found'); + }); + + it('deve retornar WARN quando diretorio IDE nao existe', async () => { + const sourceDir = path.join(tmpDir, '.aiox-core', 'development', 'agents'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(sourceDir, 'dev.md'), '# dev\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + }); + + it('deve retornar PASS quando agent counts coincidem', async () => { + const sourceDir = path.join(tmpDir, '.aiox-core', 'development', 'agents'); + const ideDir = path.join(tmpDir, '.claude', 'commands', 'AIOX', 'agents'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.mkdirSync(ideDir, { recursive: true }); + + const agents = ['dev.md', 'qa.md', 'architect.md']; + for (const agent of agents) { + fs.writeFileSync(path.join(sourceDir, agent), '# agent\n'); + fs.writeFileSync(path.join(ideDir, agent), '# agent\n'); + } + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('PASS'); + expect(result.message).toContain('3/3'); + }); + + it('deve retornar WARN quando counts nao coincidem', async () => { + const sourceDir = path.join(tmpDir, '.aiox-core', 'development', 'agents'); + const ideDir = path.join(tmpDir, '.claude', 'commands', 'AIOX', 'agents'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.mkdirSync(ideDir, { recursive: true }); + + fs.writeFileSync(path.join(sourceDir, 'dev.md'), '# dev\n'); + fs.writeFileSync(path.join(sourceDir, 'qa.md'), '# qa\n'); + fs.writeFileSync(path.join(ideDir, 'dev.md'), '# dev\n'); + + const ctx = makeContext(tmpDir); + const result = await run(ctx); + + expect(result.status).toBe('WARN'); + expect(result.message).toContain('1'); + expect(result.message).toContain('2'); + }); +}); diff --git a/tests/core/doctor/doctor-index.test.js b/tests/core/doctor/doctor-index.test.js new file mode 100644 index 000000000..d826a1b66 --- /dev/null +++ b/tests/core/doctor/doctor-index.test.js @@ -0,0 +1,144 @@ +/** + * Testes unitarios para o Doctor Index (orchestrator) + * + * Cobre runDoctorChecks com todas as opcoes: fix, json, dryRun, quiet. + * + * @see .aiox-core/core/doctor/index.js + * @issue #52 + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const { runDoctorChecks, DOCTOR_VERSION } = require('../../../.aiox-core/core/doctor'); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-index-')); +} + +describe('Doctor Orchestrator (index)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve exportar DOCTOR_VERSION', () => { + expect(DOCTOR_VERSION).toBe('2.0.0'); + }); + + it('deve retornar resultados estruturados com summary', async () => { + const { data } = await runDoctorChecks({ projectRoot: tmpDir }); + + expect(data).toHaveProperty('version', DOCTOR_VERSION); + expect(data).toHaveProperty('timestamp'); + expect(data).toHaveProperty('summary'); + expect(data).toHaveProperty('checks'); + expect(data.summary).toHaveProperty('pass'); + expect(data.summary).toHaveProperty('warn'); + expect(data.summary).toHaveProperty('fail'); + expect(data.summary).toHaveProperty('info'); + expect(data.fixResults).toBeNull(); + }); + + it('deve retornar formatted text por padrao', async () => { + const { formatted } = await runDoctorChecks({ projectRoot: tmpDir }); + + expect(typeof formatted).toBe('string'); + expect(formatted.length).toBeGreaterThan(0); + }); + + it('deve retornar formatted JSON quando json=true', async () => { + const { formatted, data } = await runDoctorChecks({ + projectRoot: tmpDir, + json: true, + }); + + expect(typeof formatted).toBe('string'); + const parsed = JSON.parse(formatted); + expect(parsed).toHaveProperty('version'); + expect(parsed).toHaveProperty('checks'); + }); + + it('deve aplicar fixes quando fix=true', async () => { + const { data } = await runDoctorChecks({ + projectRoot: tmpDir, + fix: true, + }); + + expect(data.fixResults).not.toBeNull(); + expect(Array.isArray(data.fixResults)).toBe(true); + }); + + it('deve retornar dry-run results quando dryRun=true', async () => { + const { data } = await runDoctorChecks({ + projectRoot: tmpDir, + dryRun: true, + }); + + expect(data.fixResults).not.toBeNull(); + for (const fix of data.fixResults) { + expect(fix.applied).toBe(false); + } + }); + + it('deve funcionar com quiet=true', async () => { + const { formatted } = await runDoctorChecks({ + projectRoot: tmpDir, + quiet: true, + }); + + expect(typeof formatted).toBe('string'); + }); + + it('deve usar cwd como projectRoot padrao', async () => { + const { data } = await runDoctorChecks(); + + expect(data).toHaveProperty('checks'); + expect(data.checks.length).toBeGreaterThan(0); + }); + + it('deve conter todos os checks esperados', async () => { + const { data } = await runDoctorChecks({ projectRoot: tmpDir }); + + const checkNames = data.checks.map((c) => c.check); + expect(checkNames).toContain('node-version'); + expect(checkNames).toContain('core-config'); + expect(checkNames).toContain('rules-files'); + expect(checkNames).toContain('agent-memory'); + expect(checkNames).toContain('entity-registry'); + }); + + it('deve tratar erros em checks individuais sem parar', async () => { + // Rodar contra um diretorio vazio — nenhum check deve lançar exceção + const { data } = await runDoctorChecks({ projectRoot: tmpDir }); + + // Todos os checks devem ter resultado, mesmo com diretório vazio + expect(data.checks.length).toBeGreaterThan(0); + for (const check of data.checks) { + expect(check).toHaveProperty('status'); + expect(['PASS', 'WARN', 'FAIL', 'INFO']).toContain(check.status); + } + }); + + it('summary counts devem bater com resultados', async () => { + const { data } = await runDoctorChecks({ projectRoot: tmpDir }); + + const pass = data.checks.filter((c) => c.status === 'PASS').length; + const warn = data.checks.filter((c) => c.status === 'WARN').length; + const fail = data.checks.filter((c) => c.status === 'FAIL').length; + const info = data.checks.filter((c) => c.status === 'INFO').length; + + expect(data.summary.pass).toBe(pass); + expect(data.summary.warn).toBe(warn); + expect(data.summary.fail).toBe(fail); + expect(data.summary.info).toBe(info); + }); +}); diff --git a/tests/core/doctor/fix-handler.test.js b/tests/core/doctor/fix-handler.test.js new file mode 100644 index 000000000..442b2b725 --- /dev/null +++ b/tests/core/doctor/fix-handler.test.js @@ -0,0 +1,199 @@ +/** + * Testes unitarios para o Doctor Fix Handler + * + * Cobre applyFixes com dry-run, fix real, erros e checks sem fixer. + * + * @see .aiox-core/core/doctor/fix-handler.js + * @issue #52 + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); + +const { applyFixes } = require('../../../.aiox-core/core/doctor/fix-handler'); +const { EXPECTED_RULES } = require('../../../.aiox-core/core/doctor/checks/rules-files'); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-fix-')); +} + +describe('Doctor Fix Handler', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('deve ignorar resultados com status PASS', async () => { + const results = [ + { check: 'node-version', status: 'PASS', message: 'ok' }, + { check: 'core-config', status: 'INFO', message: 'info' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toEqual([]); + }); + + it('deve retornar "No auto-fix available" para checks sem fixer', async () => { + const results = [ + { check: 'unknown-check', status: 'FAIL', message: 'broken' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toHaveLength(1); + expect(fixResults[0].applied).toBe(false); + expect(fixResults[0].message).toBe('No auto-fix available'); + }); + + it('deve retornar dry-run description para rules-files', async () => { + const results = [ + { check: 'rules-files', status: 'FAIL', message: 'missing rules' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: { dryRun: true }, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toHaveLength(1); + expect(fixResults[0].applied).toBe(false); + expect(fixResults[0].message).toContain('[DRY RUN]'); + expect(fixResults[0].message).toContain('Copy missing rules'); + }); + + it('deve copiar regras faltantes quando fix = true (rules-files)', async () => { + const rulesTarget = path.join(tmpDir, '.claude', 'rules'); + // Nao cria o diretorio — o fixer deve criar + + const results = [ + { check: 'rules-files', status: 'FAIL', message: 'missing rules' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toHaveLength(1); + expect(fixResults[0].applied).toBe(true); + expect(fixResults[0].message).toContain('Copied'); + + // Verifica que o diretorio foi criado + expect(fs.existsSync(rulesTarget)).toBe(true); + }); + + it('deve criar MEMORY.md stubs para agentes faltantes', async () => { + const results = [ + { check: 'agent-memory', status: 'WARN', message: 'missing agents' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toHaveLength(1); + expect(fixResults[0].applied).toBe(true); + expect(fixResults[0].message).toContain('Created'); + expect(fixResults[0].message).toContain('MEMORY.md'); + }); + + it('deve retornar dry-run description para agent-memory', async () => { + const results = [ + { check: 'agent-memory', status: 'WARN', message: 'missing' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: { dryRun: true }, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toHaveLength(1); + expect(fixResults[0].message).toContain('[DRY RUN]'); + expect(fixResults[0].message).toContain('MEMORY.md'); + }); + + it('deve tratar fix de claude-md como redirect para install --force', async () => { + const results = [ + { check: 'claude-md', status: 'FAIL', message: 'missing sections' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toHaveLength(1); + expect(fixResults[0].applied).toBe(true); + expect(fixResults[0].message).toContain('install --force'); + }); + + it('deve tratar fix de settings-json', async () => { + const results = [ + { check: 'settings-json', status: 'FAIL', message: 'missing deny rules' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; + + const fixResults = await applyFixes(results, context); + expect(fixResults).toHaveLength(1); + // Pode ser applied=true (com generator) ou message com install --force + expect(fixResults[0].check).toBe('settings-json'); + }); + + it('deve processar multiplos resultados WARN/FAIL em sequencia', async () => { + const results = [ + { check: 'node-version', status: 'PASS', message: 'ok' }, + { check: 'rules-files', status: 'WARN', message: 'missing 2' }, + { check: 'unknown-check', status: 'FAIL', message: 'broken' }, + { check: 'agent-memory', status: 'WARN', message: 'missing agents' }, + ]; + + const context = { + projectRoot: tmpDir, + frameworkRoot: path.resolve(__dirname, '..', '..', '..'), + options: {}, + }; + + const fixResults = await applyFixes(results, context); + // PASS ignorado, 3 processados (rules-files, unknown-check, agent-memory) + expect(fixResults).toHaveLength(3); + expect(fixResults[0].check).toBe('rules-files'); + expect(fixResults[1].check).toBe('unknown-check'); + expect(fixResults[1].applied).toBe(false); + expect(fixResults[2].check).toBe('agent-memory'); + }); +});