d3141a3e91
Memind CI / Test, build, and release guards (push) Successful in 4m34s
Move report generation into excel-report-html.mjs so excel_report matches the hand-enhanced layout with KPI cards, charts, and insight cards, while keeping fact-model validation in the analysis engine. Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
15 KiB
JavaScript
358 lines
15 KiB
JavaScript
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(['苹果', '苹果标准装', '水果', '<script>alert("cell")</script>']);
|
|
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'), /<svg/);
|
|
|
|
const report = await engine.report({
|
|
path: 'oa/sales.xlsx',
|
|
sheet: '销售明细',
|
|
metric: '销售额',
|
|
profit_metric: '利润',
|
|
order_id: '订单ID',
|
|
dimensions: ['地区', '产品'],
|
|
joins: [{
|
|
dimension: '产品',
|
|
sheet: '产品主数据',
|
|
key: '产品',
|
|
fields: ['产品名称', '产品类别', '产品说明'],
|
|
}],
|
|
output_path: 'public/sales-report.html',
|
|
});
|
|
assert.equal(report.model.validation.valid, true);
|
|
assert.equal(report.model.totals.metric, 360);
|
|
assert.equal(report.model.totals.profit, 105);
|
|
assert.equal(report.model.totals.margin, 0.291667);
|
|
assert.deepEqual(report.model.facts.topAverage.产品, { group: '苹果', value: 110 });
|
|
const productSection = report.model.sections.find((item) => item.dimension === '产品');
|
|
assert.deepEqual(productSection.items[0].masterData, {
|
|
产品名称: '苹果标准装',
|
|
产品类别: '水果',
|
|
产品说明: '<script>alert("cell")</script>',
|
|
});
|
|
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, /<script>alert/);
|
|
|
|
await assert.rejects(
|
|
() => engine.report({
|
|
path: 'oa/sales.xlsx',
|
|
sheet: '销售明细',
|
|
metric: '销售额',
|
|
dimensions: ['产品'],
|
|
joins: [{ dimension: '产品', sheet: '不完整产品主数据', key: '产品', fields: ['产品名称'] }],
|
|
output_path: 'public/invalid-report.html',
|
|
}),
|
|
(error) => error?.code === 'REPORT_VALIDATION_FAILED' && error?.detail?.errors?.some((item) => item.code === 'MISSING_MASTER_DATA'),
|
|
);
|
|
assert.equal(fs.existsSync(path.join(root, 'public', 'invalid-report.html')), false);
|
|
|
|
await createPrefixedNamespaceWorkbook(root, workbookPath);
|
|
const prefixedInspection = await engine.inspect({ path: 'oa/prefixed.xlsx', sample_rows: 1 });
|
|
assert.equal(prefixedInspection.sheets[0].headers[0], '地区');
|
|
assert.equal(prefixedInspection.sheets[1].headerRow, 3);
|
|
assert.deepEqual(fs.readFileSync(workbookPath), before);
|
|
await assert.rejects(() => engine.inspect({ path: '../sales.xlsx' }), /工作区内的相对路径/);
|
|
|
|
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-outside-'));
|
|
const outsideWorkbook = await createWorkbook(outside);
|
|
fs.symlinkSync(outsideWorkbook, path.join(root, 'oa', 'linked.xlsx'));
|
|
await assert.rejects(() => engine.inspect({ path: 'oa/linked.xlsx' }), /符号链接越出/);
|
|
|
|
const outsideCharts = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-chart-outside-'));
|
|
fs.symlinkSync(outsideCharts, path.join(root, 'oa', 'linked-charts'));
|
|
await assert.rejects(
|
|
() => engine.chart({
|
|
path: 'oa/sales.xlsx',
|
|
sheet: '销售明细',
|
|
group_by: '产品',
|
|
metric: '销售额',
|
|
output_path: 'oa/linked-charts/chart.svg',
|
|
}),
|
|
/符号链接越出/,
|
|
);
|
|
});
|
|
|
|
test('Excel engine reports the exact supported format boundary for XLSX, XLS, and CSV', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-format-contract-'));
|
|
await createWorkbook(root);
|
|
const engine = createExcelAnalysisEngine({ workspaceRoot: root });
|
|
|
|
assert.equal((await engine.inspect({ path: 'oa/sales.xlsx' })).format, 'xlsx');
|
|
for (const unsupportedPath of ['oa/legacy.xls', 'oa/export.csv']) {
|
|
await assert.rejects(
|
|
engine.inspect({ path: unsupportedPath }),
|
|
(error) => (
|
|
error?.code === 'UNSUPPORTED_FORMAT' &&
|
|
/Phase 1 只支持 \.xlsx 文件/.test(error.message)
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('Excel analysis keeps source workbooks private and isolated to one user workspace', async () => {
|
|
const userOneRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-user-one-'));
|
|
const userTwoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-user-two-'));
|
|
const userOneWorkbook = await createWorkbook(userOneRoot);
|
|
const userOneEngine = createExcelAnalysisEngine({ workspaceRoot: userOneRoot });
|
|
const userTwoEngine = createExcelAnalysisEngine({ workspaceRoot: userTwoRoot });
|
|
|
|
await userOneEngine.inspect({ path: 'oa/sales.xlsx' });
|
|
assert.equal(fs.existsSync(path.join(userOneRoot, 'public', 'sales.xlsx')), false);
|
|
assert.equal(fs.existsSync(userOneWorkbook), true);
|
|
|
|
await assert.rejects(
|
|
userTwoEngine.inspect({ path: userOneWorkbook }),
|
|
(error) => error?.code === 'INVALID_PATH',
|
|
);
|
|
await assert.rejects(
|
|
userTwoEngine.inspect({ path: 'oa/sales.xlsx' }),
|
|
(error) => error?.code === 'FILE_NOT_FOUND',
|
|
);
|
|
});
|
|
|
|
test('Excel MCP exposes the Phase 1 analysis tools and verified report generator', async (t) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'excel-mcp-'));
|
|
await createWorkbook(root);
|
|
const server = startExcelMcp(root);
|
|
t.after(() => server.child.kill());
|
|
|
|
const initialized = await server.request('initialize');
|
|
assert.equal(initialized.result.serverInfo.name, 'tkmind-excel');
|
|
const listed = await server.request('tools/list');
|
|
assert.equal(initialized.result.serverInfo.version, '0.2.0');
|
|
assert.deepEqual(listed.result.tools.map((item) => item.name), ['excel_inspect', 'excel_analyze', 'excel_chart', 'excel_report']);
|
|
const inspected = await server.request('tools/call', {
|
|
name: 'excel_inspect',
|
|
arguments: { path: 'oa/sales.xlsx', sample_rows: 2 },
|
|
});
|
|
assert.equal(inspected.result.isError, undefined);
|
|
assert.equal(inspected.result.structuredContent.sheets[0].name, '销售明细');
|
|
const reported = await server.request('tools/call', {
|
|
name: 'excel_report',
|
|
arguments: {
|
|
path: 'oa/sales.xlsx',
|
|
sheet: '销售明细',
|
|
metric: '销售额',
|
|
profit_metric: '利润',
|
|
order_id: '订单ID',
|
|
dimensions: ['产品'],
|
|
joins: [{ dimension: '产品', sheet: '产品主数据', key: '产品', fields: ['产品名称', '产品类别'] }],
|
|
output_path: 'public/mcp-sales-report.html',
|
|
},
|
|
});
|
|
assert.equal(reported.result.isError, undefined);
|
|
assert.equal(reported.result.structuredContent.model.validation.valid, true);
|
|
assert.equal(reported.result.structuredContent.model.totals.metric, 360);
|
|
});
|