0
0
Fork 0
Codesys-MCP-SP21-plus/tests/test-fixes.mjs
Karstein Kvistad 32e612000d fix(create_folder): try project.create_folder(name, SV_POU) first; drop ScriptManager cache
create_folder v2 (positional foldername) returned None silently against
the SP22 Application object -- no exception raised, no folder created.
Investigation showed:
  - ScriptObject.create_folder(foldername) is documented to "create a
    folder in the structured view of the parent node", but on Application
    specifically it's a silent no-op (the structured view isn't pinned to
    POU view there).
  - ScriptProject.create_folder(foldername, structured_view=None) on the
    project itself with explicit SV_POU GUID
    ({21AF5390-2942-461a-BF89-951AAF6999F1}) is the documented and
    reliable pathway -- the resulting folder appears under Application
    in the IDE tree because that's where SV_POU lives.

v3 fix: try strategies in order until one returns non-None:
  (1) primary_project.create_folder(name, SV_POU_GUID) -- new, primary
  (2) parent.create_folder(name) positional -- pre-SP21 path
  (3) parent.create_folder(foldername=name) -- alt keyword
  (4) primary_project.create_folder(name) -- default view
  (5) parent.create_object(typeUuid='85d1215e-...') -- alt factory
  (6) parent.add(script_engine.types.IecFolder, name=name) -- legacy
Each strategy guards on hasattr + return-value-not-None, so a silent
no-op falls through instead of being mistaken for success.

ScriptManager: dropped the in-memory template cache. Each loadTemplate
call now reads the .py from disk fresh. Cost: ~1ms per call vs ~1.5s
of CODESYS execution time -- invisible. Win: edits to dist/scripts/
take effect without an MCP restart, which makes iterating on script-
side fixes (like this very create_folder loop) much faster. Existing
"cache hit" unit test rewritten as "two loads return equal content".

tests/test-fixes.mjs: standalone harness that drives a single persistent
CODESYS through HeadlessExecutor + CodesysLauncher to verify the four
broken-tool fixes end-to-end. Useful for regression-testing without
needing a vsc reboot loop. Currently only smoke-tests
create_folder + compile + cross-project; expand as more fixes need
verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 17:16:44 +02:00

