0
0
Fork 0
Codesys-MCP-SP21-plus/tests/unit/ssh-version.test.ts
Karstein Phobic Nyvold Kvistad 37fc80764e feat(ssh): read_running_version_ssh -- read PLC project version over SSH, no CODESYS needed
New MCP tool + --ssh-version CLI flag. Bypasses the CODESYS IDE
entirely: SSH to a CODESYS Control Linux PLC, sudo strings the boot
application binary, extract the X.Y.Z.W literal of
_MCP_PROJECT_VERSION.sVersion. Filters out 3.5.x.y CODESYS runtime
versions automatically.

Solves the case where the .project file is locked by another CODESYS
instance, or no CODESYS install is reachable, but the PLC is. Read-
only on the PLC (just strings the boot binary).

Requires SSH key auth + passwordless sudo for /usr/bin/strings on
the PLC. Both error paths surface exact-instructions error messages
(PowerShell key install command, sudoers line) instead of opaque
failures.

Smoke-tested against codesys-pi (RPi running CODESYS Control 3.5.22)
with MCPTest2 v1.5.0.0 downloaded -- correctly extracts 1.5.0.0 and
filters out the 3.5.22.0 runtime version literal.
2026-04-27 22:13:29 +02:00

62 lines
2.3 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { parseSshOutput } from '../../src/ssh-version';
describe('parseSshOutput', () => {
it('extracts a single project version and filters out the 3.5.x runtime version', () => {
const r = parseSshOutput('1.5.0.0\n3.5.22.0\n');
expect(r.projectVersion).toBe('1.5.0.0');
expect(r.candidates).toEqual(['1.5.0.0']);
expect(r.runtimeVersionLiterals).toEqual(['3.5.22.0']);
});
it('returns null projectVersion when only 3.5.* runtime literals are present', () => {
const r = parseSshOutput('3.5.22.0\n3.5.22.10\n');
expect(r.projectVersion).toBeNull();
expect(r.candidates).toEqual([]);
expect(r.runtimeVersionLiterals).toEqual(['3.5.22.0', '3.5.22.10']);
});
it('returns null projectVersion when multiple project candidates remain after filtering', () => {
const r = parseSshOutput('1.0.0.0\n2.3.4.5\n3.5.22.0\n');
expect(r.projectVersion).toBeNull();
expect(r.candidates).toEqual(['1.0.0.0', '2.3.4.5']);
expect(r.runtimeVersionLiterals).toEqual(['3.5.22.0']);
});
it('returns all-empty/null on empty input', () => {
const r = parseSshOutput('');
expect(r.projectVersion).toBeNull();
expect(r.candidates).toEqual([]);
expect(r.runtimeVersionLiterals).toEqual([]);
});
it('tolerates leading/trailing whitespace and blank lines', () => {
const r = parseSshOutput('\n 1.5.0.0 \n\n 3.5.22.0\n \n');
expect(r.projectVersion).toBe('1.5.0.0');
expect(r.candidates).toEqual(['1.5.0.0']);
expect(r.runtimeVersionLiterals).toEqual(['3.5.22.0']);
});
it('ignores non-X.Y.Z.W tokens (3-part versions, words, partial numbers)', () => {
const r = parseSshOutput(
[
'1.5.0.0',
'1.2.3', // 3-part, not X.Y.Z.W
'hello world',
'v1.5.0.0', // prefix
'3.5.22.0',
'1.5.0.0.1', // 5-part
].join('\n')
);
expect(r.projectVersion).toBe('1.5.0.0');
expect(r.candidates).toEqual(['1.5.0.0']);
expect(r.runtimeVersionLiterals).toEqual(['3.5.22.0']);
});
it('handles CRLF line endings (Windows-style stdout)', () => {
const r = parseSshOutput('1.5.0.0\r\n3.5.22.0\r\n');
expect(r.projectVersion).toBe('1.5.0.0');
expect(r.candidates).toEqual(['1.5.0.0']);
expect(r.runtimeVersionLiterals).toEqual(['3.5.22.0']);
});
});