fix: resume UAT checkpoint from paused placeholder (#1350)

This commit is contained in:
Tom Boucher
2026-06-16 14:47:17 -04:00
committed by GitHub
parent 9540fe43b9
commit 284dc7bc44
3 changed files with 163 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
type: Fixed
pr: 1350
---
**UAT resume now accepts paused checkpoints** — `uat render-checkpoint` treats a non-structured paused `Current Test` placeholder as a resume signal and derives the checkpoint from the first pending UAT test instead of failing as malformed. (#1300)

View File

@@ -200,6 +200,13 @@ function parseCurrentTest(content: string): CurrentTest {
const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m);
if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) {
if (!numberMatch && !nameMatch && !expectedBlockMatch && !expectedInlineMatch) {
const pendingTest = parseFirstPendingTest(content);
if (pendingTest) {
return pendingTest;
}
error('Current Test section is non-structured and no pending UAT test remains to resume');
}
error('Current Test section is malformed');
}
@@ -222,6 +229,63 @@ function parseCurrentTest(content: string): CurrentTest {
};
}
function parseFirstPendingTest(content: string): CurrentTest | null {
const testsMatch = content.match(/##\s*Tests\s*\n([\s\S]*?)(?=\n##\s|$)/i);
if (!testsMatch) {
return null;
}
const testsSection = testsMatch[1];
const headingPattern = /^###\s*(\d+)\.\s*([^\n]+)\s*$/gm;
const headings: Array<{ index: number; number: number; name: string }> = [];
let headingMatch: RegExpExecArray | null;
while ((headingMatch = headingPattern.exec(testsSection)) !== null) {
headings.push({
index: headingMatch.index,
number: parseInt(headingMatch[1], 10),
name: headingMatch[2].trim(),
});
}
for (let i = 0; i < headings.length; i += 1) {
const current = headings[i];
const next = headings[i + 1];
const block = testsSection.slice(current.index, next ? next.index : undefined);
if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
continue;
}
const expected = parseExpectedFromTestBlock(block);
if (!expected) {
error(`Pending UAT test ${current.number} is missing an expected field`);
}
return {
complete: false,
number: current.number,
name: sanitizeForDisplay(current.name),
expected: sanitizeForDisplay(expected),
};
}
return null;
}
function parseExpectedFromTestBlock(block: string): string | null {
const expectedBlockMatch = block.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m)
|| block.match(/^expected:\s*\|\n([\s\S]+)/m);
if (expectedBlockMatch) {
return expectedBlockMatch[1]
.split('\n')
.map((line: string) => line.replace(/^ {2}/, ''))
.join('\n')
.trim();
}
const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
}
// ─── buildCheckpoint ──────────────────────────────────────────────────────────
function buildCheckpoint(currentTest: { number: number; name: string; expected: string }): string {

View File

@@ -533,6 +533,99 @@ expected: |
assert.ok(result.output.includes('It ends at the section boundary.'));
});
test('resumes paused Current Test placeholder from first pending test (#1300)', () => {
fs.writeFileSync(uatPath, [
'---',
'status: partial',
'phase: 01-test-phase',
'started: 2026-06-15T00:00:00Z',
'updated: 2026-06-15T00:00:00Z',
'---',
'',
'## Current Test',
'',
'[testing paused — 2 items outstanding]',
'',
'## Tests',
'',
'### 1. First test',
'expected: something observable',
'result: pass',
'',
'### 2. Second test',
'expected: another observable thing',
'result: [pending]',
'',
'## Summary',
'',
'total: 2',
'passed: 1',
'issues: 0',
'pending: 1',
'skipped: 0',
'blocked: 0',
'',
'## Gaps',
'',
'[none yet]',
].join('\n'));
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.test_number, 2);
assert.strictEqual(output.test_name, 'Second test');
assert.strictEqual(output.file_path, '.planning/phases/01-test-phase/01-UAT.md');
});
test('raw checkpoint mode accepts paused Current Test placeholder (#1300)', () => {
fs.writeFileSync(uatPath, [
'---',
'status: partial',
'phase: 01-test-phase',
'---',
'',
'## Current Test',
'',
'[testing paused — 1 item outstanding]',
'',
'## Tests',
'',
'### 1. First pending test',
'expected: raw mode checkpoint is available',
'result: [pending]',
].join('\n'));
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
assert.ok(result.output.length > 0, 'raw mode must emit a checkpoint');
});
test('non-structured Current Test with no pending tests reports actionable resume error (#1300)', () => {
fs.writeFileSync(uatPath, [
'---',
'status: partial',
'phase: 01-test-phase',
'---',
'',
'## Current Test',
'',
'[testing paused — 0 items outstanding]',
'',
'## Tests',
'',
'### 1. Already handled test',
'expected: completed behavior',
'result: pass',
].join('\n'));
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
assert.strictEqual(result.success, false, 'Should fail when a paused placeholder has no pending test to resume');
assert.ok(result.error.includes('no pending UAT test remains'));
assert.ok(!result.error.includes('Current Test section is malformed'));
});
test('fails when testing is already complete', () => {
fs.writeFileSync(uatPath, `---
status: complete