0
0
Fork 0

Merge pull request #6 from phobicdotno/feature/phobics-tui-v0.3-live-values

phobiCS-tui v0.3: inline live values (server pump + TUI overlay)
This commit is contained in:
phobicdotno 2026-04-29 09:59:57 +02:00 committed by GitHub
commit a3caf0dd38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 860 additions and 16 deletions

View file

@ -307,6 +307,15 @@ The Viewer applies ST syntax highlighting (cyan keywords, magenta types, gray co
Approve mode is opt-in for the MCP server's modifying tools — start the server with `--approve-edits` to wire it in. The v0.2 followup gates **all 9 modifying tools**: `create_pou`, `create_property`, `create_method`, `create_dut`, `create_gvl`, `create_folder`, `delete_object`, `rename_object`, `add_library` — plus the original `set_pou_code`. Each operation pops a y/n diff prompt; create/delete render as all-green/all-red one-sided diffs, rename as a del+add of the leaf name, and `set_pou_code` as a real diff against the existing mirror file. Off by default.
### Inline live values (`--live-values`)
When the server is started with `--live-values` and the runtime is online, the Viewer overlays each declared variable's live value next to its declaration:
3 counter : INT := 0; ◀ live: 47
4 bRunning : BOOL; ◀ live: TRUE
The server writes a 500 ms snapshot to `tui-live-values.json` (next to `tui-state.json`); the TUI polls that file and renders an overlay only when the snapshot's `pou_name` matches the user's current selection and the file is fresh (≤ 5 s). v0.3 covers top-level vars on the displayed POU; sub-property paths and ARRAY/STRUCT pretty-printing are deferred. Off by default.
## MCP Tools
41 tools across the categories below. Tools marked **NEW** were added in this fork; tools marked **FIXED** existed upstream but were broken before this fork.

View file

@ -48,6 +48,7 @@ program
.option('--keep-alive', 'Keep CODESYS running after server stops', false)
.option('--auto-mirror', 'Re-run mirror_export after every modifying tool so an external editor watching <projectDir>/mcp-mirror/ sees changes live', false)
.option('--approve-edits', 'Gate modifying MCP tools behind a phobiCS-tui y/n diff prompt', false)
.option('--live-values', 'Pump runtime values for the selected POU into tui-live-values.json so phobiCS-tui can overlay them inline (500ms poll). Requires the runtime to be online; failures are silent.', false)
.option('--timeout <ms>', 'Default command timeout in ms', '60000')
.option('--verbose', 'Enable verbose logging')
.option('--debug', 'Enable debug logging (more verbose)')
@ -199,6 +200,7 @@ if (opts.sshVersion) {
mode: (opts.mode === 'headless' ? 'headless' : 'persistent') as ExecutionMode,
autoMirror: opts.autoMirror || false,
approveEdits: opts.approveEdits || false,
liveValues: opts.liveValues || false,
};
process.stderr.write(`Starting CODESYS MCP Server v${version}\n`);
@ -212,6 +214,9 @@ if (opts.sshVersion) {
if (config.approveEdits) {
process.stderr.write(` Approve edits: ENABLED (modifying tools will prompt via phobiCS-tui)\n`);
}
if (config.liveValues) {
process.stderr.write(` Live values: ENABLED (poll 500ms; writes tui-live-values.json)\n`);
}
startMcpServer(config).catch((err) => {
process.stderr.write(`FATAL: ${err.message}\n`);

153
src/live-values-pump.ts Normal file
View file

@ -0,0 +1,153 @@
import * as fs from 'fs/promises';
import { writeLiveValues, LiveValueSnapshotIn } from './live-values-write';
import { readSelection } from './state-read';
const VAR_OPEN_RE = /^\s*(?:VAR(?:_INPUT|_OUTPUT|_IN_OUT|_GLOBAL|_TEMP|_CONFIG|_EXTERNAL|_STAT)?)\b/i;
const VAR_CLOSE_RE = /^\s*END_VAR\b/i;
const VAR_DECL_RE = /^\s*([A-Za-z_]\w*)\b/;
/**
* Parse all variable names declared in any VAR / VAR_INPUT / VAR_OUTPUT /
* VAR_GLOBAL / etc. block. One name per declaration line; skips lines that
* are entirely inside (* ... *) blocks (joined across lines) or are
* `// ...` comments.
*
* Recognises the IEC declaration grammar enough for typical PLC code:
* `name [AT %loc] : type [:= init];`. Doesn't try to parse multi-name
* shorthand (`a, b : INT;`) that's vanishingly rare in practice and
* the cost of getting it wrong is just a missing overlay.
*/
export function parseVarNames(text: string): string[] {
const names: string[] = [];
const lines = text.split(/\r?\n/);
let inVarBlock = false;
let inMultilineComment = false;
for (let raw of lines) {
// Strip block comments first, threading the open-state across lines.
let scrubbed = '';
let i = 0;
while (i < raw.length) {
if (inMultilineComment) {
const end = raw.indexOf('*)', i);
if (end < 0) {
i = raw.length;
} else {
inMultilineComment = false;
i = end + 2;
}
continue;
}
if (raw[i] === '(' && raw[i + 1] === '*') {
const end = raw.indexOf('*)', i + 2);
if (end < 0) {
inMultilineComment = true;
i = raw.length;
} else {
i = end + 2;
}
continue;
}
scrubbed += raw[i];
i++;
}
// Strip line comments.
const slash = scrubbed.indexOf('//');
if (slash >= 0) scrubbed = scrubbed.slice(0, slash);
if (!inVarBlock) {
if (VAR_OPEN_RE.test(scrubbed)) inVarBlock = true;
continue;
}
if (VAR_CLOSE_RE.test(scrubbed)) {
inVarBlock = false;
continue;
}
const m = VAR_DECL_RE.exec(scrubbed);
if (m) names.push(m[1]);
}
return names;
}
// ─── Pump ────────────────────────────────────────────────────────────────
//
// The full pump (interval + per-var read_variable round-trips) is wired
// up by Task 7 once the server-side plumbing is in place. parseVarNames
// is the only piece we can unit-test in isolation; the rest needs an
// executor + state-read + write integration.
export interface PumpDeps {
/** Read the TUI selection state file. Defaults to readSelection. */
readSelection?: typeof readSelection;
/** Read the POU's mirror file from disk. */
readPouFile?: (absPath: string) => Promise<string>;
/** Read a single PLC variable. Returns its current value as a string. */
readVariable?: (projectFilePath: string, variablePath: string) => Promise<string>;
/** Write the live-values snapshot. Defaults to writeLiveValues. */
writeLiveValues?: typeof writeLiveValues;
}
export interface PumpConfig {
/** Path to tui-state.json. */
stateFilePath: string;
/** Path to tui-live-values.json. */
liveValuesFilePath: string;
/** Poll interval in ms. */
intervalMs: number;
}
export class LiveValuesPump {
private timer: ReturnType<typeof setInterval> | null = null;
private busy = false;
constructor(private cfg: PumpConfig, private deps: Required<PumpDeps>) {}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.tick();
}, this.cfg.intervalMs);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async tick(): Promise<void> {
if (this.busy) return;
this.busy = true;
try {
const sel = await this.deps.readSelection(this.cfg.stateFilePath);
if (sel.status !== 'ok') return;
const pou = sel.payload.selection;
const text = await this.deps.readPouFile(pou.abs_path);
const names = parseVarNames(text);
if (names.length === 0) return;
const values: Record<string, LiveValueSnapshotIn> = {};
const ts = Date.now();
for (const name of names) {
try {
const v = await this.deps.readVariable(
sel.payload.project_dir,
`${pou.name}.${name}`
);
values[name] = { value: v, ts };
} catch {
// single-var failure: skip silently, others may succeed
}
}
await this.deps.writeLiveValues(this.cfg.liveValuesFilePath, sel.payload.project_dir, {
device: sel.payload.device,
pou_name: pou.name,
values,
});
} catch {
// Pump must never crash; swallow.
} finally {
this.busy = false;
}
}
}

41
src/live-values-write.ts Normal file
View file

@ -0,0 +1,41 @@
import * as fs from 'fs/promises';
import * as path from 'path';
export interface LiveValueSnapshotIn {
value: string;
type?: string;
ts: number;
}
export interface LiveValuesPayloadIn {
device: string;
pou_name: string;
values: Record<string, LiveValueSnapshotIn>;
}
/**
* Server-side counterpart to the TUI's readLiveValues.
*
* Wraps the caller's payload in the v1 envelope, writes atomically via
* `<file>.<pid>.tmp` + rename, and creates parent dirs as needed. Mirrors
* src/tui/shared/state-write.ts (the selection writer); kept separate
* because that one's ESM and the server is CJS.
*/
export async function writeLiveValues(
filePath: string,
projectDir: string,
payload: LiveValuesPayloadIn
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const envelope = {
version: 1,
updated_at: new Date().toISOString(),
project_dir: projectDir,
device: payload.device,
pou_name: payload.pou_name,
values: payload.values,
};
const tmp = `${filePath}.${process.pid}.tmp`;
await fs.writeFile(tmp, JSON.stringify(envelope, null, 2), 'utf8');
await fs.rename(tmp, filePath);
}

View file

@ -26,6 +26,8 @@ import { inspectProjectFile } from './inspect';
import { parseProfileName } from './detect';
import { decideOpenProjectPreflight } from './preflight';
import { readSelection } from './state-read';
import { writeLiveValues } from './live-values-write';
import { LiveValuesPump } from './live-values-pump';
import { runApproveGate, gateOpForTool } from './approve-gate';
/**
@ -707,17 +709,25 @@ export async function buildGetUserSelectionResponse(stateFilePath: string) {
};
}
function defaultStateFilePath(): string {
function defaultStateDir(): string {
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp', 'tui-state.json');
return path.join(os.homedir(), 'AppData', 'Local', 'codesys-mcp');
}
return path.join(localAppData, 'codesys-mcp', 'tui-state.json');
return path.join(localAppData, 'codesys-mcp');
}
const xdg = process.env.XDG_STATE_HOME;
const base = xdg ?? path.join(os.homedir(), '.local', 'state');
return path.join(base, 'codesys-mcp', 'tui-state.json');
return path.join(base, 'codesys-mcp');
}
function defaultStateFilePath(): string {
return path.join(defaultStateDir(), 'tui-state.json');
}
function defaultLiveValuesFilePath(): string {
return path.join(defaultStateDir(), 'tui-live-values.json');
}
export async function startMcpServer(config: ServerConfig): Promise<void> {
@ -728,6 +738,7 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
serverLog.info(`Starting CODESYS Persistent MCP Server v0.1.0`);
serverLog.info(`Mode: ${config.mode}`);
serverLog.info(`Approve edits: ${config.approveEdits ? 'ON' : 'off'}`);
serverLog.info(`Live values: ${config.liveValues ? 'ON' : 'off'}`);
serverLog.info(`CODESYS Path: ${config.codesysPath}`);
serverLog.info(`Profile: ${config.profileName}`);
serverLog.info(`Workspace: ${config.workspaceDir}`);
@ -3140,10 +3151,49 @@ export async function startMcpServer(config: ServerConfig): Promise<void> {
server.connect(transport);
serverLog.info('MCP Server connected and listening.');
// ─── Live values pump (opt-in) ───────────────────────────────────────
let liveValuesPump: LiveValuesPump | null = null;
if (config.liveValues) {
liveValuesPump = new LiveValuesPump(
{
stateFilePath: defaultStateFilePath(),
liveValuesFilePath: defaultLiveValuesFilePath(),
intervalMs: 500,
},
{
readSelection,
readPouFile: (absPath) => fs.promises.readFile(absPath, 'utf8'),
readVariable: async (projectFilePath, variablePath) => {
// Reuse the existing read_variable script. Returns the value as
// a string captured from script stdout. Errors throw.
const script = scriptManager.prepareScriptWithHelpers(
'read_variable',
{ PROJECT_FILE_PATH: projectFilePath, VARIABLE_PATH: variablePath },
['ensure_project_open', 'ensure_online_connection']
);
const result = await executor.executeScript(script);
if (!result.success || !result.output.includes('SCRIPT_SUCCESS')) {
throw new Error(result.error || 'read_variable failed');
}
// The script prints `Value: <value>` on the success line.
const m = /Value:\s*(.*)$/m.exec(result.output);
return m ? m[1].trim() : '';
},
writeLiveValues,
}
);
liveValuesPump.start();
serverLog.info('Live-values pump started (500ms)');
}
// ─── Graceful Shutdown ───────────────────────────────────────────────
const shutdown = async () => {
serverLog.info('Shutdown signal received');
if (liveValuesPump) {
liveValuesPump.stop();
}
if (launcher) {
try {
await launcher.shutdown();

View file

@ -2,6 +2,7 @@ import React from 'react';
import { Box, Text } from 'ink';
import { POU } from '../shared/types.js';
import { Token, tokenizeLines, TokenKind } from './highlight.js';
import { LiveValueSnapshot } from '../shared/live-values.js';
const COLORS: Record<TokenKind, string | undefined> = {
keyword: 'cyan',
@ -23,14 +24,38 @@ function HighlightedTokens({ tokens }: { tokens: Token[] }): React.ReactElement
);
}
/**
* Find the first var name on the line that has a live value.
*
* Match rule: scan the raw line for identifiers, return the value of the
* first one that's a key in liveValues. Operates on the original line text
* (not on highlighter tokens) because the highlighter's uppercase-only
* identifier splitter shreds mixed-case names like `bRunning` into
* `b` + `R` + `unning`, which would never match a `bRunning` key.
*/
function findOverlayValue(
line: string,
liveValues: Record<string, LiveValueSnapshot>
): string | null {
const re = /[A-Za-z_][A-Za-z0-9_]*/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
const v = liveValues[m[0]];
if (v) return v.value;
}
return null;
}
export interface ViewerProps {
pou: POU | null;
text: string | null;
scrollTop: number;
visibleRows: number;
/** Optional bare-name → live-value map. When set, lines with a matching var get an inline overlay. */
liveValues?: Record<string, LiveValueSnapshot>;
}
export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): React.ReactElement {
export function Viewer({ pou, text, scrollTop, visibleRows, liveValues }: ViewerProps): React.ReactElement {
if (!pou || text == null) {
return (
<Box flexDirection="column">
@ -48,12 +73,17 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac
<Text bold>
{pou.name}.st ({pou.kind}, {pou.loc} L)
</Text>
{sliceTokens.map((tokens, i) => (
<Text key={scrollTop + i}>
<Text dimColor>{String(scrollTop + i + 1).padStart(4, ' ')} </Text>
<HighlightedTokens tokens={tokens} />
</Text>
))}
{sliceTokens.map((tokens, i) => {
const lineNo = scrollTop + i;
const overlay = liveValues ? findOverlayValue(lines[lineNo] ?? '', liveValues) : null;
return (
<Text key={lineNo}>
<Text dimColor>{String(lineNo + 1).padStart(4, ' ')} </Text>
<HighlightedTokens tokens={tokens} />
{overlay !== null && <Text color="green"> live: {overlay}</Text>}
</Text>
);
})}
</Box>
);
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import { LiveValueSnapshot } from '../shared/live-values.js';
import { readLiveValues } from '../shared/live-values-read.js';
/**
* Poll the live-values file every `intervalMs` and return the values map
* iff the snapshot's pou_name matches the requested `pouName`. Returns
* null when:
* - pouName is null (cursor not on a POU)
* - file is missing / stale / invalid
* - snapshot's pou_name disagrees with the requested pouName
*/
export function useLiveValues(
filePath: string | null,
pouName: string | null,
intervalMs = 500
): Record<string, LiveValueSnapshot> | null {
const [values, setValues] = React.useState<Record<string, LiveValueSnapshot> | null>(null);
React.useEffect(() => {
if (!filePath || !pouName) {
setValues(null);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
const r = await readLiveValues(filePath);
if (cancelled) return;
if (r.status === 'ok' && r.payload.pou_name === pouName) {
setValues(r.payload.values);
} else {
setValues(null);
}
timer = setTimeout(tick, intervalMs);
};
tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [filePath, pouName, intervalMs]);
return values;
}

View file

@ -0,0 +1,38 @@
import * as fs from 'fs/promises';
import { LiveValuesPayload } from './live-values.js';
/**
* Live values stop being interesting fast tighter than the selection
* file's 60s window. If the pump dies or the runtime goes offline, we'd
* rather show no overlay than a frozen value.
*/
export const FRESHNESS_MS = 5_000;
export type ReadResult =
| { status: 'ok'; payload: LiveValuesPayload }
| { status: 'missing' }
| { status: 'stale' }
| { status: 'invalid'; reason: string };
export async function readLiveValues(filePath: string): Promise<ReadResult> {
let text: string;
try {
text = await fs.readFile(filePath, 'utf8');
} catch {
return { status: 'missing' };
}
let parsed: LiveValuesPayload;
try {
parsed = JSON.parse(text);
} catch (err) {
return { status: 'invalid', reason: (err as Error).message };
}
if (parsed.version !== 1) {
return { status: 'invalid', reason: `unsupported version ${parsed.version}` };
}
const ageMs = Date.now() - new Date(parsed.updated_at).getTime();
if (Number.isNaN(ageMs) || ageMs > FRESHNESS_MS) {
return { status: 'stale' };
}
return { status: 'ok', payload: parsed };
}

View file

@ -0,0 +1,31 @@
/**
* Shape of `tui-live-values.json` written by the server's live-values pump.
* The TUI Viewer reads this to overlay live runtime values inline next to
* declared variable names.
*
* One file per machine; keyed by which POU the user is currently looking at
* in `tui-state.json` so the TUI knows whether the values match its current
* view and can ignore stale snapshots from a previous selection.
*/
export interface LiveValuesPayload {
version: 1;
/** ISO 8601 timestamp of the moment the snapshot was written. */
updated_at: string;
/** Project root dir the runtime was queried for. Matches the project_dir field in tui-state.json. */
project_dir: string;
/** Device name the runtime belongs to. Matches the device field in tui-state.json. */
device: string;
/** POU the values were read from. Bare name (no path); matches tui-state.json's selection.name. */
pou_name: string;
/** Variable name -> value snapshot. Bare names (no `PLC_PRG.` prefix). */
values: Record<string, LiveValueSnapshot>;
}
export interface LiveValueSnapshot {
/** Live value as a string (whatever read_variable returned). */
value: string;
/** Optional declared type, populated when the pump can determine it. */
type?: string;
/** Per-value timestamp in ms since epoch. Useful when the snapshot was assembled across several reads. */
ts: number;
}

View file

@ -2,18 +2,31 @@ import * as os from 'os';
import * as path from 'path';
const APP_DIR = 'codesys-mcp';
const FILE_NAME = 'tui-state.json';
const STATE_FILE = 'tui-state.json';
const LIVE_VALUES_FILE = 'tui-live-values.json';
export function stateFilePath(): string {
function resolveStateDir(): string {
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
throw new Error('LOCALAPPDATA is not set; cannot resolve TUI state file path');
}
return path.win32.join(localAppData, APP_DIR, FILE_NAME);
return path.win32.join(localAppData, APP_DIR);
}
const xdg = process.env.XDG_STATE_HOME;
const home = process.env.HOME ?? os.homedir();
const base = xdg ?? path.posix.join(home, '.local', 'state');
return path.posix.join(base, APP_DIR, FILE_NAME);
return path.posix.join(base, APP_DIR);
}
function joinForPlatform(dir: string, file: string): string {
return process.platform === 'win32' ? path.win32.join(dir, file) : path.posix.join(dir, file);
}
export function stateFilePath(): string {
return joinForPlatform(resolveStateDir(), STATE_FILE);
}
export function liveValuesFilePath(): string {
return joinForPlatform(resolveStateDir(), LIVE_VALUES_FILE);
}

View file

@ -73,6 +73,13 @@ export interface ServerConfig extends LauncherConfig {
* so existing scripted flows are not regressed.
*/
approveEdits?: boolean;
/**
* If true, run a background pump that reads runtime values for the
* variables of the user's currently-selected POU and writes them to
* tui-live-values.json so the TUI Viewer can overlay them inline.
* Off by default. 500 ms interval (fixed in v0.3).
*/
liveValues?: boolean;
}
/** Script template parameters */

66
tests/tui/Viewer.test.tsx Normal file
View file

@ -0,0 +1,66 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render } from 'ink-testing-library';
import { Viewer } from '../../src/tui/browser/Viewer.tsx';
import { POU } from '../../src/tui/shared/types.ts';
const pou: POU = {
name: 'PLC_PRG',
kind: 'PRG',
relPath: 'PLC_PRG.st',
absPath: '/abs/PLC_PRG.st',
loc: 4,
mtimeMs: 0,
};
const text = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
' bRunning : BOOL;',
'END_VAR',
].join('\n');
describe('<Viewer>', () => {
it('renders without overlay when liveValues is absent', () => {
const { lastFrame } = render(
<Viewer pou={pou} text={text} scrollTop={0} visibleRows={10} />
);
expect(lastFrame()).not.toContain('live:');
});
it('overlays "◀ live: <val>" on the line whose token matches a key', () => {
const { lastFrame } = render(
<Viewer
pou={pou}
text={text}
scrollTop={0}
visibleRows={10}
liveValues={{
counter: { value: '47', type: 'INT', ts: Date.now() },
bRunning: { value: 'TRUE', type: 'BOOL', ts: Date.now() },
}}
/>
);
const out = lastFrame()!;
expect(out).toMatch(/counter\b.*◀ live: 47/);
expect(out).toMatch(/bRunning\b.*◀ live: TRUE/);
});
it('does not overlay vars that are not in liveValues', () => {
const { lastFrame } = render(
<Viewer
pou={pou}
text={text}
scrollTop={0}
visibleRows={10}
liveValues={{ counter: { value: '47', ts: Date.now() } }}
/>
);
const out = lastFrame()!;
expect(out).toMatch(/counter\b.*◀ live: 47/);
// bRunning line must not gain an overlay
const bRunningLine = out.split('\n').find((l) => l.includes('bRunning'))!;
expect(bRunningLine).not.toContain('live:');
});
});

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { readLiveValues, FRESHNESS_MS } from '../../src/tui/shared/live-values-read.ts';
async function tmpFile(content: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-lv-'));
const f = path.join(dir, 'tui-live-values.json');
await fs.writeFile(f, content, 'utf8');
return f;
}
const fresh = (overrides: Record<string, unknown> = {}) =>
JSON.stringify({
version: 1,
updated_at: new Date().toISOString(),
project_dir: '/p',
device: 'D1',
pou_name: 'PLC_PRG',
values: {
counter: { value: '47', type: 'INT', ts: Date.now() },
},
...overrides,
});
describe('readLiveValues', () => {
it('returns ok with the parsed payload when fresh', async () => {
const f = await tmpFile(fresh());
const r = await readLiveValues(f);
expect(r.status).toBe('ok');
if (r.status === 'ok') {
expect(r.payload.pou_name).toBe('PLC_PRG');
expect(r.payload.values['counter'].value).toBe('47');
}
});
it('returns stale when older than FRESHNESS_MS', async () => {
const old = JSON.stringify({
...JSON.parse(fresh()),
updated_at: new Date(Date.now() - FRESHNESS_MS - 1000).toISOString(),
});
const f = await tmpFile(old);
expect((await readLiveValues(f)).status).toBe('stale');
});
it('returns missing when file does not exist', async () => {
expect((await readLiveValues('/nonexistent.json')).status).toBe('missing');
});
it('returns invalid for malformed JSON', async () => {
const f = await tmpFile('not json');
expect((await readLiveValues(f)).status).toBe('invalid');
});
});

View file

@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'path';
import { stateFilePath } from '../../src/tui/shared/state-paths.ts';
import { stateFilePath, liveValuesFilePath } from '../../src/tui/shared/state-paths.ts';
const ORIG_PLATFORM = Object.getOwnPropertyDescriptor(process, 'platform')!;
const ORIG_ENV = { ...process.env };
@ -49,3 +49,33 @@ describe('stateFilePath', () => {
expect(() => stateFilePath()).toThrow(/LOCALAPPDATA/);
});
});
describe('liveValuesFilePath', () => {
it('uses %LOCALAPPDATA%/codesys-mcp/tui-live-values.json on Windows', () => {
setPlatform('win32');
process.env.LOCALAPPDATA = 'C:\\\\Users\\\\u\\\\AppData\\\\Local';
const p = liveValuesFilePath();
expect(p).toBe(
path.join('C:\\\\Users\\\\u\\\\AppData\\\\Local', 'codesys-mcp', 'tui-live-values.json')
);
});
it('uses $XDG_STATE_HOME/codesys-mcp/tui-live-values.json when set', () => {
setPlatform('linux');
process.env.XDG_STATE_HOME = '/tmp/xdg-state';
expect(liveValuesFilePath()).toBe('/tmp/xdg-state/codesys-mcp/tui-live-values.json');
});
it('falls back to ~/.local/state on Linux without XDG_STATE_HOME', () => {
setPlatform('linux');
delete process.env.XDG_STATE_HOME;
process.env.HOME = '/home/u';
expect(liveValuesFilePath()).toBe('/home/u/.local/state/codesys-mcp/tui-live-values.json');
});
it('throws on Windows when LOCALAPPDATA is unset', () => {
setPlatform('win32');
delete process.env.LOCALAPPDATA;
expect(() => liveValuesFilePath()).toThrow(/LOCALAPPDATA/);
});
});