146 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
// Verifies the four broken-tool fixes (commit 2607063) end-to-end.
// Drives a single persistent CODESYS instance, runs each test against
// a *copy* of MCPTest2 so the source binary isn't mutated, and reports
// pass/fail with the relevant slice of script output for inspection.
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { fileURLToPath } from 'url';
import { CodesysLauncher } from '../dist/launcher.js';
import { ScriptManager } from '../dist/script-manager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const MCPTEST2 = '\\\\files\\karstein.kvistad\\Documents\\Claude\\PLC\\MCPTest2\\MCPTest2.project';
const MARINER = '\\\\files\\karstein.kvistad\\Documents\\Claude\\PLC\\mariner40206\\MRCodesysMarinerMK2.6_012.project';
const config = {
codesysPath: 'C:\\Program Files\\CODESYS 3.5.22.10\\CODESYS\\Common\\CODESYS.exe',
profileName: 'CODESYS V3.5 SP22 Patch 1',
};
// Set up a working copy of MCPTest2.
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codesys-mcp-testfixes-'));
const workMCP = path.join(workDir, 'MCPTest2.project');
fs.copyFileSync(MCPTEST2, workMCP);
console.log(`copy: ${workMCP}`);
const sm = new ScriptManager(path.join(repoRoot, 'src', 'scripts'));
const launcher = new CodesysLauncher(config);
console.log('launching persistent CODESYS...');
await launcher.launch();
console.log(' ready.');
const results = [];
async function run(name, scriptArgs, helpers, projectPath) {
const params = { PROJECT_FILE_PATH: projectPath, ...scriptArgs };
const script = sm.prepareScriptWithHelpers(name, params, helpers);
const t0 = process.hrtime.bigint();
const r = await launcher.executeScript(script);
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
const ok = r?.success && (r.output || '').includes('SCRIPT_SUCCESS');
// ScriptManager caches templates after first load, so re-instantiate to
// force fresh reads if we mutate src/scripts/. Not needed here -- we
// only load each name once per test.
return { ok, ms, output: r?.output || '', error: r?.error || '' };
}
// --- TEST 1: create_folder fix ---
console.log('\n[1/3] create_folder fix...');
{
const r = await run('create_folder',
{ FOLDER_NAME: 'Test_Bench_Folder', PARENT_PATH: 'PLCWinNT/Plc Logic/Application' },
['ensure_project_open', 'find_object_by_path'], workMCP);
console.log(` ${r.ms.toFixed(0)} ms ${r.ok ? 'PASS' : 'FAIL'}`);
if (!r.ok) {
console.log(' ---output tail---');
console.log(r.output.slice(-1500).split('\n').map(l => ' ' + l).join('\n'));
}
// Cleanup: delete the folder so MCPTest2 stays unchanged for next runs.
if (r.ok) {
const cleanup = await run('delete_object',
{ OBJECT_PATH: 'PLCWinNT/Plc Logic/Application/Test_Bench_Folder' },
['ensure_project_open', 'find_object_by_path'], workMCP);
console.log(` cleanup delete_object: ${cleanup.ok ? 'OK' : 'FAIL'}`);
}
results.push({ name: 'create_folder', ok: r.ok, ms: r.ms });
}
// --- TEST 2: compile_project + get_compile_messages fix ---
console.log('\n[2/3] compile_project + get_compile_messages fix...');
{
const r = await run('compile_project', {}, ['ensure_project_open'], workMCP);
console.log(` compile_project: ${r.ms.toFixed(0)} ms ${r.ok ? 'PASS' : 'FAIL'}`);
if (!r.ok) {
console.log(' ---output tail---');
console.log(r.output.slice(-1500).split('\n').map(l => ' ' + l).join('\n'));
} else {
// Verify the JSON markers + parseable JSON in the output (this is
// exactly what would have failed pre-fix).
const m = r.output.match(/### COMPILE_MESSAGES_START ###\n([\s\S]*?)\n### COMPILE_MESSAGES_END ###/);
if (!m) {
console.log(' WARN: no markers found in output (still passed -- but JSON emit may be missing)');
} else {
try {
const parsed = JSON.parse(m[1]);
console.log(` json parsed OK: ${parsed.length} messages`);
if (parsed.length > 0) {
console.log(' first message:', JSON.stringify(parsed[0]));
}
} catch (e) {
console.log(` FAIL: json parse error: ${e.message}`);
}
}
}
results.push({ name: 'compile_project', ok: r.ok, ms: r.ms });
const r2 = await run('get_compile_messages', {}, ['ensure_project_open'], workMCP);
console.log(` get_compile_messages: ${r2.ms.toFixed(0)} ms ${r2.ok ? 'PASS' : 'FAIL'}`);
if (!r2.ok) {
console.log(' ---output tail---');
console.log(r2.output.slice(-1500).split('\n').map(l => ' ' + l).join('\n'));
}
results.push({ name: 'get_compile_messages', ok: r2.ok, ms: r2.ms });
}
// --- TEST 3: ensure_project_open cross-project switch ---
console.log('\n[3/3] ensure_project_open cross-project switch fix...');
{
// Currently MCPTest2 working copy is primary. Switch to mariner40206.
// Pre-fix: this would either fail or leave MCPTest2 still primary.
const r = await run('open_project', {}, ['ensure_project_open'], MARINER);
console.log(` open_project(mariner40206): ${r.ms.toFixed(0)} ms ${r.ok ? 'PASS' : 'FAIL'}`);
if (!r.ok) {
console.log(' ---output tail---');
console.log(r.output.slice(-1500).split('\n').map(l => ' ' + l).join('\n'));
}
// Verify: list_project_libraries should show mariner's libs (~64), not MCPTest2's (5).
if (r.ok) {
const verify = await run('list_project_libraries', {}, ['ensure_project_open'], MARINER);
const libCount = (verify.output.match(/(\d+) library reference\(s\)/) || [])[1];
console.log(` verify list_project_libraries: lib count = ${libCount} (expected ~64 for mariner40206)`);
const switchOk = libCount && parseInt(libCount, 10) > 50;
console.log(` cross-project switch verified: ${switchOk ? 'PASS' : 'FAIL'}`);
results.push({ name: 'cross_project_switch', ok: r.ok && switchOk, ms: r.ms });
} else {
results.push({ name: 'cross_project_switch', ok: false, ms: r.ms });
}
}
console.log('\n=== summary ===');
for (const t of results) {
console.log(` ${t.ok ? '✓' : '✗'} ${t.name} (${t.ms.toFixed(0)} ms)`);
}
console.log('\nshutting down...');
await launcher.shutdown();
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
console.log('done.');
const allOk = results.every(r => r.ok);
process.exit(allOk ? 0 : 1);