const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const { stripFencedCode, scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
const { cleanup, createTempDir, readFileNormalized } = require('./helpers.cjs');
const SHIP_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md');
function extractStep(name) {
const content = readFileNormalized(SHIP_MD);
const open = ``;
const start = content.indexOf(open);
assert.notEqual(start, -1, `ship.md must contain a ${name} step`);
const end = content.indexOf('', start);
assert.notEqual(end, -1, `${name} step must close`);
return content.slice(start, end);
}
function extractTrackShippingScript() {
const step = extractStep('track_shipping');
const lines = step.split(/\r?\n/);
const blocks = scanFencedBlocks(lines)
.filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash')
.map((b) => lines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n'));
const match = blocks.find(block => block.includes('mergeStateStatus'));
assert.ok(match, 'track_shipping must contain an executable merge-state bash block');
return match;
}
function runTrackShipping(responses) {
const tmpDir = createTempDir('gsd-ship-note-');
const responsesPath = path.join(tmpDir, 'responses.jsonl');
const ghCallsPath = path.join(tmpDir, 'gh-calls.log');
const gitCallsPath = path.join(tmpDir, 'git-calls.log');
try {
fs.writeFileSync(
responsesPath,
responses.map(response => JSON.stringify(response)).join('\n') + '\n',
'utf8',
);
fs.writeFileSync(ghCallsPath, '', 'utf8');
fs.writeFileSync(gitCallsPath, '', 'utf8');
const preamble = [
'gsd_run() { :; }',
'sleep() { :; }',
'git() {',
' if [ "$1" = "rev-parse" ] && [ "$2" = "HEAD" ]; then',
' printf "%s\\n" "$EXPECTED_HEAD"',
' elif [ "$1" = "log" ]; then',
' printf "[ci skip]\\n"',
' elif [ "$1" = "commit" ]; then',
' printf "commit\\n" >> "$GIT_CALLS"',
' elif [ "$1" = "push" ]; then',
' printf "push\\n" >> "$GIT_CALLS"',
' fi',
'}',
'gh() {',
' _call=$(wc -l < "$GH_CALLS")',
' _call=$((_call + 1))',
' printf "call\\n" >> "$GH_CALLS"',
' sed -n "${_call}p" "$GH_RESPONSES"',
'}',
'jq() {',
' _field="${2#.}"',
' _raw=$(sed -nE \'s/.*"\'"$_field"\'":("[^"]*"|[^,}]*).*/\\1/p\')',
' _raw="${_raw%\\"}"',
' _raw="${_raw#\\"}"',
' printf \'%s\' "$_raw"',
'}',
].join('\n');
const result = spawnSync('bash', ['-c', `${preamble}\n${extractTrackShippingScript()}`], {
cwd: tmpDir,
encoding: 'utf8',
timeout: 10000,
env: {
...process.env,
CURRENT_BRANCH: 'fix/ship-note',
EXPECTED_HEAD: 'current-head',
GH_CALLS: ghCallsPath,
GH_RESPONSES: responsesPath,
GIT_CALLS: gitCallsPath,
PHASE_NUMBER: '1',
PR_NUMBER: '123',
padded_phase: '01',
},
});
assert.strictEqual(result.status, 0, `track_shipping failed: ${result.stderr}`);
return {
ghCalls: fs.readFileSync(ghCallsPath, 'utf8').trim().split(/\r?\n/).filter(Boolean),
gitCalls: fs.readFileSync(gitCallsPath, 'utf8').trim().split(/\r?\n/).filter(Boolean),
};
} finally {
cleanup(tmpDir);
}
}
describe('#2783 ship.md track_shipping self-heals wedged PRs', () => {
const step = extractStep('track_shipping');
test('track_shipping inspects mergeStateStatus post-push', () => {
assert.ok(
/mergeStateStatus/.test(step),
'track_shipping must query mergeStateStatus to detect wedged PRs (#2783)'
);
});
test('track_shipping self-heals BLOCKED PRs by pushing a recovery commit without skip token', () => {
assert.ok(
/BLOCKED/.test(step),
'track_shipping must check for BLOCKED merge state (#2783)'
);
assert.ok(
/trigger CI/.test(step) || /allow-empty/.test(step),
'track_shipping must push a recovery commit to trigger CI when wedged (#2783)'
);
});
test('track_shipping and the following step remain outside balanced code fences', () => {
const content = fs.readFileSync(SHIP_MD, 'utf8');
const stripped = stripFencedCode(content);
assert.strictEqual(stripped.unterminatedFence, false, 'ship.md must not contain an unterminated code fence');
assert.match(
stripped.text,
/[\s\S]*?<\/step>\s*/,
'the track_shipping boundary and following step must remain visible after stripping code fences',
);
});
});
describe('#2783 ship-note recovery decisions use current GitHub state', { skip: process.platform === 'win32' }, () => {
test('ignores a stale BLOCKED response until headRefOid matches the pushed commit', () => {
const result = runTrackShipping([
{ head: 'stale-head', status: 'BLOCKED', checks: 0, review: '' },
{ head: 'current-head', status: 'CLEAN', checks: 0, review: '' },
]);
assert.strictEqual(result.ghCalls.length, 2, 'must poll through the stale PR response');
assert.strictEqual(
result.gitCalls.filter(call => call === 'commit').length,
0,
'must not recover from merge state attached to an older head',
);
});
test('does not recover when review requirements are the BLOCKED reason', () => {
for (const review of ['REVIEW_REQUIRED', 'CHANGES_REQUESTED']) {
const result = runTrackShipping([
{ head: 'current-head', status: 'BLOCKED', checks: 0, review },
]);
assert.strictEqual(
result.gitCalls.filter(call => call === 'commit').length,
0,
`${review} must not create an empty CI-recovery commit`,
);
}
});
test('still recovers a current BLOCKED head with zero checks and no review gate', () => {
const result = runTrackShipping([
{ head: 'current-head', status: 'BLOCKED', checks: 0, review: 'APPROVED' },
]);
assert.strictEqual(
result.gitCalls.filter(call => call === 'commit').length,
1,
'a confirmed skip-token wedge must create exactly one recovery commit',
);
});
test('recovers successfully on the 4th polling attempt', () => {
const result = runTrackShipping([
{ head: 'stale-head', status: 'BLOCKED', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'BLOCKED', checks: 0, review: '' },
]);
assert.strictEqual(result.ghCalls.length, 4, 'must poll exactly 4 times');
assert.strictEqual(
result.gitCalls.filter(call => call === 'commit').length,
1,
'must recover after resolving on the 4th attempt',
);
});
test('recovers successfully on the 5th (final) polling attempt', () => {
const result = runTrackShipping([
{ head: 'stale-head', status: 'BLOCKED', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'BLOCKED', checks: 0, review: '' },
]);
assert.strictEqual(result.ghCalls.length, 5, 'must poll exactly 5 times');
assert.strictEqual(
result.gitCalls.filter(call => call === 'commit').length,
1,
'must recover after resolving on the 5th attempt',
);
});
test('exhausts the polling bound after 5 attempts and warns without recovering (attempt 6)', () => {
const result = runTrackShipping([
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'UNKNOWN', checks: 0, review: '' },
{ head: 'current-head', status: 'BLOCKED', checks: 0, review: '' },
]);
assert.strictEqual(result.ghCalls.length, 5, 'must exhaust after exactly 5 polling attempts');
assert.strictEqual(
result.gitCalls.filter(call => call === 'commit').length,
0,
'must not recover if the status is unresolved when polling exhausts',
);
});
});