View file

@ -0,0 +1,58 @@
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { useLiveValues } from '../../src/tui/browser/useLiveValues.tsx';
async function tmpFile(content: string): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'phobics-uv-'));
const f = path.join(dir, 'tui-live-values.json');
await fs.writeFile(f, content, 'utf8');
return f;
}
const fresh = (pouName = 'PLC_PRG') =>
JSON.stringify({
version: 1,
updated_at: new Date().toISOString(),
project_dir: '/p',
device: 'D1',
pou_name: pouName,
values: { counter: { value: '47', ts: Date.now() } },
});
function Probe({ filePath, pouName }: { filePath: string; pouName: string | null }) {
const lv = useLiveValues(filePath, pouName, 50);
return <Text>{lv ? `MATCH:${Object.keys(lv).join(',')}` : 'NULL'}</Text>;
}
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
describe('useLiveValues', () => {
it('returns map when pou_name matches', async () => {
const f = await tmpFile(fresh('PLC_PRG'));
const { lastFrame, unmount } = render(<Probe filePath={f} pouName="PLC_PRG" />);
await wait(120);
expect(lastFrame()).toBe('MATCH:counter');
unmount();
});
it('returns null when pou_name does not match', async () => {
const f = await tmpFile(fresh('FB_Other'));
const { lastFrame, unmount } = render(<Probe filePath={f} pouName="PLC_PRG" />);
await wait(120);
expect(lastFrame()).toBe('NULL');
unmount();
});
it('returns null when pouName is null (cursor not on a POU)', async () => {
const f = await tmpFile(fresh('PLC_PRG'));
const { lastFrame, unmount } = render(<Probe filePath={f} pouName={null} />);
await wait(120);
expect(lastFrame()).toBe('NULL');
unmount();
});
});

