0
0
Fork 0
Codesys-MCP-SP21-plus/tests/unit/approve-gate.test.ts
Karstein Phobic Nyvold Kvistad e1966c7e83 feat(approve-gate): wire 9 modifying tools through phobiCS-tui
Generalizes runApproveGate beyond set_pou_code by adding:

- runApproveGateOp({slug, oldText, newText}) — writes synthetic
  before/after files into a tmpdir, spawns 'phobiCS-tui approve' on
  them, cleans up the tmpdir on return. Used for ops that don't have
  a clean existing-file -> proposed-file mapping (create/delete/rename
  /add).

- gateOpForTool({enabled, slug, oldText, newText}) — MCP-tool-shaped
  wrapper. Returns null when the op should proceed (gate disabled,
  accepted, or no-existing); returns a {content, isError} block-
  response otherwise. Lets each tool gate with one if-statement.

Wired into the 9 modifying tools (with --approve-edits on, each one
prompts via the TUI before applying):
  - create_pou         all-green diff: name + type + language + parent
  - create_property    all-green diff: name + type + parent FB
  - create_method      all-green diff: name + return type + parent FB
  - create_dut         all-green diff: name + DUT type + parent
  - create_gvl         all-green diff: name + parent + (optional decl)
  - create_folder      all-green diff: name + parent
  - delete_object      all-red diff: object + project
  - rename_object      del+add: old name -> new name
  - add_library        all-green diff: library name + project

set_pou_code keeps using the existing runApproveGate (which composes
real merged file content via the IMPL_SENTINEL split, giving the
nicest possible diff against the real mirror file).
2026-04-29 08:22:09 +02:00

100 lines
3.4 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import * as fs from 'fs/promises';
import { composeMergedContent, IMPL_SENTINEL, runApproveGateOp } from '../../src/approve-gate';
const existing = [
'PROGRAM PLC_PRG',
'VAR',
' counter : INT := 0;',
'END_VAR',
IMPL_SENTINEL,
'counter := counter + 1;',
].join('\n');
describe('composeMergedContent', () => {
it('overrides only declaration when only declarationCode is provided', () => {
const out = composeMergedContent(existing, {
declarationCode: 'PROGRAM PLC_PRG\nVAR\n counter : DINT := 0;\nEND_VAR',
implementationCode: undefined,
});
expect(out).toContain('counter : DINT := 0;');
expect(out).toContain('counter := counter + 1;');
});
it('overrides only implementation when only implementationCode is provided', () => {
const out = composeMergedContent(existing, {
declarationCode: undefined,
implementationCode: 'counter := counter + 2;',
});
expect(out).toContain('counter : INT := 0;');
expect(out).toContain('counter := counter + 2;');
});
it('overrides both when both are provided', () => {
const out = composeMergedContent(existing, {
declarationCode: 'PROGRAM X\nVAR\nEND_VAR',
implementationCode: 'x := 1;',
});
expect(out).toBe(['PROGRAM X', 'VAR', 'END_VAR', IMPL_SENTINEL, 'x := 1;'].join('\n'));
});
it('handles existing files with no sentinel by appending one', () => {
const noSentinel = 'PROGRAM PLC_PRG\nVAR\nEND_VAR';
const out = composeMergedContent(noSentinel, {
declarationCode: undefined,
implementationCode: 'x := 1;',
});
expect(out).toContain(IMPL_SENTINEL);
expect(out).toContain('x := 1;');
});
});
describe('runApproveGateOp', () => {
it('writes before/after files and spawns the TUI with both paths', async () => {
const captured: { existing?: string; staged?: string } = {};
const spawnFn = vi.fn(async (existingPath: string, stagedPath: string) => {
captured.existing = existingPath;
captured.staged = stagedPath;
const oldText = await fs.readFile(existingPath, 'utf8');
const newText = await fs.readFile(stagedPath, 'utf8');
expect(oldText).toBe('OLD');
expect(newText).toBe('NEW');
return { status: 'accepted' as const };
});
const result = await runApproveGateOp({
slug: 'create-Application/MyFB',
oldText: 'OLD',
newText: 'NEW',
spawnFn,
});
expect(result.status).toBe('accepted');
expect(spawnFn).toHaveBeenCalledOnce();
});
it('passes the rejected status through', async () => {
const spawnFn = vi.fn(async () => ({
status: 'rejected' as const,
message: 'nope',
}));
const result = await runApproveGateOp({
slug: 'delete-Foo',
oldText: 'something',
newText: '',
spawnFn,
});
expect(result.status).toBe('rejected');
});
it('cleans up the temp dir after the spawn returns', async () => {
let dirSeen: string | undefined;
const spawnFn = vi.fn(async (existingPath: string) => {
dirSeen = existingPath.replace(/[\\/][^\\/]+$/, '');
// dir should exist while we're in here
await fs.access(dirSeen);
return { status: 'accepted' as const };
});
await runApproveGateOp({ slug: 'x', oldText: '', newText: 'Y', spawnFn });
// After: the dir must be gone
await expect(fs.access(dirSeen!)).rejects.toThrow();
});
});