From b6e9790294c4a31f7169fd32c396eb13b5591205 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 06:02:05 +0000 Subject: [PATCH] test: land, fragment and Version Gate cases from /ship's coverage audit Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/changelog-fragments.test.ts | 41 +++++++ test/ship-queue-land.test.ts | 166 +++++++++++++++++++++++++++- test/version-gate-fragments.test.ts | 16 +++ 3 files changed, 222 insertions(+), 1 deletion(-) diff --git a/test/changelog-fragments.test.ts b/test/changelog-fragments.test.ts index 37aa89e4f..dab553f81 100644 --- a/test/changelog-fragments.test.ts +++ b/test/changelog-fragments.test.ts @@ -136,3 +136,44 @@ describe('restamp folds fragments', () => { expect(readFile(repo, 'VERSION')).toBe('1.2.3.0\n'); }); }); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1). +describe('readFragments against landed base fragments', () => { + // Value: protects=a landed fragment is skipped when unchanged and refused when the branch edits it, and an oversized or + // gitlink fragment refuses; fails_when=the same-name/other-bytes check, the size cap or the mode check is dropped; + // why_new=the existing reader test has no fragment on the base, no oversize and no gitlink; seam=none; no_claim=none beyond protects + test('an unchanged base fragment is skipped; an edited base fragment, an oversized one and a gitlink refuse', () => { + const repo = fixture(); + repo.git('checkout', '-q', '--detach', 'origin/main'); + fs.mkdirSync(path.join(repo.work, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, 'changelog.d', 'keep.md'), '- landed keep\n'); + fs.writeFileSync(path.join(repo.work, 'changelog.d', 'old.md'), '- landed old\n'); + repo.git('add', '-A'); repo.git('commit', '-q', '-m', 'landed fragments'); repo.git('push', '-q', 'origin', 'HEAD:main'); + fragmentPr(repo, 'feat-e', { 'old.md': '- rewritten old\n', 'big.md': '- ' + 'x'.repeat(70 * 1024) + '\n', 'new.md': '- new\n' }); + repo.git('update-index', '--add', '--cacheinfo', '160000,' + repo.git('rev-parse', 'HEAD') + ',changelog.d/mod.md'); + repo.git('commit', '-q', '-m', 'gitlink fragment'); + const set = readFragments(repo.work, 'HEAD', 'changelog.d', 'origin/main'); + expect(set.fragments.map(f => f.path)).toEqual(['changelog.d/new.md']); + expect(set.errors.map(e => e.path)).toEqual(['changelog.d/big.md', 'changelog.d/mod.md', 'changelog.d/old.md']); + expect(set.errors.find(e => e.path === 'changelog.d/old.md')!.message).toContain('already exists on the base with other text'); + expect(set.errors.find(e => e.path === 'changelog.d/big.md')!.message).toContain('larger than 65536 bytes'); + expect(set.errors.find(e => e.path === 'changelog.d/mod.md')!.message).toContain('(gitlink)'); + }); +}); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 2). +describe('restamp: an explicit --bump', () => { + // Value: protects=an explicit --bump is the caller's decision and wins over fragment front matter, while the fold still happens; + // fails_when=restamp applies effectiveBump even when --bump is passed; why_new=every fragment restamp test omits --bump; + // seam=none; no_claim=none beyond protects + test('--bump patch with a "bump: major" fragment stamps the next patch and still folds the fragment', () => { + const repo = fixture(); + fragmentPr(repo, 'feat-d', { 'feat-d.md': '---\nbump: major\n---\n- feat-d\n' }); + const r = restamp(repo, ['--next', '--bump', 'patch']); + expect(r.err).toBe(''); + expect(r.out).toContain('STAMP_VERSION: 1.2.4.0'); + expect(r.out).toContain('folded changelog.d/feat-d.md'); + expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n'); + expect(fs.existsSync(path.join(repo.work, 'changelog.d', 'feat-d.md'))).toBe(false); + }); +}); diff --git a/test/ship-queue-land.test.ts b/test/ship-queue-land.test.ts index 3773239ec..68d8b70bd 100644 --- a/test/ship-queue-land.test.ts +++ b/test/ship-queue-land.test.ts @@ -7,6 +7,7 @@ * receipt and drift after the merge. */ import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; @@ -37,12 +38,13 @@ function setup(policy: Record = POLICY): Fx { "if(a[0]==='pr'&&a[1]==='view'){const p=d.prs[a[2]];if(!p){console.error('no pr');process.exit(1)}", " let head='';try{head=g('rev-parse','refs/heads/'+p.headRefName)}catch{};", " console.log(JSON.stringify({...p,headRefOid:head}));process.exit(0)}", - "if(a[0]==='pr'&&a[1]==='list'){console.log(JSON.stringify(Object.entries(d.prs).filter(([,p])=>p.state==='OPEN').map(([n,p])=>({number:Number(n),headRefOid:g('rev-parse','refs/heads/'+p.headRefName),isCrossRepository:false}))));process.exit(0)}", + "if(a[0]==='pr'&&a[1]==='list'){console.log(JSON.stringify(Object.entries(d.prs).filter(([,p])=>p.state==='OPEN').map(([n,p])=>({number:Number(n),headRefOid:g('rev-parse','refs/heads/'+p.headRefName),isCrossRepository:!!p.isCrossRepository}))));process.exit(0)}", "if(a[0]==='api'){const m=/commits\\/([0-9a-f]+)\\/(check-runs|status)/.exec(a[1]);if(!m)process.exit(1);", " if(m[2]==='status'){console.log(JSON.stringify({total_count:0,statuses:[]}));process.exit(0)}", " const red=d.red.includes(m[1]);console.log(JSON.stringify({total_count:1,check_runs:[{id:1,name:'free-tests',status:'completed',conclusion:red?'failure':'success',completed_at:'2026-10-29T00:00:00Z',app:{id:1}}]}));process.exit(0)}", "if(a[0]==='pr'&&a[1]==='merge'){const p=d.prs[a[2]];const want=a[a.indexOf('--match-head-commit')+1];const head=g('rev-parse','refs/heads/'+p.headRefName);", " if(head!==want){console.error('Head branch was modified');process.exit(1)}", + " if(d.mergeRefuse){console.error('Pull request is not mergeable: the base branch policy prohibits the merge');process.exit(1)}", " if(d.driftOnMerge){const m=g('rev-parse','refs/heads/main');const t=g('rev-parse',m+'^{tree}');g('update-ref','refs/heads/main',g('commit-tree',t,'-p',m,'-m','outside merge'));d.driftOnMerge=false}", " const main=g('rev-parse','refs/heads/main');const sq=g('commit-tree',g('rev-parse',want+'^{tree}'),'-p',main,'-m','squash #'+a[2]);g('update-ref','refs/heads/main',sq);p.state='MERGED';save();process.exit(0)}", "if(a[0]==='pr'&&a[1]==='create'){const n=String(Math.max(0,...Object.keys(d.prs).map(Number))+1);d.prs[n]={state:'OPEN',headRefName:a[a.indexOf('--head')+1],isCrossRepository:false,title:a[a.indexOf('--title')+1],body:''};save();console.log('https://github.com/o/r/pull/'+n);process.exit(0)}", @@ -293,3 +295,165 @@ describe('resolveReleaseConflicts', () => { expect(resolveReleaseConflicts(repo.work, ['package.json'], policy, 'VERSION')).toEqual(['package.json']); }, 90_000); }); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1). +describe('land: dry run and refusals before the lock', () => { + const lockRef = (fx: Fx) => fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land'); + const journal = (fx: Fx, pr: string) => path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', pr + '.json'); + + // Value: protects=land --dry-run prints the stamp it would make and writes nothing: no lock ref, no push, no journal; + // fails_when=dry-run falls through to the lock/push path or saves a journal; why_new=no land test passes --dry-run; + // seam=none; no_claim=gh shim stands in for GitHub (fixture repos only) + test('--dry-run prints the stamp version and tree but takes no lock, pushes nothing and saves no journal', () => { + const fx = setup(); + const pin = openPr(fx, '11', { fragment: '---\nbump: minor\n---\n- dry #11\n' }); + const r = land(fx, ['--pr', '11', '--dry-run']); + expect(r.err).toBe(''); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('STAMP_VERSION: 1.3.0.0'); + expect(r.out).toMatch(/^STAMP_TREE: [0-9a-f]{40}$/m); + expect(r.out).toContain('DRY-RUN: nothing locked, pushed or merged'); + expect(remote(fx, 'pr-11')).toBe(pin); + expect(lockRef(fx)).toBe(''); + expect(fs.existsSync(journal(fx, '11'))).toBe(false); + }, 90_000); + + // Value: protects=land refuses a red pin, a fork head and a closed PR before it locks or pushes anything; + // fails_when=the pin CI check, the isCrossRepository guard or the OPEN-state guard is dropped or moved after the lock; + // why_new=existing land tests only use green, open, same-repo PRs; seam=none; no_claim=GitHub check semantics (gh shim) + test('a red pin, a fork head and a closed PR refuse with no lock taken and the branch untouched', () => { + const fx = setup(); + const pin = openPr(fx, '12'); + fx.write(d => { d.red = [pin]; }); + const red = land(fx, ['--pr', '12']); + expect(red.err).toMatch(/required checks are not green at the pin .*\(QUEUE_NOT_READY\)/); + fx.write(d => { d.red = []; d.prs['12'].isCrossRepository = true; }); + expect(land(fx, ['--pr', '12']).err).toContain('(QUEUE_TRAIN_UNTRUSTED)'); + fx.write(d => { d.prs['12'].isCrossRepository = false; d.prs['12'].state = 'CLOSED'; }); + expect(land(fx, ['--pr', '12']).err).toMatch(/#12 is CLOSED.*\(QUEUE_NOT_READY\)/); + expect(remote(fx, 'pr-12')).toBe(pin); + expect(lockRef(fx)).toBe(''); + }, 90_000); + + // Value: protects=--break-lock is refused while another holder's lock is in phase pushing and under 10 minutes old, + // and allowed once that push lock is stale; fails_when=the PUSH_STALE_MS guard is removed or inverted; + // why_new=the existing break-lock test only breaks a waiting lock; seam=none; no_claim=a real concurrent lander (lock ref crafted) + test('--break-lock refuses a fresh mid-push lock and breaks a stale one', () => { + const fx = setup(); + const pin = openPr(fx, '13'); + const pushLock = (startedAt: string) => { + const sh = (args: string[], input?: string) => { + const r = spawnSync('git', args, { cwd: fx.repo.work, input, encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + return r.stdout.trim(); + }; + const blob = sh(['hash-object', '-w', '--stdin'], JSON.stringify({ pr: '99', holder: 'other:1', started_at: startedAt, phase: 'pushing', journal: '.gstack/tmp/ship-queue/land/99.json' })); + const tree = sh(['mktree'], '100644 blob ' + blob + '\tlock.json\n'); + const commit = sh(['commit-tree', tree, '-m', 'land lock #99 pushing']); + sh(['push', '-q', '-f', 'origin', commit + ':refs/notes/gstack-land']); + }; + pushLock(new Date().toISOString()); + const fresh = land(fx, ['--pr', '13', '--break-lock']); + expect(fresh.err).toMatch(/#99's holder is mid-push .*--break-lock refused.*\(QUEUE_LAND_LOCKED\)/); + expect(remote(fx, 'pr-13')).toBe(pin); + pushLock(new Date(Date.now() - 60 * 60_000).toISOString()); + const stale = land(fx, ['--pr', '13', '--break-lock']); + expect(stale.err).toBe(''); + expect(stale.out).toContain("LOCK: broke #99's lock (other:1, phase pushing)"); + expect(remote(fx, 'pr-13')).not.toBe(pin); + }, 90_000); +}); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 2). +describe('land: failures after the push, repair branches and census rows', () => { + const journal = (fx: Fx, pr: string) => JSON.parse(fs.readFileSync(path.join(fx.repo.work, '.gstack', 'tmp', 'ship-queue', 'land', pr + '.json'), 'utf8')); + const pushMain = (fx: Fx, msg: string, edit: (work: string) => void) => { + fx.repo.git('fetch', '-q', 'origin'); fx.repo.git('checkout', '-q', '--detach', 'origin/main'); + edit(fx.repo.work); + fx.repo.git('add', '-A'); fx.repo.git('commit', '-q', '-m', msg); fx.repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + }; + + // Value: protects=a red stamp commit stops land with QUEUE_NOT_READY and a refused gh pr merge stops it too, both keeping + // the journal at phase pushed so a rerun resumes and lands; fails_when=land merges on red, or a failure resets or loses the journal; + // why_new=the shim never reds a stamp commit or refuses a merge; seam=none; no_claim=GitHub check and merge semantics (gh shim) + test('a red stamp commit and a refused merge both stop with the journal at pushed, and a rerun lands; a non-numeric --pr is a usage error', () => { + const fx = setup(); + openPr(fx, '14'); + expect(land(fx, ['--pr', '14']).status).toBe(EXIT.ok); + const stamp = remote(fx, 'pr-14'); + fx.write(d => { d.red = [stamp]; }); + const red = land(fx, ['--pr', '14']); + expect(red.err).toMatch(/a check failed on the stamp commit .*\(QUEUE_NOT_READY\)/); + expect(red.out).toMatch(/^CI: red at /m); + expect(journal(fx, '14')).toMatchObject({ phase: 'pushed', stamp_commit: stamp }); + fx.write(d => { d.red = []; d.mergeRefuse = true; }); + const refused = land(fx, ['--pr', '14']); + expect(refused.err).toMatch(/gh pr merge refused: Pull request is not mergeable.*\(QUEUE_NOT_READY\)/); + expect(journal(fx, '14')).toMatchObject({ phase: 'pushed', stamp_commit: stamp }); + expect(remote(fx, 'pr-14')).toBe(stamp); + fx.write(d => { d.mergeRefuse = false; }); + const done = land(fx, ['--pr', '14']); + expect(done.err).toBe(''); + expect(done.out).toContain('MERGED: #14'); + expect(journal(fx, '14').phase).toBe('landed'); + const usage = runBin(fx.repo, 'gstack-ship-queue', ['land', '--pr', 'x14', '--base', 'main'], fx.env); + expect(usage.status).toBe(EXIT.usage); + expect(usage.err).toContain('land needs --pr (or --census)'); + }, 90_000); + + // Value: protects=--repair refuses without a drift record and with fragments off, moves fragments the outside merge left + // on the base to repair--.md, and a second --repair reports the open PR instead of opening another; fails_when=repair + // opens duplicate PRs, drops a stray fragment, or runs without a drift record; why_new=the drift test covers only the no-stray path; + // seam=none; no_claim=gh pr create (gh shim) + test('--repair: no drift record and fragments off refuse; stray base fragments move; a second run reports the open PR', () => { + const fx = setup(); + openPr(fx, '15'); + expect(land(fx, ['--pr', '15', '--repair']).err).toMatch(/has no drift record; --repair runs only after QUEUE_LAND_DRIFT.*\(QUEUE_NOT_READY\)/); + expect(land(fx, ['--pr', '15']).status).toBe(EXIT.ok); + fx.write(d => { d.driftOnMerge = true; }); + expect(land(fx, ['--pr', '15']).err).toContain('(QUEUE_LAND_DRIFT)'); + pushMain(fx, 'fragments off', w => fs.writeFileSync(path.join(w, '.gstack', 'ship-policy.json'), JSON.stringify({ ...POLICY, changelog_fragments: null }, null, 2) + '\n')); + expect(land(fx, ['--pr', '15', '--repair']).err).toMatch(/changelog_fragments is off.*\(QUEUE_NOT_READY\)/); + pushMain(fx, 'fragments on, outside fragment', w => { + fs.writeFileSync(path.join(w, '.gstack', 'ship-policy.json'), JSON.stringify(POLICY, null, 2) + '\n'); + fs.mkdirSync(path.join(w, 'changelog.d'), { recursive: true }); + fs.writeFileSync(path.join(w, 'changelog.d', 'stray.md'), '- outside stray\n'); + }); + const rep = land(fx, ['--pr', '15', '--repair']); + expect(rep.err).toBe(''); + expect(rep.out).toContain('moved: changelog.d/stray.md (left unfolded by the outside merge)'); + expect(rep.out).not.toContain('entry: changelog.d/repair-15.md'); + const m = /REPAIR_PR: #(\d+) gstack-land-repair\/15 ([0-9a-f]{40})/.exec(rep.out)!; + expect(fx.repo.git('ls-tree', '-r', '--name-only', m[2]!, '--', 'changelog.d')).toBe('changelog.d/repair-15-stray.md'); + expect(show(fx, m[2] + ':changelog.d/repair-15-stray.md')).toBe('- outside stray'); + const again = land(fx, ['--pr', '15', '--repair']); + expect(again.out).toContain('REPAIR_PR: #' + m[1] + ' (already open)'); + expect(Object.keys(JSON.parse(fs.readFileSync(fx.data, 'utf8')).prs)).toEqual(['15', m[1]]); + }, 90_000); + + // Value: protects=census reports a fork, a product conflict, an invalid fragment and a legacy [Unreleased] PR with a tentative + // version as distinct rows and writes nothing; fails_when=a row is misclassified (e.g. a product conflict shown as foldable); + // why_new=the census test covers only fold and no-entry rows; seam=none; no_claim=gh pr list (gh shim) + test('census rows: fork refused, product conflict refused, invalid fragment, legacy entry with a tentative version', () => { + const fx = setup(); + openPr(fx, '1'); + fx.write(d => { d.prs['1'].isCrossRepository = true; }); + openPr(fx, '2', { edit: w => fs.writeFileSync(path.join(w, 'shared.txt'), 'two\n') }); + openPr(fx, '3', { fragment: '## [9.9.9.9]\n- bad\n' }); + openPr(fx, '4', { fragment: null, edit: w => { + fs.writeFileSync(path.join(w, 'VERSION'), '1.2.9.0\n'); + fs.writeFileSync(path.join(w, 'CHANGELOG.md'), fs.readFileSync(path.join(w, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- legacy #4\n')); + } }); + pushMain(fx, 'main edits shared', w => fs.writeFileSync(path.join(w, 'shared.txt'), 'main\n')); + const mainBefore = remote(fx, 'main'); + const c = runBin(fx.repo, 'gstack-ship-queue', ['land', '--census', '--base', 'main'], fx.env); + expect(c.status, c.err).toBe(EXIT.ok); + expect(c.out).toMatch(/^CENSUS: 4 open PR\(s\) against origin\/main@[0-9a-f]{12} \(fragments: changelog\.d\)$/m); + expect(c.out).toMatch(/^#1 fork: refused \(QUEUE_TRAIN_UNTRUSTED\)$/m); + expect(c.out).toMatch(/^#2 [0-9a-f]{12}: refuse \(product conflict: shared\.txt\)$/m); + expect(c.out).toMatch(/^#3 [0-9a-f]{12}: invalid fragment changelog\.d\/pr-3\.md$/m); + expect(c.out).toMatch(/^#4 [0-9a-f]{12}: re-head legacy \[Unreleased\]; rewrite its tentative 1\.2\.9\.0 through restamp$/m); + expect(remote(fx, 'main')).toBe(mainBefore); + expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-land')).toBe(''); + }, 90_000); +}); diff --git a/test/version-gate-fragments.test.ts b/test/version-gate-fragments.test.ts index fbd42d8f0..0df546be1 100644 --- a/test/version-gate-fragments.test.ts +++ b/test/version-gate-fragments.test.ts @@ -95,3 +95,19 @@ describe('version gate: changelog fragments', () => { expect(workflow.on.pull_request.branches).toEqual(['main']); }); }); + +// Generated by /ship Step 7 coverage audit (GSTA-154 PR A, pass 1). +describe('version gate: legacy entries under changelog fragments', () => { + // Value: protects=a person's PR with a legacy [Unreleased] entry and no fragment still passes once fragments are on; + // fails_when=the [Unreleased] fallback in classifyTrainHead is dropped, so in-flight PRs fail with "add a CHANGELOG fragment"; + // why_new=existing gate tests cover fragment, bot and no-entry heads only; seam=none; no_claim=GitHub workflow wiring (script run directly) + test('an [Unreleased] entry without a fragment passes and names that entry', () => { + const r = fixture(); + r.git('checkout', '-q', '-b', 'legacy'); + write(r, 'legacy.txt', 'l\n'); + write(r, 'CHANGELOG.md', fs.readFileSync(path.join(r.work, 'CHANGELOG.md'), 'utf8').replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- legacy entry\n')); + const res = run(r, commit(r, 'legacy')); + expect(res.code, res.out).toBe(0); + expect(res.out).toContain('the CHANGELOG change is confined to [Unreleased]'); + }); +});