From 284dc7bc44319c7377ebb63c198d73069303e551 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 16 Jun 2026 14:47:17 -0400 Subject: [PATCH] fix: resume UAT checkpoint from paused placeholder (#1350) --- .changeset/1300-uat-paused-checkpoint.md | 6 ++ src/uat.cts | 64 ++++++++++++++++ tests/uat.test.cjs | 93 ++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 .changeset/1300-uat-paused-checkpoint.md diff --git a/.changeset/1300-uat-paused-checkpoint.md b/.changeset/1300-uat-paused-checkpoint.md new file mode 100644 index 000000000..95c316e56 --- /dev/null +++ b/.changeset/1300-uat-paused-checkpoint.md @@ -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) diff --git a/src/uat.cts b/src/uat.cts index 918948aa8..80d9c7ba2 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -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 { diff --git a/tests/uat.test.cjs b/tests/uat.test.cjs index 3f25601ca..2db573b40 100644 --- a/tests/uat.test.cjs +++ b/tests/uat.test.cjs @@ -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