View file

@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest';
import { LiveValuesPump } from '../../src/live-values-pump';
const okSelection = (overrides: Record<string, unknown> = {}) => ({
status: 'ok' as const,
payload: {
version: 1 as const,
updated_at: new Date().toISOString(),
project_dir: '/abs/proj',
device: 'D1',
selection: {
kind: 'PRG',
name: 'PLC_PRG',
path: 'PLC_PRG.st',
abs_path: '/abs/PLC_PRG.st',
},
viewer_line: 1,
...overrides,
},
});
const sampleSt = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
' bRunning : BOOL;',
'END_VAR',
].join('\n');
describe('LiveValuesPump.tick', () => {
it('writes a snapshot containing every var read successfully', async () => {
const writeLiveValues = vi.fn(async () => {});
const readVariable = vi.fn(async (_proj: string, varPath: string) => {
if (varPath.endsWith('.counter')) return '47';
if (varPath.endsWith('.bRunning')) return 'TRUE';
throw new Error('not found');
});
const pump = new LiveValuesPump(
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
{
readSelection: vi.fn(async () => okSelection()),
readPouFile: vi.fn(async () => sampleSt),
readVariable,
writeLiveValues,
}
);
await pump.tick();
expect(writeLiveValues).toHaveBeenCalledTimes(1);
const [, projectDir, payload] = writeLiveValues.mock.calls[0];
expect(projectDir).toBe('/abs/proj');
expect(payload.device).toBe('D1');
expect(payload.pou_name).toBe('PLC_PRG');
expect(payload.values.counter.value).toBe('47');
expect(payload.values.bRunning.value).toBe('TRUE');
});
it('skips writing when selection is missing/stale', async () => {
const writeLiveValues = vi.fn(async () => {});
const pump = new LiveValuesPump(
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
{
readSelection: vi.fn(async () => ({ status: 'missing' as const })),
readPouFile: vi.fn(async () => sampleSt),
readVariable: vi.fn(async () => '1'),
writeLiveValues,
}
);
await pump.tick();
expect(writeLiveValues).not.toHaveBeenCalled();
});
it('continues when one read_variable fails (partial write)', async () => {
const writeLiveValues = vi.fn(async () => {});
const pump = new LiveValuesPump(
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
{
readSelection: vi.fn(async () => okSelection()),
readPouFile: vi.fn(async () => sampleSt),
readVariable: vi.fn(async (_p, varPath) => {
if (varPath.endsWith('.counter')) return '47';
throw new Error('boom');
}),
writeLiveValues,
}
);
await pump.tick();
const payload = writeLiveValues.mock.calls[0][2];
expect(payload.values.counter.value).toBe('47');
expect(payload.values.bRunning).toBeUndefined();
});
it('never throws when readPouFile throws', async () => {
const writeLiveValues = vi.fn(async () => {});
const pump = new LiveValuesPump(
{ stateFilePath: '/state.json', liveValuesFilePath: '/lv.json', intervalMs: 500 },
{
readSelection: vi.fn(async () => okSelection()),
readPouFile: vi.fn(async () => { throw new Error('disk gone'); }),
readVariable: vi.fn(async () => '1'),
writeLiveValues,
}
);
await expect(pump.tick()).resolves.toBeUndefined();
expect(writeLiveValues).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { writeLiveValues } from '../../src/live-values-write';
async function tmpDir(): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), 'phobics-lvw-'));
}
const sample = {
device: 'D1',
pou_name: 'PLC_PRG',
values: {
counter: { value: '47', type: 'INT', ts: Date.now() },
},
};
describe('writeLiveValues', () => {
it('writes the v1 envelope with required fields', async () => {
const dir = await tmpDir();
const target = path.join(dir, 'tui-live-values.json');
await writeLiveValues(target, '/abs/project', sample);
const parsed = JSON.parse(await fs.readFile(target, 'utf8'));
expect(parsed.version).toBe(1);
expect(parsed.project_dir).toBe('/abs/project');
expect(parsed.device).toBe('D1');
expect(parsed.pou_name).toBe('PLC_PRG');
expect(parsed.values.counter.value).toBe('47');
expect(typeof parsed.updated_at).toBe('string');
});
it('creates parent dirs as needed', async () => {
const dir = await tmpDir();
const target = path.join(dir, 'a', 'b', 'tui-live-values.json');
await writeLiveValues(target, '/abs/project', sample);
expect((await fs.stat(target)).isFile()).toBe(true);
});
it('does not leave .tmp residue on success', async () => {
const dir = await tmpDir();
const target = path.join(dir, 'tui-live-values.json');
await writeLiveValues(target, '/abs/project', sample);
const entries = await fs.readdir(dir);
expect(entries.filter((e) => e.endsWith('.tmp'))).toEqual([]);
});
});

