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>
86 lines
3.1 KiB
TypeScript
86 lines
3.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import * as path from 'path';
|
|
import { ScriptManager } from '../../src/script-manager';
|
|
|
|
const SCRIPTS_DIR = path.join(__dirname, '..', '..', 'src', 'scripts');
|
|
|
|
describe('ScriptManager', () => {
|
|
const mgr = new ScriptManager(SCRIPTS_DIR);
|
|
|
|
it('loads an existing template', () => {
|
|
const content = mgr.loadTemplate('check_status');
|
|
expect(content).toContain('scriptengine');
|
|
expect(content).toContain('SCRIPT_SUCCESS');
|
|
});
|
|
|
|
it('throws for non-existent template', () => {
|
|
expect(() => mgr.loadTemplate('nonexistent_script')).toThrow(/not found/);
|
|
});
|
|
|
|
it('interpolates a single param', () => {
|
|
const result = mgr.interpolate('hello {FOO}', { FOO: 'bar' });
|
|
expect(result).toBe('hello bar');
|
|
});
|
|
|
|
it('passes backslashes through unchanged (raw string templates)', () => {
|
|
const result = mgr.interpolate('path = r"{PATH}"', {
|
|
PATH: 'C:\\Users\\Test',
|
|
});
|
|
expect(result).toBe('path = r"C:\\Users\\Test"');
|
|
});
|
|
|
|
it('passes triple quotes through unchanged (callers handle escaping)', () => {
|
|
const result = mgr.interpolate('code = """{CODE}"""', {
|
|
CODE: 'a """ b',
|
|
});
|
|
expect(result).toBe('code = """a """ b"""');
|
|
});
|
|
|
|
it('interpolates multiple params', () => {
|
|
const result = mgr.interpolate('{A} and {B}', { A: 'x', B: 'y' });
|
|
expect(result).toBe('x and y');
|
|
});
|
|
|
|
it('two loads return identical content (no cache, fresh file read each call)', () => {
|
|
const first = mgr.loadTemplate('check_status');
|
|
const second = mgr.loadTemplate('check_status');
|
|
expect(first).toEqual(second);
|
|
});
|
|
|
|
it('combineScripts concatenates with double newlines', () => {
|
|
const result = mgr.combineScripts('script1', 'script2', 'script3');
|
|
expect(result).toBe('script1\n\nscript2\n\nscript3');
|
|
});
|
|
|
|
it('prepareScript loads and interpolates', () => {
|
|
// create_project has {PROJECT_FILE_PATH} and {TEMPLATE_PROJECT_PATH} placeholders
|
|
const result = mgr.prepareScript('create_project', {
|
|
PROJECT_FILE_PATH: 'C:\\Projects\\test.project',
|
|
TEMPLATE_PROJECT_PATH: 'C:\\Templates\\Standard.project',
|
|
});
|
|
// Values should appear as-is (no escaping) since templates use r"..." raw strings
|
|
expect(result).toContain('C:\\Projects\\test.project');
|
|
expect(result).toContain('C:\\Templates\\Standard.project');
|
|
});
|
|
|
|
it('prepareScriptWithHelpers prepends helpers', () => {
|
|
const result = mgr.prepareScriptWithHelpers(
|
|
'open_project',
|
|
{ PROJECT_FILE_PATH: 'C:\\test.project' },
|
|
['ensure_project_open']
|
|
);
|
|
// ensure_project_open content should appear before open_project content
|
|
const ensureIdx = result.indexOf('def ensure_project_open');
|
|
const openIdx = result.indexOf('Project Opened');
|
|
expect(ensureIdx).toBeGreaterThan(-1);
|
|
expect(openIdx).toBeGreaterThan(-1);
|
|
expect(ensureIdx).toBeLessThan(openIdx);
|
|
});
|
|
|
|
it('Windows path with spaces passes through correctly', () => {
|
|
const result = mgr.interpolate('path = r"{PATH}"', {
|
|
PATH: 'C:\\Program Files\\CODESYS',
|
|
});
|
|
expect(result).toBe('path = r"C:\\Program Files\\CODESYS"');
|
|
});
|
|
});
|