maestro/src/bridge/yaml-patch.test.ts
2026-06-03 05:08:00 +00:00

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);
});
});