177 lines
6.2 KiB
TypeScript
177 lines
6.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { parse } from 'yaml';
|
|
import { patchYaml, diff } from './yaml-patch.js';
|
|
|
|
// Representative piece with all the formatting we want to preserve.
|
|
const ORIGINAL = [
|
|
'name: sample',
|
|
'description: |',
|
|
' Multi-line',
|
|
' description.',
|
|
'max_movements: 10',
|
|
'initial_movement: start',
|
|
'',
|
|
'movements:',
|
|
' - name: start',
|
|
' edit: false',
|
|
' persona: analyst',
|
|
' instruction: |',
|
|
' Do the thing.',
|
|
' Keep newlines.',
|
|
' allowed_tools: [Read, Glob]',
|
|
' default_next: execute',
|
|
' rules:',
|
|
' - condition: ok',
|
|
' next: execute',
|
|
'',
|
|
' - name: execute',
|
|
' edit: true',
|
|
' persona: worker',
|
|
' instruction: |',
|
|
' Work work work.',
|
|
' allowed_tools: [Read, Write, Edit]',
|
|
' default_next: verify',
|
|
' rules:',
|
|
' - condition: done',
|
|
' next: verify',
|
|
'',
|
|
' - name: verify',
|
|
' edit: false',
|
|
' persona: reviewer',
|
|
' instruction: |',
|
|
' Review it.',
|
|
' allowed_tools: [Read]',
|
|
' default_next: COMPLETE',
|
|
' rules:',
|
|
' - condition: good',
|
|
' next: COMPLETE',
|
|
'',
|
|
].join('\n');
|
|
|
|
describe('diff', () => {
|
|
it('returns no ops when values are equal', () => {
|
|
expect(diff({ a: 1, b: [1, 2] }, { a: 1, b: [1, 2] })).toEqual([]);
|
|
});
|
|
|
|
it('emits a single set for a changed leaf', () => {
|
|
const ops = diff({ a: 1, b: 2 }, { a: 1, b: 3 });
|
|
expect(ops).toEqual([{ kind: 'set', path: ['b'], value: 3 }]);
|
|
});
|
|
|
|
it('emits a delete for a removed key', () => {
|
|
const ops = diff({ a: 1, b: 2 }, { a: 1 });
|
|
expect(ops).toEqual([{ kind: 'delete', path: ['b'] }]);
|
|
});
|
|
|
|
it('emits a set for a new key', () => {
|
|
const ops = diff({ a: 1 }, { a: 1, b: 2 });
|
|
expect(ops).toEqual([{ kind: 'set', path: ['b'], value: 2 }]);
|
|
});
|
|
|
|
it('recurses element-wise when array lengths match', () => {
|
|
const ops = diff({ xs: [1, 2, 3] }, { xs: [1, 2, 4] });
|
|
expect(ops).toEqual([{ kind: 'set', path: ['xs', 2], value: 4 }]);
|
|
});
|
|
|
|
it('replaces whole array when lengths differ', () => {
|
|
const ops = diff({ xs: [1, 2, 3] }, { xs: [1, 2, 3, 4] });
|
|
expect(ops).toEqual([{ kind: 'set', path: ['xs'], value: [1, 2, 3, 4] }]);
|
|
});
|
|
});
|
|
|
|
describe('patchYaml', () => {
|
|
it('preserves `|` literal block style when editing instruction text', () => {
|
|
const body = parse(ORIGINAL);
|
|
body.movements[0].instruction = 'Do the updated thing.\nStill multiline.\n';
|
|
const patched = patchYaml(ORIGINAL, body);
|
|
expect(patched).toContain('instruction: |');
|
|
expect(patched).not.toMatch(/instruction:\s*>/);
|
|
// round-trips to the intended value
|
|
expect(parse(patched).movements[0].instruction).toBe(
|
|
'Do the updated thing.\nStill multiline.\n',
|
|
);
|
|
});
|
|
|
|
it('does not reformat unrelated inline allowed_tools array', () => {
|
|
const body = parse(ORIGINAL);
|
|
body.movements[0].instruction = 'tweaked';
|
|
const patched = patchYaml(ORIGINAL, body);
|
|
// yaml v2 may normalize bracket spacing (`[x]` -> `[ x ]`), but the key
|
|
// invariant is that flow-style stays flow (no multiline block expansion).
|
|
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Write,\s*Edit\s*\]/);
|
|
expect(patched).toMatch(/allowed_tools: \[\s*Read\s*\]/);
|
|
// And crucially it must NOT have been expanded to block style:
|
|
expect(patched).not.toMatch(/allowed_tools:\s*\n\s*-\s*Read\s*\n\s*-\s*Write/);
|
|
});
|
|
|
|
it('preserves blank lines between movements', () => {
|
|
const body = parse(ORIGINAL);
|
|
body.movements[1].persona = 'coder';
|
|
const patched = patchYaml(ORIGINAL, body);
|
|
// Each movement in the original is separated by a blank line. Check that
|
|
// the sequence `\n\n - name:` still appears between them.
|
|
const blankLineBeforeMovements = patched.match(/\n\n - name:/g);
|
|
expect(blankLineBeforeMovements).not.toBeNull();
|
|
expect(blankLineBeforeMovements!.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
|
|
it('supports adding a brand-new movement', () => {
|
|
const body = parse(ORIGINAL);
|
|
body.movements.push({
|
|
name: 'extra',
|
|
edit: false,
|
|
persona: 'helper',
|
|
instruction: 'added step',
|
|
allowed_tools: ['Read'],
|
|
default_next: 'COMPLETE',
|
|
rules: [{ condition: 'x', next: 'COMPLETE' }],
|
|
});
|
|
const patched = patchYaml(ORIGINAL, body);
|
|
const reparsed = parse(patched);
|
|
expect(reparsed.movements).toHaveLength(4);
|
|
expect(reparsed.movements[3].name).toBe('extra');
|
|
// other movements still have their literal-block instructions intact
|
|
expect(patched).toContain('instruction: |');
|
|
});
|
|
|
|
it('supports deleting a movement', () => {
|
|
const body = parse(ORIGINAL);
|
|
body.movements.splice(1, 1); // drop `execute`
|
|
const patched = patchYaml(ORIGINAL, body);
|
|
const reparsed = parse(patched);
|
|
expect(reparsed.movements).toHaveLength(2);
|
|
expect(reparsed.movements.map((m: any) => m.name)).toEqual(['start', 'verify']);
|
|
});
|
|
|
|
it('renaming default_next is a minimal targeted change', () => {
|
|
const body = parse(ORIGINAL);
|
|
body.movements[0].default_next = 'verify';
|
|
const patched = patchYaml(ORIGINAL, body);
|
|
expect(parse(patched).movements[0].default_next).toBe('verify');
|
|
// untouched: instruction block style, inline arrays, blank lines
|
|
expect(patched).toContain('instruction: |');
|
|
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Glob\s*\]/);
|
|
expect(patched).toMatch(/allowed_tools: \[\s*Read,\s*Write,\s*Edit\s*\]/);
|
|
expect(patched).toMatch(/\n\n - name: execute/);
|
|
});
|
|
|
|
it('falls back to stringify when original yaml is malformed', () => {
|
|
const broken = 'name: x\n bad-indent: [\n';
|
|
const body = { name: 'x', description: 'ok' };
|
|
const out = patchYaml(broken, body);
|
|
// Fallback should still produce parseable output with the new body.
|
|
const parsed = parse(out);
|
|
expect(parsed.name).toBe('x');
|
|
expect(parsed.description).toBe('ok');
|
|
});
|
|
|
|
it('semantic content matches body after patching', () => {
|
|
const body = parse(ORIGINAL);
|
|
body.description = 'new description';
|
|
body.movements[2].instruction = 'reviewed differently';
|
|
body.movements[2].allowed_tools = ['Read', 'Glob'];
|
|
const patched = patchYaml(ORIGINAL, body);
|
|
expect(parse(patched)).toEqual(body);
|
|
});
|
|
});
|