View file

@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { parseVarNames } from '../../src/live-values-pump';
describe('parseVarNames', () => {
it('extracts vars from a single VAR block', () => {
const text = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
' bRunning : BOOL;',
' rTemperature : REAL;',
'END_VAR',
].join('\n');
expect(parseVarNames(text)).toEqual(['counter', 'bRunning', 'rTemperature']);
});
it('handles VAR_INPUT / VAR_OUTPUT / VAR_GLOBAL too', () => {
const text = [
'FUNCTION_BLOCK FB_X',
'VAR_INPUT',
' x : INT;',
'END_VAR',
'VAR_OUTPUT',
' y : BOOL;',
'END_VAR',
'VAR',
' internal : DINT;',
'END_VAR',
].join('\n');
expect(parseVarNames(text).sort()).toEqual(['internal', 'x', 'y']);
});
it('ignores lines inside (* ... *) blocks and after //', () => {
const text = [
'VAR',
' alpha : INT;',
' (* commentedOut : BOOL; *)',
' beta : INT; // tail comment',
'END_VAR',
].join('\n');
expect(parseVarNames(text)).toEqual(['alpha', 'beta']);
});
it('returns [] when no VAR block is present', () => {
expect(parseVarNames('PROGRAM X\nEND_PROGRAM')).toEqual([]);
});
it('handles AT %X10.0 location prefix and := initializer', () => {
const text = [
'VAR',
' bRelay AT %QX0.1 : BOOL := FALSE;',
' iCount : INT := 42;',
'END_VAR',
].join('\n');
expect(parseVarNames(text)).toEqual(['bRelay', 'iCount']);
});
});