mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
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>
This commit is contained in:
1 parent
1e02f6e699
commit
b6e9790294
3 files changed
+222
-1
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = 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 <number> (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-<n>-<name>.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);
|
||||
});
|
||||
@@ -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]');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user