diff --git a/README.md b/README.md index fb736cd..ea243a6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/bin.ts b/src/bin.ts index 5381934..a71bdf1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -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 /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 ', '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`); diff --git a/src/live-values-pump.ts b/src/live-values-pump.ts new file mode 100644 index 0000000..437eb23 --- /dev/null +++ b/src/live-values-pump.ts @@ -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; + /** Read a single PLC variable. Returns its current value as a string. */ + readVariable?: (projectFilePath: string, variablePath: string) => Promise; + /** 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 | null = null; + private busy = false; + + constructor(private cfg: PumpConfig, private deps: Required) {} + + 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 { + 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 = {}; + 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; + } + } +} diff --git a/src/live-values-write.ts b/src/live-values-write.ts new file mode 100644 index 0000000..2ce23d5 --- /dev/null +++ b/src/live-values-write.ts @@ -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; +} + +/** + * Server-side counterpart to the TUI's readLiveValues. + * + * Wraps the caller's payload in the v1 envelope, writes atomically via + * `..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 { + 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); +} diff --git a/src/server.ts b/src/server.ts index 6f60249..79a37e2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { @@ -728,6 +738,7 @@ export async function startMcpServer(config: ServerConfig): Promise { 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 { 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: ` 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(); diff --git a/src/tui/browser/Viewer.tsx b/src/tui/browser/Viewer.tsx index 91c837c..8857a63 100644 --- a/src/tui/browser/Viewer.tsx +++ b/src/tui/browser/Viewer.tsx @@ -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 = { 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 | 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; } -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 ( @@ -48,12 +73,17 @@ export function Viewer({ pou, text, scrollTop, visibleRows }: ViewerProps): Reac {pou.name}.st ({pou.kind}, {pou.loc} L) - {sliceTokens.map((tokens, i) => ( - - {String(scrollTop + i + 1).padStart(4, ' ')} - - - ))} + {sliceTokens.map((tokens, i) => { + const lineNo = scrollTop + i; + const overlay = liveValues ? findOverlayValue(lines[lineNo] ?? '', liveValues) : null; + return ( + + {String(lineNo + 1).padStart(4, ' ')} + + {overlay !== null && ◀ live: {overlay}} + + ); + })} ); } diff --git a/src/tui/browser/useLiveValues.tsx b/src/tui/browser/useLiveValues.tsx new file mode 100644 index 0000000..fbe7d1b --- /dev/null +++ b/src/tui/browser/useLiveValues.tsx @@ -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 | null { + const [values, setValues] = React.useState | null>(null); + + React.useEffect(() => { + if (!filePath || !pouName) { + setValues(null); + return; + } + let cancelled = false; + let timer: ReturnType | 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; +} diff --git a/src/tui/shared/live-values-read.ts b/src/tui/shared/live-values-read.ts new file mode 100644 index 0000000..376e902 --- /dev/null +++ b/src/tui/shared/live-values-read.ts @@ -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 { + 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 }; +} diff --git a/src/tui/shared/live-values.ts b/src/tui/shared/live-values.ts new file mode 100644 index 0000000..d71813b --- /dev/null +++ b/src/tui/shared/live-values.ts @@ -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; +} + +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; +} diff --git a/src/tui/shared/state-paths.ts b/src/tui/shared/state-paths.ts index e7e0e6c..fcdde6a 100644 --- a/src/tui/shared/state-paths.ts +++ b/src/tui/shared/state-paths.ts @@ -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); } diff --git a/src/types.ts b/src/types.ts index a7506b9..f7fec8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 */ diff --git a/tests/tui/Viewer.test.tsx b/tests/tui/Viewer.test.tsx new file mode 100644 index 0000000..a46f9e1 --- /dev/null +++ b/tests/tui/Viewer.test.tsx @@ -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('', () => { + it('renders without overlay when liveValues is absent', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).not.toContain('live:'); + }); + + it('overlays "◀ live: " on the line whose token matches a key', () => { + const { lastFrame } = render( + + ); + 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( + + ); + 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:'); + }); +}); diff --git a/tests/tui/live-values-read.test.ts b/tests/tui/live-values-read.test.ts new file mode 100644 index 0000000..154ed92 --- /dev/null +++ b/tests/tui/live-values-read.test.ts @@ -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 { + 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 = {}) => + 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'); + }); +}); diff --git a/tests/tui/state-paths.test.ts b/tests/tui/state-paths.test.ts index 4df3849..7b4c2f3 100644 --- a/tests/tui/state-paths.test.ts +++ b/tests/tui/state-paths.test.ts @@ -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/); + }); +}); diff --git a/tests/tui/useLiveValues.test.tsx b/tests/tui/useLiveValues.test.tsx new file mode 100644 index 0000000..c331b52 --- /dev/null +++ b/tests/tui/useLiveValues.test.tsx @@ -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 { + 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 {lv ? `MATCH:${Object.keys(lv).join(',')}` : 'NULL'}; +} + +const wait = (ms: number) => new Promise((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(); + 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(); + 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(); + await wait(120); + expect(lastFrame()).toBe('NULL'); + unmount(); + }); +}); diff --git a/tests/unit/live-values-pump.test.ts b/tests/unit/live-values-pump.test.ts new file mode 100644 index 0000000..5d9b319 --- /dev/null +++ b/tests/unit/live-values-pump.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LiveValuesPump } from '../../src/live-values-pump'; + +const okSelection = (overrides: Record = {}) => ({ + 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(); + }); +}); diff --git a/tests/unit/live-values-write.test.ts b/tests/unit/live-values-write.test.ts new file mode 100644 index 0000000..db630f1 --- /dev/null +++ b/tests/unit/live-values-write.test.ts @@ -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 { + 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([]); + }); +}); diff --git a/tests/unit/var-block-parse.test.ts b/tests/unit/var-block-parse.test.ts new file mode 100644 index 0000000..2f59819 --- /dev/null +++ b/tests/unit/var-block-parse.test.ts @@ -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']); + }); +});