voyage/tests/validators/progress-validator.test.mjs
Kjell Tore Guttormsen 8a56187341
fix(validators): the progress validator knows stopped and blocked
/trekexecute writes run status stopped (dirty-tree stop, entry-condition stop, escalation) and step status blocked (Step 0 sandbox sentinel); the validator rejected the first and warned on the second. Both are real states lib/util/session-orders.mjs already reads (ENDED_BAD), so the validator learns them. The step-status list is exported as PROGRESS_STEP_STATUSES (was an inline literal) so tests check prose against it instead of keeping a copy. docs/HANDOVER-CONTRACTS.md Handover 5 rows updated.

Red: 3 of 4 new tests fail on the pre-fix code (stopped run status, PROGRESS_STEP_STATUSES export, blocked step status). Regression guard (green on arrival): every run status but completed stays resumable.
progress-validator 16/16. Suite 1368 (1366/0/2).

Voyage order 20261005T104513Z-1250514346, plan step 1/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:32:54 +02:00

158 lines
5.9 KiB
JavaScript

import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import * as progressValidator from '../../lib/validators/progress-validator.mjs';
const { validateProgressObject, checkResumeReadiness } = progressValidator;
function goodProgress() {
return {
schema_version: '1',
plan: '.claude/projects/x/plan.md',
plan_type: 'plan',
plan_version: '1.7',
started_at: '2026-04-18T12:00:00Z',
updated_at: '2026-04-18T13:00:00Z',
mode: 'execute',
total_steps: 2,
current_step: 1,
status: 'in_progress',
steps: {
'1': { status: 'completed', attempts: 1, error: null, completed_at: '2026-04-18T12:30:00Z', commit: 'abc123', manifest_audit: 'pass' },
'2': { status: 'pending', attempts: 0, error: null, completed_at: null, commit: null, manifest_audit: null },
},
};
}
test('validateProgress — happy path', () => {
const r = validateProgressObject(goodProgress());
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateProgress — wrong schema_version', () => {
const p = goodProgress();
p.schema_version = '2';
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_SCHEMA_MISMATCH'));
});
test('validateProgress — missing required field', () => {
const p = goodProgress();
delete p.total_steps;
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_MISSING_FIELD' && /total_steps/.test(e.message)));
});
test('validateProgress — bad status', () => {
const p = goodProgress();
p.status = 'maybe';
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_BAD_STATUS'));
});
test('validateProgress — current_step out of range', () => {
const p = goodProgress();
p.current_step = 99;
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_STEP_RANGE'));
});
test('validateProgress — step count mismatch is warning', () => {
const p = goodProgress();
p.total_steps = 5;
const r = validateProgressObject(p);
assert.ok(r.warnings.find(w => w.code === 'PROGRESS_STEP_COUNT_MISMATCH'));
});
test('validateProgress — iterations_remaining non-negative integer is valid', () => {
const p = goodProgress();
p.iterations_remaining = 7;
const r = validateProgressObject(p);
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateProgress — iterations_remaining absent is valid (backward-compat)', () => {
const p = goodProgress();
delete p.iterations_remaining;
const r = validateProgressObject(p);
assert.equal(r.valid, true, JSON.stringify(r.errors));
});
test('validateProgress — iterations_remaining negative is invalid', () => {
const p = goodProgress();
p.iterations_remaining = -1;
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_ITERATIONS_REMAINING_INVALID'));
});
test('validateProgress — iterations_remaining non-integer is invalid', () => {
const p = goodProgress();
p.iterations_remaining = 3.5;
const r = validateProgressObject(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_ITERATIONS_REMAINING_INVALID'));
});
test('checkResumeReadiness — completed run cannot resume', () => {
const p = goodProgress();
p.status = 'completed';
const r = checkResumeReadiness(p);
assert.equal(r.valid, false);
assert.ok(r.errors.find(e => e.code === 'PROGRESS_ALREADY_DONE'));
});
test('checkResumeReadiness — in-progress is resumable', () => {
const r = checkResumeReadiness(goodProgress());
assert.equal(r.valid, true);
});
// /trekexecute writes run status `stopped` (dirty-tree stop, entry-condition stop,
// escalation) and step status `blocked` (Step 0 sandbox sentinel). Both are real
// states other readers already know (lib/util/session-orders.mjs ENDED_BAD), so the
// validator learns them rather than the prose mapping them away.
test('validateProgress — stopped is a valid run status', () => {
const p = goodProgress();
p.status = 'stopped';
const r = validateProgressObject(p);
assert.deepEqual(r.errors, [], 'a stopped run (escalation, dirty tree, entry condition) must validate');
});
test('PROGRESS_STEP_STATUSES is exported and is the list the validator accepts', () => {
const { PROGRESS_STEP_STATUSES } = progressValidator;
assert.ok(Array.isArray(PROGRESS_STEP_STATUSES), 'the step-status list must be exported, not an inline literal');
for (const status of PROGRESS_STEP_STATUSES) {
const p = goodProgress();
p.steps['1'].status = status;
const r = validateProgressObject(p);
assert.equal(r.warnings.find((w) => w.code === 'PROGRESS_STEP_BAD_STATUS'), undefined, `step status ${status}`);
}
for (const status of ['passed', 'running']) {
const p = goodProgress();
p.steps['1'].status = status;
const r = validateProgressObject(p);
assert.ok(r.warnings.find((w) => w.code === 'PROGRESS_STEP_BAD_STATUS'), `${status} is not a stored step status`);
}
});
test('validateProgress — blocked is a valid step status (Step 0 sandbox sentinel)', () => {
const p = goodProgress();
p.steps['1'].status = 'blocked';
const r = validateProgressObject(p);
assert.deepEqual(r.warnings, [], 'the sandbox sentinel writes step status blocked');
});
// Regression guard (passes before this change): only `completed` refuses a resume.
test('checkResumeReadiness — every run status but completed is resumable', () => {
for (const status of ['stopped', 'failed', 'partial', 'in_progress']) {
const p = goodProgress();
p.status = status;
assert.equal(checkResumeReadiness(p).valid, true, `${status} must stay resumable`);
}
const done = goodProgress();
done.status = 'completed';
assert.equal(checkResumeReadiness(done).valid, false);
});