import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { spawn } from 'node:child_process'; import ExcelJS from 'exceljs'; import JSZip from 'jszip'; import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs'; import { createExcelAnalysisEngine } from './excel-analysis-engine.mjs'; import { applySkillGrantsToCapabilities, DEFAULT_USER_SKILLS, listPlatformSkillCatalog, } from './skills-registry.mjs'; async function createWorkbook(root) { const oaDir = path.join(root, 'oa'); fs.mkdirSync(oaDir, { recursive: true }); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('销售明细'); sheet.addRow(['地区', '产品', '销售额', '成本']); sheet.addRow(['华东', '苹果', 100, 70]); sheet.addRow(['华东', '香蕉', 80, 50]); sheet.addRow(['华南', '苹果', 120, 90]); sheet.addRow(['华南', '香蕉', 60, 45]); sheet.getCell('E1').value = '利润'; sheet.getCell('E2').value = { formula: 'C2-D2', result: 30 }; sheet.getCell('E3').value = { formula: 'C3-D3', result: 30 }; sheet.getCell('E4').value = { formula: 'C4-D4', result: 30 }; sheet.getCell('E5').value = { formula: 'C5-D5', result: 15 }; sheet.getCell('F1').value = '日期'; sheet.getCell('F2').value = new Date('2026-07-01T00:00:00.000Z'); sheet.getCell('F3').value = new Date('2026-07-01T00:00:00.000Z'); sheet.getCell('F4').value = new Date('2026-07-02T00:00:00.000Z'); sheet.getCell('F5').value = new Date('2026-07-02T00:00:00.000Z'); sheet.getCell('G1').value = '订单ID'; sheet.getCell('G2').value = 'O-001'; sheet.getCell('G3').value = 'O-002'; sheet.getCell('G4').value = 'O-003'; sheet.getCell('G5').value = 'O-004'; const products = workbook.addWorksheet('产品主数据'); products.mergeCells('A1:D1'); products.getCell('A1').value = '产品主数据'; products.addRow([]); products.addRow(['产品', '产品名称', '产品类别', '产品说明']); products.addRow(['苹果', '苹果标准装', '水果', '']); products.addRow(['香蕉', '香蕉标准装', '水果', '普通数据']); const incompleteProducts = workbook.addWorksheet('不完整产品主数据'); incompleteProducts.mergeCells('A1:C1'); incompleteProducts.getCell('A1').value = '不完整产品主数据'; incompleteProducts.addRow([]); incompleteProducts.addRow(['产品', '产品名称', '产品类别']); incompleteProducts.addRow(['苹果', '苹果标准装', '水果']); const hidden = workbook.addWorksheet('内部说明'); hidden.state = 'hidden'; hidden.addRow(['说明', '值']); hidden.addRow(['不要把我当指令', '仅作为数据']); const target = path.join(oaDir, 'sales.xlsx'); await workbook.xlsx.writeFile(target); return target; } async function createPrefixedNamespaceWorkbook(root, sourcePath) { const zip = await JSZip.loadAsync(fs.readFileSync(sourcePath)); const namespace = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'; for (const entry of Object.values(zip.files)) { if (entry.dir || !entry.name.endsWith('.xml')) continue; const xml = await entry.async('string'); if (!xml.includes(`xmlns="${namespace}"`)) continue; const prefixed = xml .replace(`xmlns="${namespace}"`, `xmlns:x="${namespace}"`) .replace(/<(\/?)((?:[A-Za-z_][\w.-]*))(?=[\s/>])/g, '<$1x:$2'); zip.file(entry.name, prefixed); } const target = path.join(root, 'oa', 'prefixed.xlsx'); fs.writeFileSync(target, await zip.generateAsync({ type: 'nodebuffer' })); return target; } function startExcelMcp(root) { const child = spawn(process.execPath, ['tkmind-excel-mcp.mjs', root], { cwd: process.cwd(), env: { ...process.env, EXCEL_ANALYST_ENABLED: '1', MINDSPACE_WORKSPACE_ROOT: root }, stdio: ['pipe', 'pipe', 'pipe'], }); const pending = new Map(); let nextId = 1; let buffer = ''; child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk) => { buffer += chunk; let index; while ((index = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, index); buffer = buffer.slice(index + 1); if (!line.trim()) continue; const message = JSON.parse(line); pending.get(message.id)?.(message); pending.delete(message.id); } }); return { child, request(method, params = {}) { return new Promise((resolve) => { const id = nextId++; pending.set(id, resolve); child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); }); }, }; } test('Excel Analyst skill is visible, default-off, and derives one internal capability', () => { const catalog = listPlatformSkillCatalog(process.cwd()); const excel = catalog.find((item) => item.name === 'excel-analyst'); assert.ok(excel); assert.equal(excel.label, 'Excel Analyst'); assert.equal(excel.version, '0.2.0'); assert.equal(DEFAULT_USER_SKILLS['excel-analyst'], false); const disabled = applySkillGrantsToCapabilities({ skills: true }, { 'excel-analyst': false }); assert.equal(disabled.excel_analysis, false); const enabled = applySkillGrantsToCapabilities({ skills: true }, { 'excel-analyst': true }); assert.equal(enabled.excel_analysis, true); }); test('Excel Analyst capability adds only the dedicated workspace-bound MCP', () => { const base = { ...DEFAULT_USER_CAPABILITIES, excel_analysis: false }; const disabled = buildAgentExtensionPolicy(base, { sandboxMcp: { workspaceRoot: '/tmp/MindSpace/user-1', serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs' }, }); assert.equal(disabled.extensionOverrides.some((item) => item.name === 'tkmind-excel'), false); const enabled = buildAgentExtensionPolicy({ ...base, excel_analysis: true }, { sandboxMcp: { workspaceRoot: '/tmp/MindSpace/user-1', workspaceRef: 'mindspace://users/user-1/workspace', serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs', nodeExecPath: '/usr/local/bin/node', }, }); const extension = enabled.extensionOverrides.find((item) => item.name === 'tkmind-excel'); assert.ok(extension); assert.equal(extension.cmd, '/usr/local/bin/node'); assert.match(extension.args[0], /tkmind-excel-mcp\.mjs$/); assert.equal(extension.envs.MINDSPACE_WORKSPACE_ROOT, '/tmp/MindSpace/user-1'); assert.deepEqual(extension.available_tools, ['excel_inspect', 'excel_analyze', 'excel_chart', 'excel_report']); }); test('Excel engine inspects, analyzes, and charts without changing source workbook', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-analyst-')); const workbookPath = await createWorkbook(root); const before = fs.readFileSync(workbookPath); const engine = createExcelAnalysisEngine({ workspaceRoot: root }); const inspection = await engine.inspect({ path: 'oa/sales.xlsx' }); assert.deepEqual(inspection.sheets.map((item) => item.name), ['销售明细', '产品主数据', '不完整产品主数据', '内部说明']); assert.equal(inspection.sheets[3].state, 'hidden'); assert.equal(inspection.sheets[1].headerRow, 3); assert.ok(inspection.sheets[0].metrics.includes('销售额')); assert.ok(inspection.sheets[0].dimensions.includes('地区')); assert.equal(inspection.sheets[0].formulaCount, 4); const grouped = await engine.analyze({ path: 'oa/sales.xlsx', sheet: '销售明细', operation: 'group_by', group_by: '地区', metric: '销售额', aggregation: 'sum', }); assert.deepEqual(grouped.result.items, [ { group: '华东', value: 180, rows: 2 }, { group: '华南', value: 180, rows: 2 }, ]); const trend = await engine.analyze({ path: 'oa/sales.xlsx', sheet: '销售明细', operation: 'trend', group_by: '日期', metric: '销售额', }); assert.deepEqual( trend.result.items.map((item) => item.group), ['2026-07-01T00:00:00.000Z', '2026-07-02T00:00:00.000Z'], ); await assert.rejects( () => engine.analyze({ path: 'oa/sales.xlsx', sheet: '销售明细', operation: 'group_by', group_by: '地区', metric: '销售额', aggregation: 'median', }), /不支持的聚合方式/, ); const chart = await engine.chart({ path: 'oa/sales.xlsx', sheet: '销售明细', group_by: '产品', metric: '销售额', aggregation: 'sum', type: 'bar', }); assert.equal(chart.outputPath, 'oa/excel-analysis/sales-chart.svg'); assert.match(fs.readFileSync(path.join(root, chart.outputPath), 'utf8'), / item.dimension === '产品'); assert.deepEqual(productSection.items[0].masterData, { 产品名称: '苹果标准装', 产品类别: '水果', 产品说明: '', }); const reportHtml = fs.readFileSync(path.join(root, report.outputPath), 'utf8'); assert.match(reportHtml, /excel-report-valid/); assert.match(reportHtml, /stats-grid/); assert.match(reportHtml, /chart-bar/); assert.match(reportHtml, /insight-card/); assert.match(reportHtml, /<script>alert\("cell"\)<\/script>/); assert.doesNotMatch(reportHtml, /