mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 17:58:13 +01:00
Merge origin/main (d9b0d600, v1.91.267.0) into absorb-b-pr-body-blast-radius-prose-lint (rule 19 grant, GSTA-36; CHANGELOG via mergeChangelogs, NOTICE.md additive)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
commit
5619199800
114 files changed
+8354
-397
No files matched your search
@@ -62,8 +62,17 @@ import { skillCensus } from './helpers/skill-census';
|
||||
* New-skill ratchet: previous ceiling 1,251 + ceil(150 / 4) = 1,289
|
||||
* token-equivalents (5,156 bytes), leaving 6 bytes. Existing descriptions
|
||||
* are unchanged.
|
||||
* ref design-motion addition on base 6c5b2d46 (2026-10-27), Garry
|
||||
* 2026-10-25 'Do it' (GSTA-92 plan item 15)
|
||||
* result pre-addition aggregate 5,150 bytes; design-motion adds 99 bytes
|
||||
* (name + the trimmed lead "animate an approved design into a
|
||||
* seamless MP4 and GIF loop"), yielding 5,249 bytes = 1,313
|
||||
* token-equivalents including the root router alias.
|
||||
* New-skill ratchet: previous ceiling 1,289 + ceil(99 / 4) = 1,314
|
||||
* token-equivalents (5,256 bytes), leaving 7 bytes. Existing descriptions
|
||||
* are unchanged.
|
||||
*/
|
||||
const CATALOG_BUDGET_TOKEN_EQUIVALENTS = 1_289;
|
||||
const CATALOG_BUDGET_TOKEN_EQUIVALENTS = 1_314;
|
||||
|
||||
// Largest today: design-consultation at 229 bytes. A description that needs
|
||||
// more than 260 bytes is a body paragraph, not a catalog entry.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Craft resolvers (scripts/resolvers/design-craft.ts) shared by /design-review's
|
||||
* craft audit and /plan-design-review.
|
||||
*
|
||||
* Value: protects=DESIGN_CRAFT_RULE returns a craft paragraph with its bullets and fails generation on a missing or renamed lead; fails_when=the lookup returns '' or the first paragraph instead of throwing, or drops bullets; why_new=only the freshness golden exercises the resolver, and no test reaches its throw paths; seam=none; no_claim=agent use of the rendered rules (behavioral, no paid case)
|
||||
* Value: protects=design-review and plan-design-review render the same five Persuade checks; fails_when=either surface drops or hand-forks the {{DESIGN_PERSUADE_CHECKS}} block; why_new=carve-guards checks one phrase in design-review only, nothing checks plan-design-review; seam=none; no_claim=that an agent applies the checks (eval)
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { DESIGN_CRAFT_RULE, DESIGN_PERSUADE_CHECKS } from '../scripts/resolvers/design-craft';
|
||||
import type { TemplateContext } from '../scripts/resolvers/types';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf-8');
|
||||
const ctx = { skillName: 'design-review', tmplPath: 'design-review/sections/craft-audit.md.tmpl' } as TemplateContext;
|
||||
|
||||
describe('craft resolvers', () => {
|
||||
test('DESIGN_CRAFT_RULE renders the craft paragraph with its bullets; a missing or unknown lead fails generation', () => {
|
||||
const motion = DESIGN_CRAFT_RULE(ctx, ['Motion']);
|
||||
expect(motion).toStartWith('**Motion.**');
|
||||
expect(motion).toContain("Never an unthrottled `window.addEventListener('scroll')`");
|
||||
expect(motion).toContain('`min-height: 100dvh`, not `100vh`.');
|
||||
expect(motion).not.toContain('\n\n');
|
||||
expect(DESIGN_CRAFT_RULE(ctx, ['Claims come from the user'])).toStartWith('**Claims come from the user, never from you.**');
|
||||
expect(read('design-html', 'sections', 'craft.md')).toContain(motion);
|
||||
expect(() => DESIGN_CRAFT_RULE(ctx, ['No such lead'])).toThrow('No such lead');
|
||||
expect(() => DESIGN_CRAFT_RULE(ctx, ['Claims'])).toThrow('0 paragraphs');
|
||||
expect(() => DESIGN_CRAFT_RULE(ctx, ['Mot'])).toThrow('need exactly one');
|
||||
expect(() => DESIGN_CRAFT_RULE(ctx, [])).toThrow('requires the bold lead');
|
||||
expect(() => DESIGN_CRAFT_RULE(ctx)).toThrow('requires the bold lead');
|
||||
});
|
||||
|
||||
test('design-review and plan-design-review carry the same five Persuade checks', () => {
|
||||
const checks = DESIGN_PERSUADE_CHECKS(ctx);
|
||||
expect(checks.split('\n').map(l => l.slice(0, 3))).toEqual(['1. ', '2. ', '3. ', '4. ', '5. ']);
|
||||
expect(read('design-review', 'sections', 'craft-audit.md')).toContain(checks);
|
||||
expect(read('plan-design-review', 'sections', 'review-sections.md')).toContain(checks);
|
||||
});
|
||||
|
||||
test('the design-review craft audit settles each check outcome and exception kind, and keeps the saved page private', () => {
|
||||
const audit = read('design-review', 'sections', 'craft-audit.md');
|
||||
expect(audit).toContain('line is one category 9 finding under that id');
|
||||
expect(audit).toContain('no `CRAFT_RESULT` line (framework source');
|
||||
expect(audit).toContain('or a mount-node shell: say the check was unavailable for that page and judge the tells below by eye');
|
||||
expect(audit).toContain('add `--accepted` with that screen\'s finalized.json');
|
||||
expect(audit).toContain('a `CRAFT_ACCEPTED` line is a finding the user accepted, listed with its reason, not filed again, except `needs-proof` and `lorem`');
|
||||
expect(audit).toContain('After a `cannot use exceptions` warning, file every line.');
|
||||
expect(audit).toContain('Never mark or cut a claim as a rule or fix says: defer it and ask for the proof.');
|
||||
expect(audit).toContain('never copy it to the report directory');
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,11 @@ import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { acceptFindings, BUZZWORDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft';
|
||||
import { catalogEntry } from '../lib/design-catalog';
|
||||
import { acceptFindings, BUZZWORDS, CRAFT_IDS, checkCraft, formatCraft, parseCraftExceptions, visibleText } from '../lib/design-craft';
|
||||
import { catalogEntry, DESIGN_SLOP_CATALOG, detectorSlopEntries, judgmentTellEntries, renderCatalog, selectCatalog } from '../lib/design-catalog';
|
||||
import { autoFixEntries, checklistSlopEntries } from '../scripts/resolvers/design-checklist';
|
||||
import { selectTests } from './helpers/test-selection';
|
||||
import { E2E_TOUCHFILES } from './helpers/touchfiles-data';
|
||||
import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-design-craft');
|
||||
@@ -33,7 +34,7 @@ describe('design-craft check', () => {
|
||||
const counts = Object.fromEntries(checkCraft(DIRTY).map(f => [f.id, f.count]));
|
||||
expect(counts).toEqual({
|
||||
'placeholder-name': 1, 'placeholder-brand': 1, lorem: 1, 'marketing-buzzword': 2, 'round-number': 3, 'needs-proof': 1, oops: 1,
|
||||
'scroll-listener': 1, 'transition-all': 1, 'dead-link': 1, alert: 1, 'img-no-alt': 1, 'vh-height': 1,
|
||||
'scroll-listener': 1, 'layout-transition': 1, 'dead-link': 1, alert: 1, 'img-no-alt': 1, 'vh-height': 1,
|
||||
'missing-title': 1, 'missing-meta-description': 1, 'missing-favicon': 1,
|
||||
'missing-skip-link': 1, 'missing-aria-current': 1,
|
||||
});
|
||||
@@ -46,6 +47,33 @@ describe('design-craft check', () => {
|
||||
expect(formatCraft([])).toBe('CRAFT_RESULT: clean');
|
||||
});
|
||||
|
||||
test('every id the check prints is a grep-detectable catalog entry, and the fix it prints is the catalog fix', () => {
|
||||
expect(new Set(CRAFT_IDS).size).toBe(CRAFT_IDS.length);
|
||||
for (const id of CRAFT_IDS) {
|
||||
const entry = catalogEntry(id);
|
||||
expect(entry, id).toBeDefined();
|
||||
expect(entry!.detect, id).toContain('grep');
|
||||
expect(entry!.fix?.length ?? 0, id).toBeGreaterThan(0);
|
||||
}
|
||||
const findings = checkCraft(DIRTY);
|
||||
expect(findings.map(f => f.id)).toEqual([...CRAFT_IDS]);
|
||||
for (const f of findings) expect(f.fix).toBe(catalogEntry(f.id)!.fix!);
|
||||
});
|
||||
|
||||
// Value: protects=checker-only craft entries render into no skill prose or checklist, and each is a printed craft id; fails_when=a renderer reads DESIGN_SLOP_CATALOG, not RENDERED, or filters on fix; why_new=only goldens catch it, until regenerated; seam=none; no_claim=byte size of any one skill (size guard owns it)
|
||||
test('checker-only catalog entries are craft ids that no rendered list carries; catalog entries with a fix still render', () => {
|
||||
const checkerOnly = DESIGN_SLOP_CATALOG.filter(e => e.checkerOnly);
|
||||
expect(checkerOnly.length).toBeGreaterThan(0);
|
||||
const rendered = new Set([selectCatalog({}), detectorSlopEntries(), judgmentTellEntries(), checklistSlopEntries(), autoFixEntries()].flat().map(e => e.id));
|
||||
const prose = renderCatalog({ kind: 'slop' });
|
||||
for (const e of checkerOnly) {
|
||||
expect(CRAFT_IDS, e.id).toContain(e.id);
|
||||
expect(rendered.has(e.id), e.id).toBe(false);
|
||||
expect(prose, e.id).not.toContain(e.prose);
|
||||
}
|
||||
for (const id of ['marketing-buzzword', 'layout-transition']) expect(rendered.has(id), id).toBe(true);
|
||||
});
|
||||
|
||||
test('BUZZWORDS covers every word the catalog marketing-buzzword heuristic names', () => {
|
||||
const heuristic = catalogEntry('marketing-buzzword')?.heuristic ?? '';
|
||||
const words = heuristic.replace(/^.*?for /, '').replace(/\.$/, '').split(/,\s*/);
|
||||
@@ -130,6 +158,8 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
|
||||
expect(accepted).toEqual([{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' }, { id: 'scroll-listener', count: 1, reason: 'unused' }]);
|
||||
expect(issues.find(f => f.id === 'round-number')?.fix).toEndWith('(2 accepted earlier: supplied figures)');
|
||||
expect(issues).toHaveLength(findings.length - 2);
|
||||
expect(acceptFindings([{ id: 'needs-proof', count: 1, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [], accepted: [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }] });
|
||||
expect(acceptFindings([{ id: 'needs-proof', count: 2, fix: 'f' }], [{ id: 'needs-proof', count: 1, reason: 'awaiting logo' }])).toEqual({ issues: [{ id: 'needs-proof', count: 2, fix: 'f (1 accepted earlier: awaiting logo)' }], accepted: [] });
|
||||
expect(formatCraft([], accepted)).toBe('CRAFT_ACCEPTED: placeholder-brand 1 Acme is the real customer\nCRAFT_ACCEPTED: scroll-listener 1 unused\nCRAFT_RESULT: clean (2 accepted)');
|
||||
const carried = [{ id: 'round-number', count: 1, reason: 'first run' }, { id: 'round-number', count: 3, reason: 'supplied figures' }];
|
||||
for (const list of [carried, [...carried].reverse()]) {
|
||||
@@ -143,6 +173,9 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
|
||||
const record = JSON.parse(block.replace(/<number of refinement iterations>|<n>/g, '1'));
|
||||
expect(parseCraftExceptions(JSON.stringify(record))).toEqual([{ id: '<craft id>', count: 1, reason: '<why accepted>' }]);
|
||||
expect(parseCraftExceptions('{"mode":"freeform"}')).toEqual([]);
|
||||
const legacy = parseCraftExceptions('{"craft_exceptions":[{"id":"transition-all","count":1,"reason":"recorded before the rename"}]}');
|
||||
expect(legacy).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]);
|
||||
expect(acceptFindings(checkCraft('<style>a { transition: all .2s }</style>'), legacy).accepted).toEqual([{ id: 'layout-transition', count: 1, reason: 'recorded before the rename' }]);
|
||||
expect(parseCraftExceptions('{"craft_exceptions":[{"id":"oops","count":1,"reason":"quoted\\nfrom the user"}]}')).toEqual([{ id: 'oops', count: 1, reason: 'quoted from the user' }]);
|
||||
for (const bad of ['[]', 'null', '{"craft_exceptions":{}}', '{"craft_exceptions":[{"id":"oops","count":0,"reason":"x"}]}', '{"craft_exceptions":[{"id":"oops","count":1,"reason":" "}]}', '{"craft_exceptions":[{"id":"oops","count":1.5,"reason":"x"}]}']) {
|
||||
expect(() => parseCraftExceptions(bad)).toThrow();
|
||||
@@ -175,6 +208,7 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
|
||||
const bad = run(page, '--accepted', path.join(dir, name));
|
||||
expect(bad).toMatchObject({ code: 2, out: run(page).out });
|
||||
expect(bad.err).toContain('checked with none accepted');
|
||||
expect(bad.err).toContain('cannot use exceptions');
|
||||
}
|
||||
fs.writeFileSync(page, CLEAN);
|
||||
expect(run(page, '--accepted', path.join(dir, 'broken.json'))).toMatchObject({ code: 2, out: 'CRAFT_RESULT: clean\n' });
|
||||
@@ -185,6 +219,27 @@ describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an exception the page no longer triggers prints nothing, and a record is read only with --accepted', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-cells-'));
|
||||
try {
|
||||
const page = path.join(dir, 'finalized.html');
|
||||
const record = path.join(dir, 'finalized.json');
|
||||
fs.writeFileSync(page, CLEAN);
|
||||
fs.writeFileSync(record, JSON.stringify({ craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme is the customer' }] }));
|
||||
expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' });
|
||||
expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' });
|
||||
expect(acceptFindings([], [{ id: 'oops', count: 1, reason: 'x' }])).toEqual({ issues: [], accepted: [] });
|
||||
expect(acceptFindings([], [{ id: 'lorem', count: 1, reason: 'draft copy' }])).toEqual({ issues: [], accepted: [] });
|
||||
fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus'));
|
||||
expect(run(page)).toMatchObject({ code: 1, out: 'CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data\nCRAFT_RESULT: 1 issue(s)\n' });
|
||||
fs.writeFileSync(page, CLEAN);
|
||||
fs.writeFileSync(record, '{');
|
||||
expect(run(page)).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n', err: '' });
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('design-shotgun to design-html handoff', () => {
|
||||
@@ -206,11 +261,18 @@ describe('design-shotgun to design-html handoff', () => {
|
||||
});
|
||||
|
||||
test('a change to the craft checker selects the design-html-slop-gate E2E that runs it', () => {
|
||||
for (const file of ['lib/design-craft.ts', 'bin/gstack-design-craft']) {
|
||||
for (const file of ['lib/design-craft.ts', 'lib/design-catalog.ts', 'bin/gstack-design-craft']) {
|
||||
expect(selectTests([file], E2E_TOUCHFILES).selected).toContain('design-html-slop-gate');
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=a change to the shared craft resolver reruns the paid cases whose prompts it renders; fails_when=scripts/resolvers/design-craft.ts drops from those touchfile lists; why_new=the row above covers lib/design-craft.ts and the bin only; seam=none; no_claim=that the selected cases pass
|
||||
test('a change to the craft resolvers selects the design-review and plan-design-review cases that render them', () => {
|
||||
const e2e = selectTests(['scripts/resolvers/design-craft.ts'], E2E_TOUCHFILES).selected;
|
||||
for (const name of ['design-review-fix', 'design-review-detector-shim', 'design-review-detector-shim-dom', 'design-review-plugin-handoff', 'plan-design-review-plan-mode']) expect(e2e).toContain(name);
|
||||
expect(selectTests(['scripts/resolvers/design-craft.ts'], LLM_JUDGE_TOUCHFILES).selected).toContain('plan-design-review/SKILL.md passes');
|
||||
});
|
||||
|
||||
test('design-html persists accepted exceptions and passes them back with --accepted', () => {
|
||||
const html = read('design-html', 'SKILL.md');
|
||||
const craft = read('design-html', 'sections', 'craft.md');
|
||||
|
||||
@@ -17,6 +17,7 @@ import { collectFiles, contextWaivers, lintStatic, MAX_FILE_BYTES } from '../lib
|
||||
import type { LintContext } from '../lib/design-lint/context';
|
||||
import { browserAvailable } from './helpers/browser-available';
|
||||
import { RULES } from '../lib/design-lint/rules/ids';
|
||||
import { CRAFT_IDS } from '../lib/design-craft';
|
||||
import { FAMILIES } from '../lib/design-lint/rules';
|
||||
import { findWaivers, reasonIsBoilerplate, resolveWaivers, applyWaivers, suspectedRules } from '../lib/design-lint/waivers';
|
||||
|
||||
@@ -398,11 +399,10 @@ describe('design-lint: third review cycle', () => {
|
||||
});
|
||||
|
||||
test('every id gstack-design-craft can emit has a lint rule or belongs to the static tier', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'lib', 'design-craft.ts'), 'utf8');
|
||||
const ids = [...new Set([...src.matchAll(/id: '([a-z-]+)'/g)].map((m) => m[1]))];
|
||||
const ids = [...CRAFT_IDS];
|
||||
expect(ids.length).toBeGreaterThan(15);
|
||||
expect(ids.filter((id) => !(id in RULES) && id !== 'transition-all')).toEqual([]);
|
||||
expect(ids.filter((id) => id in RULES && RULES[id].gate !== 'craft')).toEqual(['vh-height']);
|
||||
expect(ids.filter((id) => !(id in RULES))).toEqual([]);
|
||||
expect(ids.filter((id) => RULES[id].gate !== 'craft')).toEqual(['layout-transition', 'vh-height']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* C4 (#2777): ship, plan-ceo-review and office-hours are carved on every host, and no
|
||||
* C4 (#2777): ship, plan-ceo-review, office-hours and design-review are carved on every host, and no
|
||||
* generated SKILL.md exceeds 160,000 UTF-8 bytes on any host or model overlay
|
||||
* setup can select. Carved sections on external hosts are pointed at relative
|
||||
* to the installed skill directory: `$GSTACK_ROOT/<skill>/sections/...` does
|
||||
@@ -65,10 +65,10 @@ describe('C4: external section pointers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('C4: ship, plan-ceo-review and office-hours are carved on every external host', () => {
|
||||
describe('C4: ship, plan-ceo-review, office-hours and design-review are carved on every external host', () => {
|
||||
for (const config of EXTERNAL) {
|
||||
test(`${config.name}: every pointer in the carved skill resolves to a generated section file`, () => {
|
||||
for (const skill of ['ship', 'plan-ceo-review', 'office-hours']) {
|
||||
for (const skill of ['ship', 'plan-ceo-review', 'office-hours', 'design-review']) {
|
||||
expect(usesLazySections(config.name, skill)).toBe(true);
|
||||
const dir = skillDir(config, renders.all, skill);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections', 'manifest.json'), 'utf8')) as { sections: Array<{ file: string }> };
|
||||
|
||||
Vendored
+1
@@ -17,6 +17,7 @@
|
||||
"cso": 4924,
|
||||
"design-consultation": 16188,
|
||||
"design-html": 14380,
|
||||
"design-motion": 10409,
|
||||
"design-review": 35808,
|
||||
"design-shotgun": 14901,
|
||||
"deslop-shared-libs": 3411,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ledgerline weekly summary</title>
|
||||
<style>
|
||||
html, body { margin: 0; background: #f4f1ea; }
|
||||
.frame { position: relative; width: 640px; height: 400px; font: 500 15px/1.35 Georgia, serif; color: #1f1c17; }
|
||||
.card { position: absolute; top: 48px; width: 272px; height: 304px; border-radius: 16px; background: #fffdf8;
|
||||
box-shadow: 0 8px 20px rgba(31, 28, 23, 0.08); overflow: hidden; }
|
||||
.revenue { left: 32px; }
|
||||
.queue { left: 336px; }
|
||||
h2 { margin: 22px 22px 4px; font-size: 15px; font-weight: 600; }
|
||||
.value { margin: 0 22px; font-size: 30px; font-variant-numeric: tabular-nums; }
|
||||
.bars { position: absolute; left: 22px; right: 22px; bottom: 26px; height: 120px; display: flex; align-items: flex-end; gap: 10px; }
|
||||
.bars span { flex: 1; border-radius: 4px 4px 0 0; background: #3f6b5c; }
|
||||
.rows { margin: 18px 22px 0; padding: 0; list-style: none; }
|
||||
.rows li { display: flex; justify-content: space-between; padding: 11px 0; border-top: 1px solid #ece6da; }
|
||||
.rows b { font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="frame">
|
||||
<section class="card revenue">
|
||||
<h2>Collected this week</h2>
|
||||
<p class="value">$48,120</p>
|
||||
<div class="bars"><span style="height:42%"></span><span style="height:61%"></span><span style="height:55%"></span><span style="height:78%"></span><span style="height:70%"></span><span style="height:92%"></span></div>
|
||||
</section>
|
||||
<section class="card queue">
|
||||
<h2>Invoices due</h2>
|
||||
<ul class="rows">
|
||||
<li><span>Harbor Dental</span><b>$2,340</b></li>
|
||||
<li><span>Pinecrest Studio</span><b>$1,180</b></li>
|
||||
<li><span>Okafor & Lane</span><b>$4,905</b></li>
|
||||
<li><span>Mill Street Bakery</span><b>$612</b></li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+3
-1
@@ -159,7 +159,9 @@
|
||||
{ "file": "lib/ship-queue-train-cli.ts", "movedFrom": "(new: merge-train argument parsing and output, plan GSTA-49 T1)" },
|
||||
{ "file": "lib/gate-cost.ts", "movedFrom": "(new: paid gate cost by model and token class, per-case costs, selection replays and the merge-train model, GSTA-49 T0)" },
|
||||
{ "file": "scripts/gate-cost-replay.ts", "movedFrom": "(new: CLI behind lib/gate-cost.ts: cached report artifacts, merged-PR replay and the train model table, GSTA-49 T0)" },
|
||||
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" }
|
||||
{ "file": "lib/gate-cost-history.ts", "movedFrom": "(new: red attribution, red-head screen replay, READY times and the READY-arrival train replay, GSTA-49 T0b)" },
|
||||
{ "file": "lib/pr-wait.ts", "movedFrom": "(new: gstack-pr-wait event logic, GSTA-115 item 1; design ported from tstack pr-wait)" },
|
||||
{ "file": "lib/pr-wait-github.ts", "movedFrom": "(new: gstack-pr-wait gh reads, GSTA-115 item 1; design ported from tstack pr-wait)" }
|
||||
],
|
||||
"residualFiles": [
|
||||
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
|
||||
|
||||
+23
-23
@@ -4,90 +4,90 @@
|
||||
"pins": {
|
||||
"agy/gstack-autoplan": 130472,
|
||||
"agy/gstack-design-consultation": 108152,
|
||||
"agy/gstack-design-review": 143496,
|
||||
"agy/gstack-design-review": 144104,
|
||||
"agy/gstack-land-and-deploy": 117690,
|
||||
"agy/gstack-plan-design-review": 139981,
|
||||
"agy/gstack-plan-design-review": 141063,
|
||||
"agy/gstack-plan-devex-review": 135728,
|
||||
"agy/gstack-plan-eng-review": 152574,
|
||||
"agy/gstack-review": 124400,
|
||||
"claude/design-review": 136499,
|
||||
"claude/design-review": 137077,
|
||||
"codex/gstack-autoplan": 130596,
|
||||
"codex/gstack-design-consultation": 109420,
|
||||
"codex/gstack-design-review": 144996,
|
||||
"codex/gstack-design-review": 145604,
|
||||
"codex/gstack-land-and-deploy": 122240,
|
||||
"codex/gstack-office-hours": 102276,
|
||||
"codex/gstack-plan-design-review": 142434,
|
||||
"codex/gstack-plan-design-review": 143516,
|
||||
"codex/gstack-plan-devex-review": 138617,
|
||||
"codex/gstack-plan-eng-review": 155120,
|
||||
"codex/gstack-review": 125304,
|
||||
"copilot/gstack-autoplan": 131605,
|
||||
"copilot/gstack-codex": 100203,
|
||||
"copilot/gstack-design-consultation": 109504,
|
||||
"copilot/gstack-design-review": 144801,
|
||||
"copilot/gstack-design-review": 145409,
|
||||
"copilot/gstack-land-and-deploy": 118945,
|
||||
"copilot/gstack-office-hours": 100432,
|
||||
"copilot/gstack-plan-design-review": 140852,
|
||||
"copilot/gstack-plan-design-review": 141934,
|
||||
"copilot/gstack-plan-devex-review": 136880,
|
||||
"copilot/gstack-plan-eng-review": 153299,
|
||||
"copilot/gstack-review": 125671,
|
||||
"cursor/gstack-autoplan": 129246,
|
||||
"cursor/gstack-design-consultation": 107190,
|
||||
"cursor/gstack-design-review": 142475,
|
||||
"cursor/gstack-design-review": 143083,
|
||||
"cursor/gstack-land-and-deploy": 116613,
|
||||
"cursor/gstack-plan-design-review": 138936,
|
||||
"cursor/gstack-plan-design-review": 140018,
|
||||
"cursor/gstack-plan-devex-review": 134559,
|
||||
"cursor/gstack-plan-eng-review": 151428,
|
||||
"cursor/gstack-review": 142320,
|
||||
"factory/gstack-autoplan": 131530,
|
||||
"factory/gstack-codex": 100623,
|
||||
"factory/gstack-design-consultation": 108638,
|
||||
"factory/gstack-design-review": 144265,
|
||||
"factory/gstack-design-review": 144873,
|
||||
"factory/gstack-land-and-deploy": 120785,
|
||||
"factory/gstack-office-hours": 101311,
|
||||
"factory/gstack-plan-design-review": 141717,
|
||||
"factory/gstack-plan-design-review": 142799,
|
||||
"factory/gstack-plan-devex-review": 137928,
|
||||
"factory/gstack-plan-eng-review": 154542,
|
||||
"factory/gstack-review": 146099,
|
||||
"gbrain/gstack-autoplan": 129783,
|
||||
"gbrain/gstack-design-review": 132810,
|
||||
"gbrain/gstack-design-review": 133418,
|
||||
"gbrain/gstack-land-and-deploy": 117135,
|
||||
"gbrain/gstack-plan-design-review": 127823,
|
||||
"gbrain/gstack-plan-design-review": 128905,
|
||||
"gbrain/gstack-plan-devex-review": 115097,
|
||||
"gbrain/gstack-plan-eng-review": 134160,
|
||||
"hermes/gstack-autoplan": 129873,
|
||||
"hermes/gstack-design-review": 132917,
|
||||
"hermes/gstack-design-review": 133525,
|
||||
"hermes/gstack-land-and-deploy": 117237,
|
||||
"hermes/gstack-plan-design-review": 127932,
|
||||
"hermes/gstack-plan-design-review": 129014,
|
||||
"hermes/gstack-plan-devex-review": 115183,
|
||||
"hermes/gstack-plan-eng-review": 134245,
|
||||
"kiro/gstack-autoplan": 129008,
|
||||
"kiro/gstack-design-consultation": 107032,
|
||||
"kiro/gstack-design-review": 142289,
|
||||
"kiro/gstack-design-review": 142897,
|
||||
"kiro/gstack-land-and-deploy": 116501,
|
||||
"kiro/gstack-plan-design-review": 138776,
|
||||
"kiro/gstack-plan-design-review": 139858,
|
||||
"kiro/gstack-plan-devex-review": 134399,
|
||||
"kiro/gstack-plan-eng-review": 151278,
|
||||
"kiro/gstack-review": 142132,
|
||||
"openclaw/gstack-autoplan": 129488,
|
||||
"openclaw/gstack-design-review": 131615,
|
||||
"openclaw/gstack-design-review": 132223,
|
||||
"openclaw/gstack-land-and-deploy": 116725,
|
||||
"openclaw/gstack-plan-design-review": 126674,
|
||||
"openclaw/gstack-plan-design-review": 127756,
|
||||
"openclaw/gstack-plan-devex-review": 113936,
|
||||
"openclaw/gstack-plan-eng-review": 132557,
|
||||
"opencode/gstack-autoplan": 131608,
|
||||
"opencode/gstack-design-consultation": 109325,
|
||||
"opencode/gstack-design-review": 144673,
|
||||
"opencode/gstack-design-review": 145281,
|
||||
"opencode/gstack-land-and-deploy": 118730,
|
||||
"opencode/gstack-office-hours": 100266,
|
||||
"opencode/gstack-plan-design-review": 140706,
|
||||
"opencode/gstack-plan-design-review": 141788,
|
||||
"opencode/gstack-plan-devex-review": 136780,
|
||||
"opencode/gstack-plan-eng-review": 153180,
|
||||
"opencode/gstack-review": 144555,
|
||||
"slate/gstack-autoplan": 129127,
|
||||
"slate/gstack-design-consultation": 107111,
|
||||
"slate/gstack-design-review": 142382,
|
||||
"slate/gstack-design-review": 142990,
|
||||
"slate/gstack-land-and-deploy": 116557,
|
||||
"slate/gstack-plan-design-review": 138856,
|
||||
"slate/gstack-plan-design-review": 139938,
|
||||
"slate/gstack-plan-devex-review": 134479,
|
||||
"slate/gstack-plan-eng-review": 151353,
|
||||
"slate/gstack-review": 142226
|
||||
|
||||
+241
-1
@@ -7,7 +7,7 @@
|
||||
* the real executable against stub gh/glab on POSIX.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { EXIT, runPost, type PostEnv } from "../lib/gstack-post";
|
||||
import { EXIT, mergeBodySections, runPost, type PostEnv } from "../lib/gstack-post";
|
||||
|
||||
interface Call { cmd: string; args: string[]; input?: string }
|
||||
|
||||
@@ -226,6 +226,47 @@ describe("gstack-post owns the redaction scan", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Value: protects=an 11-digit GitHub Actions run id written after a run word (run, screen, gate, backstop, bisect, rerun, dispatched, failed, passed,
|
||||
// with an optional ':' '#' '='), or chained after one with ",", "and", "&", "or" or "/" (a "(#1234)" between allowed), posts without a pii.phone.e164
|
||||
// confirmation, while a generic word (id, job, attempt, workflow) exempts nothing; fails_when=real receipts ("failed 40269992077 | rerun passed
|
||||
// 40287239206", "screen 40269992077 (#3434) and 40275443393 (#3453)", "gate 40323989742") need --confirm, "customer id 14155551234" or "job 40323989748"
|
||||
// posts unconfirmed, or a bare 11-digit number, a 10-digit national number or an E.164 number stops reporting;
|
||||
// why_new=the receipt-token test allowlists only "run <id>" and the /actions/runs/ URL; seam=none; no_claim=none beyond protects
|
||||
test("run ids next to run words, and the ids chained after them, are not phone numbers; a bare id, a national number and an E.164 number still are", () => {
|
||||
const clean = [
|
||||
"failed 40269992077 | rerun passed 40287239206",
|
||||
"screen 40269992077 (#3434) and 40275443393 (#3453)",
|
||||
"gate 40323989742",
|
||||
"gates: 40323989742, 40323989743 or 40323989744",
|
||||
"backstop=40323989742 bisect #40323989745",
|
||||
"dispatched 40323989742 / 40323989746 & 40323989747",
|
||||
"runs 40323989742 and 40323989753 (#3454), 40323989754",
|
||||
];
|
||||
for (const body of clean) {
|
||||
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
|
||||
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.posted]);
|
||||
expect(f.posts()).toHaveLength(1);
|
||||
}
|
||||
const controls = [
|
||||
"the id is 19368712345",
|
||||
"customer id 14155551234",
|
||||
"job 40323989748",
|
||||
"workflow 40323989742; attempt 40323989749; ids 40323989751, 40323989752",
|
||||
"40269992077 failed",
|
||||
"gate 40323989742. Then 40323989743 on its own",
|
||||
"gate 4155551234",
|
||||
"call +14155551234 for the on-call",
|
||||
"run +14155551234",
|
||||
"see 40323989742, 40323989743",
|
||||
];
|
||||
for (const body of controls) {
|
||||
const f = fakeEnv({ files: { "/b": body + "\n" }, visibility: "public" });
|
||||
expect([body, run(["pr-comment", "12", "--body-file", "/b"], f)]).toEqual([body, EXIT.confirm]);
|
||||
expect(f.stdout()).toContain("RULE: pii.phone.e164 LINE: 1 PART: body");
|
||||
expect(f.posts()).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test("the title is scanned too, and the pusher's own email is not a finding", () => {
|
||||
const f = fakeEnv({ files: { "/t": "v1 fix for jane.roe@acme-corp.io", "/b": "body" } });
|
||||
expect(run(["pr-create", "--base", "main", "--title-file", "/t", "--body-file", "/b"], f)).toBe(EXIT.confirm);
|
||||
@@ -263,3 +304,202 @@ describe("gstack-post reports CLI failures and repairs known edit failures", ()
|
||||
expect(f.posts()[0]!.args.slice(0, 3)).toEqual(["pr", "comment", "3"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-post pr-body merges the PR body by section", () => {
|
||||
const RECEIPT = (head: string) => "```gstack-ship-receipt\n{\n \"schema_version\": 1,\n \"pr\": \"5\",\n \"head\": \"" + head + "\"\n}\n```\n";
|
||||
const CURRENT = RECEIPT("aaaa") + "\n## What changed\n\n- the old summary\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n";
|
||||
const withBody = (current: string, files: Record<string, string>) => fakeEnv({
|
||||
files,
|
||||
respond: c => (c.cmd === "gh" && c.args[1] === "view" && c.args.includes("body") ? { status: 0, stdout: current + "\n" } : undefined),
|
||||
});
|
||||
const sent = (f: ReturnType<typeof fakeEnv>) => f.posts().find(c => c.args[1] === "edit")!;
|
||||
|
||||
// Value: protects=a pr-body post holding one section replaces only that section: same-titled sections (case-insensitively) are replaced in place, the
|
||||
// receipt block is its own section, sections the new body lacks are kept, new sections are appended, and one stderr line names each group;
|
||||
// fails_when=pr-body overwrites the whole body (a "## What changed"-only post wipes Why, Approvals and the receipt), the receipt is folded into the
|
||||
// section before it, or the merge changes the kept sections' bytes; why_new=pr-body was a plain overwrite; seam=the fake run answers gh pr view --json body;
|
||||
// no_claim=real gh (unit through runPost)
|
||||
test("a first-section-only body keeps the other sections and the receipt; a same-titled section is replaced in place; new sections are appended", () => {
|
||||
const f = withBody(CURRENT, { "/b": "## What Changed\n\n- the new summary\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], f)).toBe(EXIT.posted);
|
||||
expect(sent(f).input).toBe(RECEIPT("aaaa") + "\n## What Changed\n\n- the new summary\n\n## Why\n\nBecause.\n\n## Approvals\n\n- owner: do it\n");
|
||||
expect(f.stderr()).toBe("gstack-post: pr-body merged by section: replaced What changed; kept ship receipt, Why, Approvals; added none\n");
|
||||
expect(f.calls.find(c => c.cmd === "gh" && c.args[1] === "view")!.args).toEqual(["pr", "view", "5", "--json", "body", "--jq", ".body"]);
|
||||
|
||||
const receipt = withBody(CURRENT, { "/b": RECEIPT("bbbb") });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], receipt)).toBe(EXIT.posted);
|
||||
expect(sent(receipt).input).toBe(CURRENT.replace(RECEIPT("aaaa"), RECEIPT("bbbb")));
|
||||
expect(receipt.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt; kept What changed, Why, Approvals; added none\n");
|
||||
|
||||
const added = withBody(CURRENT, { "/b": "## Why\n\nStill because.\n\n## Tests\n\n- one\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], added)).toBe(EXIT.posted);
|
||||
expect(sent(added).input).toBe(RECEIPT("aaaa") + "\n## What changed\n\n- the old summary\n\n## Why\n\nStill because.\n\n## Approvals\n\n- owner: do it\n\n## Tests\n\n- one\n");
|
||||
expect(added.stderr()).toBe("gstack-post: pr-body merged by section: replaced Why; kept ship receipt, What changed, Approvals; added Tests\n");
|
||||
|
||||
const fenced = withBody("## Notes\n\n```md\n## not a heading\n```\n\n## Why\n\nold\n", { "/b": "## Why\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], fenced)).toBe(EXIT.posted);
|
||||
expect(sent(fenced).input).toBe("## Notes\n\n```md\n## not a heading\n```\n\n## Why\n\nnew\n");
|
||||
});
|
||||
|
||||
// Value: protects=a fence closes only on a run of its own character at least as long as the opener with nothing after it, so a ``` line inside a
|
||||
// ```` block, a ```js line and a ~~~ line close nothing and the headings inside stay kept bytes, and a receipt fence that never closes is an unclosed
|
||||
// fence rather than a receipt that swallows the sections after it; fails_when=a longer or info-string fence closes early and a heading inside the
|
||||
// block is replaced (dropping the block's own closing fence and the sections after it), or an unclosed receipt fence loses ## Approvals;
|
||||
// why_new=the fenced case above covers only a three-backtick block closed by its own fence; seam=fake run; no_claim=real gh
|
||||
test("longer fences, info-string closers and an unclosed receipt fence never expose a heading inside them to the merge", () => {
|
||||
const long = "## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nold\n";
|
||||
const four = withBody(long, { "/b": "## Tests\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], four)).toBe(EXIT.posted);
|
||||
expect(sent(four).input).toBe("## Notes\n\n````md\n```\n## Tests\nfake\n```\n````\n\n## Tests\n\nnew\n");
|
||||
expect(four.stderr()).toBe("gstack-post: pr-body merged by section: replaced Tests; kept Notes; added none\n");
|
||||
|
||||
const info = withBody("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nold\n", { "/b": "## Tests\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], info)).toBe(EXIT.posted);
|
||||
expect(sent(info).input).toBe("## Notes\n\n```sh\n```js\n## Tests\n~~~\n```\n\n## Tests\n\nnew\n");
|
||||
|
||||
const unclosed = "```gstack-ship-receipt\nSHIP_RECEIPT: pr=5 head=aaaa\n\n## Summary\n\nold\n\n## Approvals\n\n- owner: do it\n";
|
||||
const open = withBody(unclosed, { "/b": RECEIPT("bbbb") + "\n## Summary\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], open)).toBe(EXIT.posted);
|
||||
expect(sent(open).input).toBe(RECEIPT("bbbb") + "\n" + unclosed + "\n## Summary\n\nnew\n");
|
||||
expect(open.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept unclosed receipt fence; added ship receipt, Summary\n");
|
||||
|
||||
const prose = withBody(unclosed, { "/b": "Updated introduction\n\n## Summary\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], prose)).toBe(EXIT.posted);
|
||||
expect(sent(prose).input).toBe("Updated introduction\n\n" + unclosed + "\n## Summary\n\nnew\n");
|
||||
|
||||
const inside = withBody("Intro\n\n## Notes\n\n" + unclosed, { "/b": "New intro\n\n## Notes\n\nnew notes\n\n## Approvals\n\n- owner: still\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], inside)).toBe(EXIT.posted);
|
||||
expect(sent(inside).input).toBe("New intro\n\n## Notes\n\nnew notes\n\n" + unclosed + "\n## Approvals\n\n- owner: still\n");
|
||||
expect(inside.stderr()).toBe("gstack-post: pr-body merged by section: replaced leading text, Notes; kept unclosed receipt fence; added Approvals\n");
|
||||
|
||||
const after = withBody(RECEIPT("aaaa") + "\n## Why\n\nold\n\n" + unclosed, { "/b": RECEIPT("bbbb") + "\n## Why\n\nnew\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], after)).toBe(EXIT.posted);
|
||||
expect(sent(after).input).toBe(RECEIPT("bbbb") + "\n## Why\n\nnew\n\n" + unclosed);
|
||||
expect(after.stderr()).toBe("gstack-post: pr-body merged by section: replaced ship receipt, Why; kept unclosed receipt fence; added none\n");
|
||||
});
|
||||
|
||||
// Value: protects=--replace keeps the plain overwrite for a caller that composed the whole body, a current body with no H2 headings is kept as the leading
|
||||
// section, an empty current body posts the new body byte for byte, the scan runs on the merged bytes, and an unreadable current body is exit 3 with
|
||||
// the --replace hint rather than an overwrite; fails_when=--replace merges, headingless text is dropped, the merge changes a lone new body, a secret in a
|
||||
// kept section passes the scan, or a failed read overwrites; why_new=first pr-body merge tests; seam=fake run; no_claim=real gh
|
||||
test("--replace overwrites; a body without H2 headings is kept as a leading section; the merged bytes are what is scanned; a failed read refuses", () => {
|
||||
const replaced = withBody(CURRENT, { "/b": "## What changed\n\nonly this\n" });
|
||||
expect(run(["pr-body", "5", "--replace", "--body-file", "/b"], replaced)).toBe(EXIT.posted);
|
||||
expect(sent(replaced).input).toBe("## What changed\n\nonly this\n");
|
||||
expect(replaced.stderr()).toBe("");
|
||||
expect(replaced.calls.some(c => c.cmd === "gh" && c.args[1] === "view")).toBe(false);
|
||||
|
||||
const plain = withBody("Just a paragraph.\nAnother line.", { "/b": "## Tests\n\n- one\n" });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], plain)).toBe(EXIT.posted);
|
||||
expect(sent(plain).input).toBe("Just a paragraph.\nAnother line.\n\n## Tests\n\n- one\n");
|
||||
expect(plain.stderr()).toBe("gstack-post: pr-body merged by section: replaced none; kept leading text; added Tests\n");
|
||||
|
||||
const empty = withBody("", { "/b": "new body" });
|
||||
expect(run(["pr-body", "6", "--body-file", "/b"], empty)).toBe(EXIT.posted);
|
||||
expect(sent(empty).input).toBe("new body");
|
||||
|
||||
const secret = fakeEnv({ files: { "/b": "## What changed\n\nclean\n" }, respond: c => (c.cmd === "gh" && c.args[1] === "view" ? { status: 0, stdout: "## Keys\n\n" + AWS + "\n" } : undefined) });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], secret)).toBe(EXIT.refused);
|
||||
expect(secret.stdout()).toContain("HIGH RULE: ");
|
||||
expect(secret.posts().filter(c => c.args[1] === "edit")).toEqual([]);
|
||||
|
||||
const unreadable = fakeEnv({ files: { "/b": "## What changed\n\nx\n" }, respond: c => (c.cmd === "gh" && c.args[1] === "view" ? { status: 1, stderr: "GraphQL: Could not resolve to a PullRequest" } : undefined) });
|
||||
expect(run(["pr-body", "5", "--body-file", "/b"], unreadable)).toBe(EXIT.cliFailed);
|
||||
expect(unreadable.stderr()).toContain("cannot read PR 5's current body");
|
||||
expect(unreadable.stderr()).toContain("--replace");
|
||||
expect(unreadable.posts()).toEqual([]);
|
||||
expect(run(["pr-comment", "5", "--replace", "--body-file", "/b"], fakeEnv({ files: { "/b": "x" } }))).toBe(EXIT.usage);
|
||||
});
|
||||
|
||||
// Value: protects=on GitLab the merge reads the MR's description from `glab mr view <n> --output json`, posts the merged bytes through
|
||||
// `glab mr update --description=`, treats an empty answer as an empty body (the file is posted unchanged), and a non-JSON answer is exit 3 with the
|
||||
// --replace hint and nothing posted; fails_when=the GitLab merge reads the wrong field or command, overwrites the MR on a failed read, or refuses an MR
|
||||
// whose description is empty; why_new=the merge tests above run against gh only; seam=fake run answers glab mr view; no_claim=real glab
|
||||
test("on GitLab the current description comes from glab mr view JSON; an empty answer is an empty body; a non-JSON answer refuses", () => {
|
||||
const remote = "https://gitlab.com/grp/sub/proj.git";
|
||||
const view = (stdout: string) => (c: Call) => (c.cmd === "glab" && c.args[0] === "mr" && c.args[1] === "view" ? { status: 0, stdout } : undefined);
|
||||
const merged = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view(JSON.stringify({ iid: 8, description: "## What changed\n\n- old\n\n## Why\n\nold\n" }) + "\n") });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], merged)).toBe(EXIT.posted);
|
||||
expect(merged.calls.find(c => c.cmd === "glab" && c.args[1] === "view")!.args).toEqual(["mr", "view", "8", "--output", "json"]);
|
||||
expect(merged.posts()).toEqual([{ cmd: "glab", args: ["mr", "update", "8", "--description=## What changed\n\n- old\n\n## Why\n\nnew\n"], input: undefined }]);
|
||||
expect(merged.stderr()).toBe("gstack-post: pr-body merged by section: replaced Why; kept What changed; added none\n");
|
||||
|
||||
const empty = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("") });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], empty)).toBe(EXIT.posted);
|
||||
expect(empty.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]);
|
||||
const nullDescription = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view('{"iid":8,"description":null}\n') });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], nullDescription)).toBe(EXIT.posted);
|
||||
expect(nullDescription.posts()[0]!.args).toEqual(["mr", "update", "8", "--description=## Why\n\nnew\n"]);
|
||||
|
||||
const garbled = fakeEnv({ remote, files: { "/b": "## Why\n\nnew\n" }, respond: view("<html>502</html>\n") });
|
||||
expect(run(["pr-body", "8", "--body-file", "/b"], garbled)).toBe(EXIT.cliFailed);
|
||||
expect(garbled.stderr()).toContain("cannot read PR 8's current body to merge into (glab returned no JSON)");
|
||||
expect(garbled.stderr()).toContain("--replace");
|
||||
expect(garbled.posts()).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=section matching survives the bodies GitHub actually returns and the shapes /ship composes: a CRLF current body (web-edited) keeps
|
||||
// its kept section's bytes and still replaces the same-titled section, a receipt new to a body without one goes first (where gstack-ship-receipt puts
|
||||
// it), a current body with two same-titled sections replaces the first and keeps the second, and a current body with no trailing newline still gets a
|
||||
// blank-line seam before an appended section; fails_when=a CRLF title no longer matches an LF one (the section is appended as a duplicate), a kept
|
||||
// CRLF section is rewritten, a new receipt lands after the sections, the second duplicate is dropped, or an appended section is glued to the last line;
|
||||
// why_new=the merge tests above use LF bodies with unique titles and a receipt that already exists; seam=none (mergeBodySections); no_claim=rendering
|
||||
test("a CRLF current body, a receipt new to the body, duplicate titles and a body without a trailing newline merge by section", () => {
|
||||
const crlfKept = "## What changed\r\n\r\n- old\r\n\r\n";
|
||||
const crlf = mergeBodySections(crlfKept + "## Why\r\n\r\nBecause.\r\n", "## why\n\nnew\n");
|
||||
expect([crlf.replaced, crlf.kept, crlf.added]).toEqual([["Why"], ["What changed"], []]);
|
||||
expect(crlf.body.startsWith(crlfKept)).toBe(true);
|
||||
expect(crlf.body.endsWith("\n## why\n\nnew\n")).toBe(true);
|
||||
expect(crlf.body).not.toContain("Because.");
|
||||
expect(crlf.body.match(/^ {0,3}## /gm)).toHaveLength(2);
|
||||
|
||||
const receipt = "```gstack-ship-receipt\n{\n \"pr\": \"5\"\n}\n```\n";
|
||||
const fresh = mergeBodySections("## A\n\na\n", receipt + "## A\n\nb\n");
|
||||
expect(fresh.body).toBe(receipt + "\n## A\n\nb\n");
|
||||
expect([fresh.replaced, fresh.kept, fresh.added]).toEqual([["A"], [], ["ship receipt"]]);
|
||||
|
||||
const twice = mergeBodySections("## A\na\n## A\na2\n", "## A\nnew\n");
|
||||
expect(twice.body).toBe("## A\nnew\n\n## A\na2\n");
|
||||
expect([twice.replaced, twice.kept]).toEqual([["A"], ["A"]]);
|
||||
|
||||
const glued = mergeBodySections("## A\n\na", "## B\n\nb");
|
||||
expect(glued.body).toBe("## A\n\na\n\n## B\n\nb");
|
||||
expect(mergeBodySections("## A\n\na\n\n", "## B\n\nb\n").body).toBe("## A\n\na\n\n## B\n\nb\n");
|
||||
});
|
||||
|
||||
// Value: protects=the body /ship composes (lib/ship-receipt.ts placeReceiptFirst: the receipt block, one blank line, then the sections) reposts
|
||||
// idempotently: merged into a PR body of the same shape it yields the file's bytes, the receipt stays byte-first, and the stderr line reports no
|
||||
// addition; fails_when=the blank line after the receipt is read as new leading text and prepended before the receipt (one more leading newline per
|
||||
// repost, "added leading text" on every /ship re-READY), or a kept blank line is counted as added; why_new=the merge tests above post a receipt-only
|
||||
// file or a file without a receipt, never the composed shape; seam=none (mergeBodySections); no_claim=the gh round trip
|
||||
test("the composed receipt-then-sections body reposts byte for byte, receipt first, with nothing reported as added", () => {
|
||||
const receipt = (head: string) => "```gstack-ship-receipt\n{\n \"head\": \"" + head + "\"\n}\n```\n";
|
||||
const composed = (head: string, summary: string) => receipt(head) + "\n## Summary\n\n" + summary + "\n\n## Test plan\n\n- [x] bun test\n";
|
||||
const first = mergeBodySections(composed("aaaa", "old"), composed("bbbb", "new"));
|
||||
expect(first.body).toBe(composed("bbbb", "new"));
|
||||
expect([first.replaced, first.kept, first.added]).toEqual([["ship receipt", "Summary", "Test plan"], [], []]);
|
||||
const again = mergeBodySections(first.body, composed("bbbb", "new"));
|
||||
expect(again.body).toBe(composed("bbbb", "new"));
|
||||
expect(again.added).toEqual([]);
|
||||
});
|
||||
|
||||
// Value: protects=leading prose in the file replaces the current body's leading prose instead of being prepended again, so a repost of a file that
|
||||
// opens with text keeps one copy; fails_when=text sections are never matched and the file's leading text is treated as fresh on every post;
|
||||
// why_new=the merge tests above post files that open with a heading or a receipt, never with prose; seam=none (mergeBodySections); no_claim=the gh round trip
|
||||
test("leading prose in the file replaces the current leading prose and reposts once", () => {
|
||||
const current = "Intro line\n\n## Summary\n\nold\n\n## Approvals\n\nx\n";
|
||||
const once = mergeBodySections(current, "Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(once.body).toBe("Intro line\n\n## Summary\n\nnew\n\n## Approvals\n\nx\n");
|
||||
expect([once.replaced, once.kept, once.added]).toEqual([["leading text", "Summary"], ["Approvals"], []]);
|
||||
const twice = mergeBodySections(once.body, "Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(twice.body).toBe(once.body);
|
||||
const fresh = mergeBodySections("## Summary\n\nold\n", "Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(fresh.body).toBe("Intro line\n\n## Summary\n\nnew\n");
|
||||
expect(fresh.added).toEqual(["leading text"]);
|
||||
const receipt = "```gstack-ship-receipt\n{\n \"head\": \"a\"\n}\n```\n";
|
||||
const afterReceipt = mergeBodySections("Intro line\n\n## Summary\n\nold\n", receipt + "note\n\n## Summary\n\nnew\n");
|
||||
expect(afterReceipt.body).toContain("Intro line\n");
|
||||
expect(afterReceipt.body).toContain("note\n");
|
||||
expect(afterReceipt.replaced).toEqual(["Summary"]);
|
||||
});
|
||||
});
|
||||
@@ -292,6 +292,70 @@ describe('ready', () => {
|
||||
const stalePregate = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } }));
|
||||
expect(stalePregate.status).toBe(EXIT.refused);
|
||||
expect(stalePregate.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
|
||||
// A lane that cleared its pregate from a separate clone (gstack-pregate --remote cannot run in a worktree) holds the verdict under
|
||||
// <state root>/pregate/by-tree/<tree>.json; ready reads that mirror for the current tree when its own record is stale or missing, and only a run
|
||||
// that holds the tests stage (what `--stage tests --remote` writes: checks=[lanes]) against origin/main under a reviewed policy stands in: a
|
||||
// --only timing run, another base or a head policy is named on the PREGATE line instead.
|
||||
const stateRoot = path.join(repo.root, 'state-root');
|
||||
const complete = { base: `origin/main@${repo.git('rev-parse', 'origin/main').slice(0, 12)}`, policy: 'origin/main@abc (no .gstack/pregate.json)', checks: [{ id: 'lanes', status: 'pass' }] };
|
||||
const fromSuffix = `(from the state root's pregate/by-tree/${'TREE'}.json, recorded by another checkout)`;
|
||||
const movedShim = ghQueueShim(repo, { prs: { '1': { headRefOid: moved, body: shippedBody('1', moved) } }, checks: { [moved]: green } });
|
||||
const movedTree = runBin(repo, 'gstack-wtree', []).out.trim();
|
||||
const mirror = path.join(stateRoot, 'pregate', 'by-tree', `${movedTree}.json`);
|
||||
fs.mkdirSync(path.dirname(mirror), { recursive: true });
|
||||
fs.writeFileSync(path.join(stateRoot, 'pregate', 'by-tree', `${'0'.repeat(movedTree.length)}.json`), JSON.stringify({ schema_version: 1, tree: '0'.repeat(movedTree.length), head: moved, verdict: 'pass', ...complete }));
|
||||
const otherTree = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(otherTree.status).toBe(EXIT.refused);
|
||||
expect(otherTree.out).toMatch(/^PREGATE: pass .* \(PREGATE_STALE\)$/m);
|
||||
const missing = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.out).toMatch(/^PREGATE: missing \(\.gstack\/tmp\/pregate\.json; run gstack-pregate run --allow-repo-commands\)$/m);
|
||||
const suffix = fromSuffix.replace('TREE', movedTree.slice(0, 12));
|
||||
const partial = { ...complete, checks: [{ id: 'timing', status: 'pass' }] };
|
||||
for (const [bad, why] of [[partial, 'a run without the tests stage (lanes)'], [{ ...complete, base: 'origin/develop@0123456789ab' }, 'recorded against origin/develop@0123456789ab, not origin/main'], [{ ...complete, policy: 'head (unreviewed) .gstack/pregate.json' }, 'recorded under the unreviewed head policy']] as const) {
|
||||
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...bad }));
|
||||
const ignored = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(ignored.status).toBe(EXIT.refused);
|
||||
expect(line(ignored.out, 'PREGATE:')).toBe(`PREGATE: missing (.gstack/tmp/pregate.json; run gstack-pregate run --allow-repo-commands); the state root's mirror for this tree cannot stand in: ${why}`);
|
||||
}
|
||||
fs.writeFileSync(mirror, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'pass', ...complete }));
|
||||
const fromMirror = queue(repo, ['ready', '--pr', '1', '--pregate', pregate], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([fromMirror.status, fromMirror.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(fromMirror.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 ${suffix}`);
|
||||
const missingFromMirror = queue(repo, ['ready', '--pr', '1'], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([missingFromMirror.status, missingFromMirror.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(missingFromMirror.out, 'PREGATE:')).toContain(suffix);
|
||||
const current = queue(repo, ['ready', '--pr', '1', '--pregate', pregateFor(repo, moved)], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([current.status, current.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(current.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1`);
|
||||
// Value: protects=a same-tree record that only waits on a remote receipt (the small-machine run) is cleared by the clone's mirror, an
|
||||
// unreadable record falls through to the mirror, and a record whose own checks failed or stayed incomplete is never overridden by a green mirror,
|
||||
// including an incomplete check the environment caused (PREGATE_ENVIRONMENT, #3480: a secrets scanner that never finished is not a scanned diff);
|
||||
// fails_when=ready reads the mirror only for another tree (the documented lane case refuses PREGATE_REMOTE_UNCLEARED) or lets a mirror hide a red
|
||||
// or incomplete local run; why_new=the rows above cover only stale, missing and current records; seam=none; no_claim=the mirror's writer
|
||||
const local = path.join(repo.root, 'pregate-local.json');
|
||||
const obligation = { lane: 'pregate.json/remote_gate', platform: 'remote', workflow: 'pregate.json', job: 'remote_gate', tests: ['test/a.test.ts'], receipt: null };
|
||||
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'requires-remote', checks: [{ id: 'lanes', status: 'requires-remote' }], requires_remote: [obligation] }));
|
||||
const uncleared = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([uncleared.status, uncleared.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(uncleared.out, 'PREGATE:')).toBe(`PREGATE: pass tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 ${suffix}`);
|
||||
fs.writeFileSync(local, '{ not json');
|
||||
const unreadable = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect([unreadable.status, unreadable.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(unreadable.out, 'PREGATE:')).toContain(suffix);
|
||||
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'fail', checks: [{ id: 'lanes', status: 'fail' }] }));
|
||||
const redLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(redLocal.status).toBe(EXIT.refused);
|
||||
expect(line(redLocal.out, 'PREGATE:')).toBe(`PREGATE: fail tree=${movedTree.slice(0, 12)} head=${moved.slice(0, 12)} checks=1 — 1 check(s) failed: lanes (PREGATE_LANE_FAILED)`);
|
||||
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'incomplete', checks: [{ id: 'lanes', status: 'incomplete' }] }));
|
||||
const incompleteLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(incompleteLocal.status).toBe(EXIT.refused);
|
||||
expect(line(incompleteLocal.out, 'PREGATE:')).toMatch(/\(PREGATE_INCOMPLETE\)$/);
|
||||
fs.writeFileSync(local, JSON.stringify({ schema_version: 1, tree: movedTree, head: moved, verdict: 'incomplete', checks: [{ id: 'secrets', status: 'incomplete', cause: 'environment', environment: { kind: 'missing-binary', evidence: 'gstack-redact: not on PATH', proof: 'launch' } }] }));
|
||||
const environmentLocal = queue(repo, ['ready', '--pr', '1', '--pregate', local], { ...movedShim, GSTACK_STATE_ROOT: stateRoot });
|
||||
expect(environmentLocal.status).toBe(EXIT.refused);
|
||||
expect(line(environmentLocal.out, 'PREGATE:')).toMatch(/\(PREGATE_ENVIRONMENT\)$/);
|
||||
expect(environmentLocal.out).not.toContain(suffix);
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-a.txt'), 'dirty\n');
|
||||
expect(queue(repo, ['ready', '--pr', '1'], shim(moved, green)).err).toMatch(/uncommitted tracked changes: pr-a\.txt.*\(QUEUE_NOT_READY\)/);
|
||||
}, 180_000);
|
||||
|
||||
@@ -732,6 +732,31 @@ do not launch the downstream skill or open a browser.`,
|
||||
mustContain: ['retrospective', '45-minute gap', 'Ship of the week', 'Praise'],
|
||||
},
|
||||
|
||||
// ── GSTA-92 Phase 2 (2026-10-25): design-review carved on every host ──
|
||||
// One lazy section, the craft audit, read for a Persuade page or a redesign;
|
||||
// CARVED_ON_EVERY_HOST in scripts/resolvers/sections.ts keeps it out of the
|
||||
// external skeletons too (Garry 2026-10-25 'Do it' (GSTA-92), Big decision 2a).
|
||||
'design-review': {
|
||||
skill: 'design-review',
|
||||
expectedSections: ['craft-audit.md'],
|
||||
requiredReads: ['craft-audit.md'],
|
||||
scenario:
|
||||
'Walk /design-review in SIMULATION — do not run bash, launch a browser, or edit files. The target is the landing page of a developer-tools SaaS (a Persuade page): treat Setup and Phase 1 as done. Read each pointed section before doing its step, then list the Phases 1-6 checks you would run on that page, including any section-specific check, and stop before Phase 7.',
|
||||
staticInvariants: {
|
||||
mustStayInSkeleton: ['## Phases 1-6: Design Audit Baseline', '**9. AI Slop Detection**', '## Phase 7: Triage', '## Phase 8: Fix Loop'],
|
||||
mustPrecedeStop: ['### Design Hard Rules'],
|
||||
mustMoveToSection: ['### 4. Fix order and stack (Phase 8)'],
|
||||
gateAfterStop: undefined,
|
||||
},
|
||||
// No section-loading paid case yet: a new periodic case would push the
|
||||
// periodic census past its 38-slice cap (test/paid-retry-supervision.test.ts).
|
||||
behavioral: 'none',
|
||||
maxSkeletonBytes: 137_767, // measured 137767 (2026-10-26; 137773 on 2026-10-25 before the shorter craft-audit trigger): the Section index the external-host carve test requires, its craft-audit row, and the STOP pointer (GSTA-92; Garry 2026-10-25 'Do it' (GSTA-92)).
|
||||
minUnionBytes: 143_300, // measured union 145037 (2026-10-26)
|
||||
mustContain: ['AI Slop Detection', 'Fix Loop', 'gstack-design-craft', 'Proof beside each claim', 'holds session data'],
|
||||
maxSizeRatio: 1.3704, // first parity guard for design-review: measured 145082 / 105,842 = 1.3708 (2026-10-25; 145077 after the /ship review fixes, 145068 = 1.3707 after the line-13 reword, 145049 = 1.3705 after the post-merge /ship review, 145037 = 1.3704 after its second pass, 2026-10-26), the skeleton's growth since v1.64.1.0 plus the craft audit (Garry 2026-10-25 'Do it' (GSTA-92)).
|
||||
},
|
||||
|
||||
// ── Token-reduction Phase 4 wave 4 (v1.69.x branch): design doctrine carve ──
|
||||
// (D3A: read-on-demand doctrine, requiredReads-guarded + loading eval)
|
||||
'design-html': {
|
||||
@@ -791,6 +816,42 @@ do not launch the downstream skill or open a browser.`,
|
||||
mustContain: ["Don't make me think", "Users scan, they don't read", 'trunk test', '44px minimum'],
|
||||
},
|
||||
|
||||
// ── GSTA-92 plan item 15 (Garry 2026-10-25 "Do it"): /design-motion ──
|
||||
// Fidelity, motion and the optional Figma path are lazy; the doctrine, the
|
||||
// capture/check/export steps and their bins stay always loaded. The paid
|
||||
// design-motion-showcase case drives a fixture page through the whole flow
|
||||
// and asserts the fidelity and motion reads.
|
||||
'design-motion': {
|
||||
skill: 'design-motion',
|
||||
expectedSections: ['fidelity.md', 'motion.md', 'figma.md'],
|
||||
requiredReads: ['fidelity.md', 'motion.md'],
|
||||
scenario:
|
||||
'Covered by the design-motion-showcase case in test/skill-e2e-design.test.ts: a finalized.html fixture is rebuilt into a page with the capture contract, captured, checked and exported.',
|
||||
staticInvariants: {
|
||||
mustStayInSkeleton: [
|
||||
'## Motion doctrine',
|
||||
'one authored motion moment per page',
|
||||
'## Step 5: Capture',
|
||||
'## Step 6: Motion checks (before any export)',
|
||||
'gstack-motion-encode check',
|
||||
'## Step 7: Export and report',
|
||||
'## Important Rules',
|
||||
],
|
||||
mustMoveToSection: [
|
||||
'### Loading choreography',
|
||||
'### Stories: one per visual',
|
||||
'### Page architecture and the capture contract',
|
||||
'### Layer export',
|
||||
],
|
||||
gateAfterStop: undefined,
|
||||
},
|
||||
behavioral: 'external',
|
||||
externalTest: 'test/skill-e2e-design.test.ts',
|
||||
maxSkeletonBytes: 39_900, // new skill (GSTA-92 plan item 15, Garry 2026-10-25 "Do it"); measured skeleton 39,836 (2026-10-25)
|
||||
minUnionBytes: 57_500, // measured union 58,666 (2026-10-25)
|
||||
mustContain: ['window.__start()', 'window.__tick(dt)', 'whole milliseconds averaging 1000/60', 'Content never hides behind animation timing'],
|
||||
},
|
||||
|
||||
// ── Multi-agent wave PR D3 (2026-10-10): investigate recipes carve ──
|
||||
// Five lazy recipes (flake, polluter, race, eval-bisect, backlog) read when
|
||||
// the Phase 2 pattern or the --backlog argument matches; Phases 1–5, the
|
||||
|
||||
@@ -73,6 +73,13 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
|
||||
'<slug>': { grammar: ID, what: 'slug' },
|
||||
'<dimension>': { grammar: ID, what: 'design dimension slug' },
|
||||
'<dim>': { grammar: ID, what: 'profile dimension name' },
|
||||
// /design-motion (GSTA-92 plan item 15): capture and export arguments the skill's plan fixes.
|
||||
'<frame-size>': { grammar: /^[0-9]+x[0-9]+$/, what: 'design frame size in CSS pixels, WxH' },
|
||||
'<cycle-seconds>': { grammar: NUMBER, what: 'loop cycle length in seconds' },
|
||||
'<app|cards>': { grammar: oneOf('app', 'cards'), what: 'motion layout: joined app surfaces or separate cards' },
|
||||
'<hex-color>': { grammar: /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/, what: 'page background colour as hex', quoted: true },
|
||||
'<figma-file-key>': { grammar: ID, what: 'Figma file key from the link' },
|
||||
'<figma-node-id>': { grammar: /^[0-9]+[:-][0-9]+$/, what: 'Figma node id from the link' },
|
||||
'<new_value>': { grammar: NUMBER, what: 'profile dimension value' },
|
||||
'<Q1_VALUE>': { grammar: NUMBER, what: 'profile answer value' },
|
||||
'<Q2_VALUE>': { grammar: NUMBER, what: 'profile answer value' },
|
||||
@@ -140,7 +147,9 @@ export const IDENTIFIER_PLACEHOLDERS: Record<string, IdentifierGrammar> = {
|
||||
'<plan-path>': { grammar: PATH, what: 'bound plan file path' },
|
||||
'<sketch-dir>': { grammar: PATH, what: 'sketch directory printed by an earlier block' },
|
||||
'<path-to-finalized.html>': { grammar: PATH, what: 'finalized HTML path' },
|
||||
'<path-to-page.html>': { grammar: PATH, what: 'saved page HTML path' },
|
||||
'<finalized.html>': { grammar: PATH, what: 'finalized HTML path' },
|
||||
'<motion-dir>': { grammar: PATH, what: 'run directory printed by the /design-motion Step 0 MOTION_DIR line' },
|
||||
'<approved-variant.png>': { grammar: PATH, what: 'approved variant image path' },
|
||||
'<failing-test-file>': { grammar: PATH, what: 'test file path' },
|
||||
'<source-file-under-test>': { grammar: PATH, what: 'source file path' },
|
||||
|
||||
@@ -31,13 +31,22 @@ export interface Rule {
|
||||
plan?: { planned: number } | { manifest: unknown } | null;
|
||||
/** Marks every artifact of runs dispatched under this rule expired in the artifacts listing. */
|
||||
expired?: boolean;
|
||||
/** A screen run's gate binding (gate-binding-a1/gate-binding.json, scripts/gate-reuse.ts) and its report's collector-outcomes.json (report-verdict-a1). */
|
||||
binding?: { verdict: 'GREEN' | 'RED'; reconcile_exit?: number; flaky?: string[] };
|
||||
outcomes?: { red?: string[]; flakyLines?: string[] };
|
||||
/** The run confirmed a verdict reuse (R2a/R2): paid-plan carries a valid gate-reuse.json naming this run and no binding is uploaded. */
|
||||
reuse?: { run: number; flaky?: string[] };
|
||||
check?: string | null;
|
||||
/** The check-run name a run dispatched under this rule attaches to its head (a push or workflow_dispatch run's job), with the run's status and conclusion. */
|
||||
ci?: string;
|
||||
bisect?: { prefixes: string[]; patterns: Record<string, string>; costUsd?: number };
|
||||
}
|
||||
export interface ShimData {
|
||||
repo: string; origin: string; nextId: number; prs: Record<string, unknown>; runs: Array<Record<string, unknown>>;
|
||||
artifacts: Record<string, Record<string, unknown>>; checks: Record<string, Array<{ name: string; status: string; conclusion: string | null }>>;
|
||||
ci: Record<string, unknown[]>; rules: Record<string, Rule>; artifact: string; log: Array<Record<string, unknown>>;
|
||||
/** Commit statuses (the legacy status API) at a sha, beside the check runs in `ci`. */
|
||||
statuses?: Record<string, Array<{ state: string; context: string }>>;
|
||||
}
|
||||
|
||||
export interface TrainFixture { repo: FixtureRepo; state: string; dataFile: string; env: Record<string, string>; read: () => ShimData; write: (fn: (d: ShimData) => void) => void }
|
||||
@@ -163,6 +172,13 @@ function shimSource(): string {
|
||||
" if (rule.record && !inputs.prefix_shas) d.artifacts[id][d.artifact] = { schema: 1, kind: 'train-gate', nonce: rule.record.nonce || inputs.nonce, head_sha: head, tree: rev(head + '^{tree}'),",
|
||||
" verdict: rule.record.verdict || 'GREEN', census: rule.record.census, panels: rule.record.panels };",
|
||||
" if (rule.check && !inputs.prefix_shas) (d.checks[head] = d.checks[head] || []).push({ name: 'gstack/full-gate', status: 'completed', conclusion: rule.check });",
|
||||
" if (rule.ci) (d.ci[head] = d.ci[head] || []).push({ name: rule.ci, status: run.status, conclusion: run.conclusion, id: id, completed_at: '2026-10-26T00:00:00Z' });",
|
||||
" if (rule.binding) d.artifacts[id]['gate-binding-a1'] = { schema: 1, repository: d.repo, pr: Number(inputs.screen_pr) || 0, run_id: id, run_attempt: 1, head_sha: rev(head + '^2') || head, base_sha: rev(head + '^1') || head,",
|
||||
" profile: 'pr', reconcile_exit: rule.binding.reconcile_exit === undefined ? (rule.binding.verdict === 'GREEN' ? 0 : 1) : rule.binding.reconcile_exit, verdict: rule.binding.verdict, manual_accepted: 0, selection_sha256: 'a'.repeat(64),",
|
||||
" flaky_cases: rule.binding.flaky || [], purpose: 'screen', tested_sha: head, screen_policy: inputs.screen_policy || 'narrow' };",
|
||||
" if (rule.reuse && d.artifacts[id]['paid-plan']) d.artifacts[id]['paid-plan'].__reuse = { schema: 1, kind: 'main-moved', pr: Number(inputs.screen_pr) || 0, reused_run_id: rule.reuse.run, reused_run_attempt: 1, gated_head: 'b'.repeat(40), current_head: 'c'.repeat(40),",
|
||||
" gated_base: 'd'.repeat(40), base_sha: 'e'.repeat(40), selection_sha256: 'a'.repeat(64), why: 'same own diff on a moved main', gated_merge_base: 'f'.repeat(40), merge_base: '1'.repeat(40), own_diff_sha256: 'b'.repeat(64), reused_screen_policy: 'narrow', reused_flaky: rule.reuse.flaky || [] };",
|
||||
" if (rule.outcomes) d.artifacts[id]['report-verdict-a1'] = { version: 2, panels: (rule.outcomes.red || []).map(c => ({ case: c, kind: 'judge', status: 'FAIL', failsLane: true, redClass: 'VERDICT' })), flakyLines: rule.outcomes.flakyLines || [] };",
|
||||
" if (rule.bisect && inputs.prefix_shas) for (const sha of inputs.prefix_shas.split(',')) {",
|
||||
" const at = rule.bisect.prefixes.indexOf(sha);",
|
||||
" d.artifacts[id][d.artifact + '-bisect-' + sha.slice(0, 12)] = { schema: 1, kind: 'train-bisect-prefix', nonce: inputs.nonce, prefix_sha: sha, checked_out_sha: sha,",
|
||||
@@ -178,8 +194,9 @@ function shimSource(): string {
|
||||
"if (a[0] === 'run' && a[1] === 'view') { const r = d.runs.find(x => String(x.databaseId) === a[2]); if (!r) fail('run not found'); out(r); }",
|
||||
"if (a[0] === 'run' && a[1] === 'download') {",
|
||||
" const art = (d.artifacts[a[2]] || {})[opt('-n')]; if (!art) fail('no artifact matches');",
|
||||
" fs.mkdirSync(opt('-D'), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), opt('-n') === 'paid-plan' ? 'manifest.json' : 'record.json'), JSON.stringify(art));",
|
||||
" if (opt('-n') === 'paid-plan') fs.writeFileSync(path.join(opt('-D'), 'gate-reuse.json'), '{\"reuse\":\"confirmed\"}'); save(); process.exit(0);",
|
||||
" const fileFor = n => n === 'paid-plan' ? 'manifest.json' : n.startsWith('gate-binding-') ? 'gate-binding.json' : n.startsWith('report-verdict-') ? 'paid-report/collector-outcomes.json' : 'record.json';",
|
||||
" fs.mkdirSync(path.dirname(path.join(opt('-D'), fileFor(opt('-n')))), { recursive: true }); fs.writeFileSync(path.join(opt('-D'), fileFor(opt('-n'))), JSON.stringify(art));",
|
||||
" if (opt('-n') === 'paid-plan') fs.writeFileSync(path.join(opt('-D'), 'gate-reuse.json'), art.__reuse ? JSON.stringify(art.__reuse) : '{\"reuse\":\"confirmed\"}'); save(); process.exit(0);",
|
||||
"}",
|
||||
"if (a[0] === 'api') {",
|
||||
" const arts = /actions\\/runs\\/(\\d+)\\/artifacts(?:\\?name=([^&]+))?/.exec(a[1] || '');",
|
||||
@@ -187,7 +204,7 @@ function shimSource(): string {
|
||||
" const named = /commits\\/([0-9a-f]+)\\/check-runs\\?check_name=([^&]+)/.exec(a[1] || '');",
|
||||
" if (named) { const list = (d.checks[named[1]] || []).filter(c => c.name === decodeURIComponent(named[2])); out({ total_count: list.length, check_runs: list }); }",
|
||||
" const paged = /commits\\/([0-9a-f]+)\\/(check-runs|status)\\?.*page=(\\d+)/.exec(a[1] || '');",
|
||||
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : []; const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
|
||||
" if (paged) { const list = paged[2] === 'check-runs' ? (d.ci[paged[1]] || []) : ((d.statuses || {})[paged[1]] || []); const key = paged[2] === 'check-runs' ? 'check_runs' : 'statuses';",
|
||||
" out(paged[3] === '1' ? { total_count: list.length, state: 'success', [key]: list } : { total_count: list.length, [key]: [] }); }",
|
||||
"}",
|
||||
"fail('gh shim: unsupported ' + a.join(' '));",
|
||||
|
||||
@@ -263,7 +263,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
"scripts/resolvers/preamble/generate-preamble-bash.ts",
|
||||
'test/fixtures/pty-companion-cli.ts', 'test/helpers/plan-seed-submission.ts', 'test/fixtures/plan-seed-cli.ts', 'test/helpers/owned-claude-transcript.ts', 'lib/fs-atomic.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'test/helpers/plan-mode-evidence.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-fixture.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'bin/gstack-context-recovery'],
|
||||
// SDK plan-edit case in test/skill-e2e-design.test.ts (claude -p edits plan.md).
|
||||
'plan-design-review-plan-mode': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'plan-design-review/**', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts',
|
||||
'plan-design-review-plan-mode': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'plan-design-review/**', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts',
|
||||
'scripts/resolvers/preamble.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completion-status.ts',
|
||||
'lib/eval-model.ts', 'test/skill-e2e-design.test.ts',
|
||||
'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/fake-impeccable.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts', 'test/helpers/pty/**'],
|
||||
@@ -383,7 +383,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
|
||||
|
||||
"scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts",
|
||||
'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/plan-review-board-feedback.ts', 'test/fixtures/design-board-questions.json', 'test/fixtures/design-outside-voices-question.json', 'design/src/daemon-state.ts', 'design/src/daemon.ts', 'design/test/daemon-tests-fixtures.ts', 'design/src/daemon-client.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-paths', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
|
||||
'test/fixtures/pty-companion-cli.ts', 'lib/fs-atomic.ts', 'test/helpers/ceo-finding-fixture.ts', 'test/helpers/plan-review-cases.ts', 'test/helpers/plan-review-board-feedback.ts', 'test/fixtures/design-board-questions.json', 'test/fixtures/design-outside-voices-question.json', 'design/src/daemon-state.ts', 'design/src/daemon.ts', 'design/test/daemon-tests-fixtures.ts', 'design/src/daemon-client.ts', 'test/helpers/owned-claude-transcript.ts', 'test/helpers/plan-skill-questions.ts', 'test/fixtures/eng-auq-validation-error.json', 'test/fixtures/bash-directory-permission.json', 'test/fixtures/design-tasks-bash-permission.json', 'test/fixtures/read-permission.json', 'test/fixtures/ceo-split-e5-numbered-description-491.json', 'test/helpers/plan-skill-question-events.ts', 'test/helpers/plan-skill-question-hook-scope.ts', 'test/helpers/skill-census.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'bin/gstack-paths', 'bin/gstack-slug', 'bin/gstack-remote-identity.sh', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'test/helpers/auto-decision-state.ts', 'test/helpers/autoplan-artifact-digest.ts', 'test/helpers/autoplan-artifact-permission.ts', 'test/helpers/autoplan-artifact-recorder.ts', 'test/helpers/dx-selected-navigation.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/eng-cache-writer-decision.ts', 'test/helpers/native-auto-decide.ts', 'test/helpers/plan-count-artifacts.ts', 'test/helpers/plan-count-file-permission.ts', 'test/helpers/plan-count-pending-exit.ts', 'test/helpers/plan-count-pending-question.ts', 'test/helpers/plan-floor-review.ts', 'test/helpers/plan-floor-target.ts', 'test/helpers/plan-scope-selection.ts', 'test/helpers/plan-seed-submission.ts'],
|
||||
'tpa-present': [ 'scripts/resolvers/third-party-actions.ts', 'ship/SKILL.md.tmpl', 'ship/sections/apple-release.md.tmpl', 'scripts/gen-skill-docs.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-third-party-actions.test.ts', 'test/helpers/third-party-actions.ts',
|
||||
'lib/eval-model.ts'
|
||||
],
|
||||
@@ -848,15 +848,16 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
|
||||
|
||||
"scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts",
|
||||
'scripts/resolvers/preamble/generate-ask-user-format.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/fake-impeccable.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-review-fix': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/aside.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'browse/src/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts',
|
||||
'design-review-fix': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/aside.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'browse/src/**', 'scripts/gen-skill-docs.ts', 'test/skill-e2e-design.test.ts',
|
||||
'scripts/resolvers/testing.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/fake-impeccable.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
// Design detector (user-installed impeccable engine) through the fake engine shim: source mode on a diff and DOM mode on a served page.
|
||||
'design-review-detector-shim': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
|
||||
'design-review-detector-shim': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
|
||||
'scripts/resolvers/testing.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-review-detector-shim-dom': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
|
||||
'design-review-detector-shim-dom': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'lib/dom-dump-script.ts', 'lib/dom-dump.js', 'bin/gstack-design-detect.ts', 'browse/src/**', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.*', 'test/skill-e2e-design.test.ts',
|
||||
'scripts/resolvers/testing.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-review-plugin-handoff': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/skill-e2e-design.test.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-craft.ts', 'bin/gstack-design-craft', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-review-plugin-handoff': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-review/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/testing.ts', 'lib/design-catalog.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'test/helpers/hermetic-env.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/skill-e2e-design.test.ts', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-html-slop-gate': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'design-html/**', 'scripts/resolvers/design.ts', 'scripts/resolvers/outside-voice.ts', 'lib/design-detect-contract.ts', 'bin/gstack-design-detect.ts', 'lib/design-craft.ts', 'lib/design-catalog.ts', 'bin/gstack-design-craft', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/impeccable-detect-sample.json', 'test/skill-e2e-design.test.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
'design-motion-showcase': ['design-motion/**', 'bin/gstack-motion-capture.ts', 'bin/gstack-motion-encode', 'lib/motion-capture.ts', 'lib/motion-encode.ts', 'test/fixtures/design-motion-finalized.html', 'bin/gstack-browser-ensure', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/skill-e2e-design.test.ts', 'test/helpers/fake-impeccable.ts', 'test/fixtures/fake-impeccable.ts', 'test/fixtures/review-eval-design-slop.html', 'test/fixtures/review-eval-design-slop.css', 'test/helpers/aside-available.ts', 'test/helpers/llm-judge.ts', 'test/helpers/office-hours-attempt.ts'],
|
||||
|
||||
// /diagram (diagram-render bundle consumers). Triplet = deterministic
|
||||
// functional (gate); authoring quality = LLM-judged benchmark (periodic).
|
||||
@@ -1448,6 +1449,7 @@ export const E2E_TIERS: Record<string, 'gate' | 'periodic' | 'marathon'> = {
|
||||
'design-review-detector-shim-dom': 'gate', // same shim, DOM mode through the browse binary's dump; self-skips when the binary is absent
|
||||
'design-review-plugin-handoff': 'gate',
|
||||
'design-html-slop-gate': 'periodic', // one-pass gate behavior is a judgment call on a fake engine's fixed output
|
||||
'design-motion-showcase': 'marathon', // a full capture-check-export flow (3-6 min a trial) that needs ffmpeg and Chromium; the periodic census is at its 38-slice cap
|
||||
|
||||
// /diagram — triplet is deterministic functional (gstack-render falls back
|
||||
// to the browse daemon, so CI runs it); judge is a quality benchmark
|
||||
@@ -1573,7 +1575,7 @@ export const LLM_JUDGE_TOUCHFILES: Record<string, string[]> = {
|
||||
|
||||
|
||||
"scripts/resolvers/preamble/generate-preamble-bash.ts", "scripts/resolvers/preamble/generate-completion-status.ts",
|
||||
'plan-design-review/sections/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/fixtures/eval-baselines.json', 'test/helpers/cookie-workflow-judge-input.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/helpers/llm-judge.ts'],
|
||||
'plan-design-review/sections/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/design.ts', 'scripts/resolvers/design-craft.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'test/fixtures/eval-baselines.json', 'test/helpers/cookie-workflow-judge-input.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/helpers/llm-judge.ts'],
|
||||
|
||||
// Design skills
|
||||
'design-review/SKILL.md fix loop': ['design-review/SKILL.md', 'design-review/SKILL.md.tmpl', 'test/skill-llm-eval.test.ts', 'test/helpers/workflow-judge-input.ts', 'test/helpers/workflow-judge-cache.ts', 'scripts/eval-input-cache.ts', 'test/helpers/workflow-excerpt.ts', 'test/fixtures/eval-baselines.json', 'test/helpers/cookie-workflow-judge-input.ts', 'test/helpers/cookie-workflow-manual-review.ts', 'test/helpers/llm-judge.ts'],
|
||||
@@ -1856,6 +1858,7 @@ export const E2E_KINDS: Record<string, 'rule' | 'behavior' | 'judge'> = {
|
||||
'design-review-detector-shim-dom': 'rule',
|
||||
'design-review-plugin-handoff': 'rule',
|
||||
'design-html-slop-gate': 'behavior',
|
||||
'design-motion-showcase': 'behavior',
|
||||
'diagram-triplet': 'rule',
|
||||
'diagram-authoring-quality': 'rule',
|
||||
'gstack-upgrade-happy-path': 'rule',
|
||||
@@ -1980,6 +1983,8 @@ export const BEHAVIOR_WHY: Record<string, string> = {
|
||||
"Avoiding neutral posture and naming a because-reason is live formatting of one question.",
|
||||
'design-html-slop-gate':
|
||||
"How many scan passes the one-pass slop gate takes on a fake engine's fixed output is a judgment call.",
|
||||
'design-motion-showcase':
|
||||
"The stories and the number of check-fix rounds vary per run; reading the sections, running check before export and a clean result stay the contract.",
|
||||
'scrape-match-path':
|
||||
"The /scrape fallback no longer prescribes the browser-skills match flow, so taking it is prompt compliance.",
|
||||
'dx-error-contract-review':
|
||||
|
||||
@@ -155,14 +155,17 @@ describe('template wiring', () => {
|
||||
const main = source('SKILL.md.tmpl');
|
||||
const step = (title: string, next: string) => main.slice(main.indexOf(title), main.indexOf(next));
|
||||
|
||||
test('Step 2 and the Step 3 CI wait call the helper, not a --required-only query', () => {
|
||||
test('Step 2 calls the gate helper and Step 3 is one gstack-pr-wait call, not a polling loop or a --required-only query', () => {
|
||||
const step2 = step('## Step 2: Pre-merge checks', '## Step 3: Wait for CI');
|
||||
const step3 = step('## Step 3: Wait for CI', '## Step 3.4');
|
||||
expect(step2).toContain('bin/gstack-ci-gate --repo "$REPO" --pr "$PR_NUMBER" --expect-head "$PR_HEAD"');
|
||||
expect(step3).toMatch(/bin\/gstack-ci-gate [^\n]*--expect-head "\$PR_HEAD" --wait \d+/);
|
||||
expect(step3).toContain('bin/gstack-pr-wait "$PR_NUMBER" --repo "$REPO" --until green');
|
||||
expect(step3.match(/```bash/g)).toHaveLength(1);
|
||||
expect(`${step2}${step3}`).not.toMatch(/gh pr checks[^\n]*--required/);
|
||||
expect(step3).not.toContain('--watch');
|
||||
expect(step3).toContain('still-pending checks');
|
||||
expect(step3).not.toMatch(/--watch|--wait \d+|4-minute|sleep/);
|
||||
for (const code of ['Exit 0', 'PR_WAIT_BLOCKED', 'PR_WAIT_REFUSED']) expect(step3).toContain(code);
|
||||
expect(step3).toMatch(/restart at\s+Step 1/);
|
||||
expect(main).not.toContain('4-minute');
|
||||
expect(step2).toContain('never on the exit code');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
captureScale, clockMs, maskCss, parseCaptureArgs, planCapture, stepMs, withMode, type CaptureOptions,
|
||||
} from '../lib/motion-capture';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-motion-capture.ts');
|
||||
|
||||
describe('capture timing', () => {
|
||||
test('steps are whole milliseconds averaging 1000/60', () => {
|
||||
expect(Array.from({ length: 6 }, (_, i) => stepMs(i))).toEqual([17, 16, 17, 17, 16, 17]);
|
||||
const steps = Array.from({ length: 720 }, (_, i) => stepMs(i));
|
||||
expect(steps.every(s => s === 16 || s === 17)).toBe(true);
|
||||
expect(steps.slice(0, 60).reduce((a, b) => a + b, 0)).toBe(1000);
|
||||
expect(steps.reduce((a, b) => a + b, 0)).toBe(12_000);
|
||||
});
|
||||
|
||||
test('the page clock is 0 before the start and sums the steps from PRE on', () => {
|
||||
expect(clockMs(0, 15)).toBe(0);
|
||||
expect(clockMs(14, 15)).toBe(0);
|
||||
expect(clockMs(15, 15)).toBe(stepMs(15));
|
||||
let sum = 0;
|
||||
for (let i = 15; i < 200; i++) {
|
||||
sum += stepMs(i);
|
||||
expect(clockMs(i, 15)).toBe(sum);
|
||||
}
|
||||
});
|
||||
|
||||
test('the seam frame lands exactly one cycle after the window start for any PRE and cycle', () => {
|
||||
for (const cycle of [4, 6, 12, 12.5, 15]) {
|
||||
for (let pre = 2; pre <= 30; pre++) {
|
||||
const plan = planCapture({ cycleSeconds: cycle, pre });
|
||||
expect(clockMs(plan.windowStart, pre)).toBe(0);
|
||||
expect(clockMs(plan.windowStart + plan.windowFrames, pre)).toBe(cycle * 1000);
|
||||
expect(plan.frames).toBeGreaterThan(plan.windowStart + plan.windowFrames);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a default 12 s plan records PRE, one cycle from PRE - 1, and 10 frames past it', () => {
|
||||
expect(planCapture({ cycleSeconds: 12 })).toEqual({ pre: 15, windowStart: 14, windowFrames: 720, tail: 10, frames: 744 });
|
||||
});
|
||||
|
||||
test('the scale puts the long side on 1920x1200', () => {
|
||||
expect(captureScale(1440, 990)).toBeCloseTo(1200 / 990, 10);
|
||||
expect(Math.floor(1440 * captureScale(1440, 990))).toBe(1745);
|
||||
expect(captureScale(1920, 1080)).toBe(1);
|
||||
expect(captureScale(600, 400)).toBe(3);
|
||||
// Value: protects=a portrait frame scales its long side to 1920 and short side within 1200, like a landscape one;
|
||||
// fails_when=the scale again treats width as the long side and under-scales phone frames; why_new=every case above is landscape;
|
||||
// seam=none; no_claim=scale math only; the capture's screenshot size is covered by test/motion-pipeline.test.ts
|
||||
expect(captureScale(390, 844)).toBeCloseTo(1920 / 844, 10);
|
||||
expect(captureScale(1080, 1920)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capture arguments', () => {
|
||||
test('a capture and a still parse', () => {
|
||||
expect(parseCaptureArgs(['page.html', '--size', '1440x990', '--cycle', '12', '--out', 'cap', '--mask', '.card'])).toEqual({
|
||||
target: 'page.html', width: 1440, height: 990, out: 'cap', still: undefined, cycle: 12, scale: undefined, pre: 15, tail: 10, mask: '.card',
|
||||
} satisfies CaptureOptions);
|
||||
const still = parseCaptureArgs(['https://example.com/a', '--size', '640x400', '--still', 'ref.png', '--scale', '2']) as CaptureOptions;
|
||||
expect([still.still, still.scale, still.out]).toEqual(['ref.png', 2, undefined]);
|
||||
});
|
||||
|
||||
test('each bad invocation names its problem', () => {
|
||||
const err = (argv: string[]) => (parseCaptureArgs(argv) as { error: string }).error;
|
||||
expect(err(['--size', '10x10'])).toContain('name the page');
|
||||
expect(err(['p.html', '--cycle', '12', '--out', 'c'])).toContain('--size');
|
||||
expect(err(['p.html', '--size', '640x400', '--out', 'c'])).toContain('--cycle');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '4'])).toContain('--out');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--still', 's.png'])).toContain('not both');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--pre', '1'])).toContain('--pre');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', 'abc', '--out', 'c'])).toContain('positive number');
|
||||
expect(err(['p.html', '--size', '640x400', '--still', 's.jpg'])).toContain('.png');
|
||||
expect(err(['p.html', '--size', '640x400', '--still', 's.png', '--mask', '.card'])).toContain('--mask');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--scale', '2'])).toContain('--scale');
|
||||
expect(err(['p.html', '--size', '640x400', '--fps', '30'])).toContain("unknown option '--fps'");
|
||||
expect(err(['p.html', 'q.html', '--size', '640x400'])).toContain("unexpected argument 'q.html'");
|
||||
expect(err(['p.html', '--size'])).toContain('needs a value');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--mask', '.card} body {'])).toContain('--mask takes a CSS selector');
|
||||
// Value: protects=a degenerate frame, a zero cycle and a fractional tail are refused at parse time with exit-2 wording;
|
||||
// fails_when=the size floor, the >0 check or the tail integer check is dropped; why_new=no row hit these three guards; seam=none;
|
||||
// no_claim=parser only; Chromium's behaviour on such a frame is not exercised
|
||||
expect(err(['p.html', '--size', '10x10', '--still', 's.png'])).toContain('--size 10x10 is too small');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '0', '--out', 'c'])).toContain("--cycle must be a positive number, got '0'");
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '4', '--out', 'c', '--tail', '2.5'])).toContain('--tail must be a whole number');
|
||||
// Value: protects=a cycle that is not a multiple of 0.05 s is refused with a nearest cycle the same guard accepts;
|
||||
// fails_when=the guard is dropped or loosened to whole frames, where the seam frame's clock depends on PRE; why_new=new guard;
|
||||
// seam=none; no_claim=parser and clock math only
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('--cycle must be a multiple of 0.05 s');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '6.01', '--out', 'c'])).toContain('nearest: 6)');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '0.5167', '--out', 'c'])).toContain('nearest: 0.5)');
|
||||
expect(err(['p.html', '--size', '640x400', '--cycle', '0.01', '--out', 'c'])).toContain('nearest: 0.05)');
|
||||
expect(parseCaptureArgs(['p.html', '--size', '640x400', '--cycle', '0.55', '--out', 'c'])).not.toHaveProperty('error');
|
||||
expect(new Set([14, 15, 16, 17].map(pre => clockMs(pre - 1 + 31, pre)))).toEqual(new Set([516, 517]));
|
||||
for (const pre of [14, 15, 16, 17]) expect(clockMs(pre - 1 + 33, pre)).toBe(550);
|
||||
});
|
||||
|
||||
test('modes keep the existing query and fragment', () => {
|
||||
expect(withMode('file:///tmp/page.html', 'capture')).toBe('file:///tmp/page.html?capture');
|
||||
expect(withMode('https://x.dev/y?a=1#top', 'still')).toBe('https://x.dev/y?a=1&still#top');
|
||||
});
|
||||
|
||||
test('the mask paints only the matched containers white', () => {
|
||||
const css = maskCss('.card, .panel');
|
||||
expect(css).toContain('* { visibility: hidden !important;');
|
||||
expect(css).toContain('.card, .panel { visibility: visible !important; background: #fff !important;');
|
||||
expect(css).toContain('box-shadow: none !important');
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-motion-capture CLI', () => {
|
||||
test('a usage error exits 2 before any browser starts', () => {
|
||||
const r = Bun.spawnSync([BIN, 'page.html', '--size', '640x400'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
||||
expect(r.exitCode).toBe(2);
|
||||
expect(r.stderr.toString()).toContain('--out <dir> is required');
|
||||
expect(r.stderr.toString()).toContain('Usage:');
|
||||
});
|
||||
|
||||
test('a missing page file exits 2', () => {
|
||||
const r = Bun.spawnSync([BIN, path.join(ROOT, 'no-such-page.html'), '--size', '640x400', '--still', '/tmp/x.png'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
||||
expect(r.exitCode).toBe(2);
|
||||
expect(r.stderr.toString()).toContain('does not exist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { clockMs, planCapture } from '../lib/motion-capture';
|
||||
import {
|
||||
compareArgs, contactSheetArgs, cropError, judgeCheck, loopGifArgs, manifestError, maskCoverageArgs, mp4Args, padError, parseMaskCoverage, parsePsnrStats, planChecks, psnrArgs,
|
||||
resolveFfmpeg, smallGifArgs, FFMPEG_MISSING, type CaptureManifest, type CheckJob, type CheckOptions,
|
||||
} from '../lib/motion-encode';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const BIN = path.join(ROOT, 'bin', 'gstack-motion-encode');
|
||||
const QUIET = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y'];
|
||||
|
||||
function manifest(cycle = 12, mask = true): CaptureManifest {
|
||||
const plan = planCapture({ cycleSeconds: cycle });
|
||||
return {
|
||||
schema: 1, target: 'page.html', width: 1440, height: 990, scale: 1200 / 990, fps: 60, cycle,
|
||||
pre: plan.pre, windowStart: plan.windowStart, windowFrames: plan.windowFrames, frames: plan.frames,
|
||||
clockMs: Array.from({ length: plan.frames }, (_, i) => clockMs(i, plan.pre)),
|
||||
loopTime: [], still: 'still.png', ...(mask ? { mask: 'mask.png' } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const OPTS: CheckOptions = { dissolveAt: 11, emptyFrom: 11.6, introEnd: 2.4, layout: 'cards', maskGrow: 2 };
|
||||
|
||||
describe('export recipes', () => {
|
||||
const m = manifest();
|
||||
|
||||
test('MP4 for X: H.264 yuv420p, 60 fps, CRF 15, preset slow, faststart, even crop, one cycle from the window start', () => {
|
||||
expect(mp4Args(m, '/c', '/c/n-x.mp4')).toEqual([...QUIET,
|
||||
'-start_number', '14', '-framerate', '60', '-i', '/c/frames/%05d.png', '-frames:v', '720',
|
||||
'-vf', 'crop=trunc(iw/2)*2:trunc(ih/2)*2', '-c:v', 'libx264', '-preset', 'slow', '-crf', '15',
|
||||
'-pix_fmt', 'yuv420p', '-movflags', '+faststart', '/c/n-x.mp4']);
|
||||
});
|
||||
|
||||
test('loop GIF: trim to the cycle, 20 fps, 960 wide lanczos, 192-colour diff palette, bayer 4, loop forever', () => {
|
||||
const args = loopGifArgs(m, '/c', '/c/n-loop.gif');
|
||||
expect(args.slice(args.indexOf('-filter_complex') + 1)).toEqual([
|
||||
'trim=end_frame=720,fps=20,scale=960:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=192:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle',
|
||||
'-loop', '0', '/c/n-loop.gif']);
|
||||
});
|
||||
|
||||
test('800x500 GIF: optional content crop, fit, pad with the page background', () => {
|
||||
const args = smallGifArgs(m, '/c', '/c/n.gif', { crop: '1600:1000:72:100', pad: '#f4f1ea', fps: 12 });
|
||||
const filter = args[args.indexOf('-filter_complex') + 1]!;
|
||||
expect(filter.startsWith('trim=end_frame=720,fps=12,crop=1600:1000:72:100,scale=800:500:force_original_aspect_ratio=decrease:flags=lanczos,pad=800:500:(ow-iw)/2:(oh-ih)/2:color=0xf4f1ea,split[a][b];')).toBe(true);
|
||||
expect(smallGifArgs(m, '/c', '/c/n.gif').join(' ')).toContain('fps=20,scale=800:500');
|
||||
});
|
||||
|
||||
test('a crop must be 1.6:1', () => {
|
||||
expect(cropError('1600:1000:0:0')).toBeNull();
|
||||
expect(cropError('801:500:0:0')).toBeNull();
|
||||
expect(cropError('1000:1000:0:0')).toContain('width 1600');
|
||||
expect(cropError('1600x1000')).toContain('w:h:x:y');
|
||||
});
|
||||
|
||||
// Value: protects=values that reach the ffmpeg filter graph (--pad, compare's --crop) are plain colours and w:h:x:y;
|
||||
// fails_when=a value with ',' or ';' reaches the graph and adds filters or inputs; why_new=pad and compare's crop were unvalidated;
|
||||
// seam=none; no_claim=validators only; the CLI refusal rows are in the exit-2 test below
|
||||
test('filter-graph values are validated: a hex pad and a w:h:x:y crop', () => {
|
||||
expect(padError('#f4f1ea')).toBeNull();
|
||||
expect(padError('f4f1eaff')).toBeNull();
|
||||
expect(padError('#fff')).toContain('hex colour');
|
||||
expect(padError('#ffffff,hue=s=0')).toContain('hex colour');
|
||||
expect(cropError('1000:1000:0:0', { ratio: false })).toBeNull();
|
||||
expect(cropError('100:100:0:0,hue=s=0', { ratio: false })).toContain('w:h:x:y');
|
||||
});
|
||||
|
||||
test('the contact sheet tiles twelve frames from the first to the last of the window', () => {
|
||||
const args = contactSheetArgs(m, '/c', '/c/sheet.png');
|
||||
const vf = args[args.indexOf('-vf') + 1]!;
|
||||
expect(vf).toContain(String.raw`eq(n\,0)+`);
|
||||
expect(vf).toContain(String.raw`eq(n\,719)'`);
|
||||
expect(vf).toContain('tile=4x3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('check planning', () => {
|
||||
test('seam, empty beat, rest and overflow compare the right frames', () => {
|
||||
const m = manifest();
|
||||
const jobs = planChecks(m, '/c', OPTS) as CheckJob[];
|
||||
expect(jobs.map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest', 'overflow']);
|
||||
const [seam, empty, rest, overflow] = jobs;
|
||||
expect(seam!.what).toBe('frame 14 vs 734');
|
||||
expect(seam!.args).toContain('/c/frames/00014.png');
|
||||
expect(seam!.args).toContain('/c/frames/00734.png');
|
||||
// Empty beat: clock in [11.6 s, 12 s) inside the window, compared with the pre-start frame.
|
||||
const emptyFrames = Array.from({ length: m.frames }, (_, i) => i)
|
||||
.filter(i => i > 14 && i < 734 && m.clockMs[i]! >= 11_600);
|
||||
expect(empty!.expected).toBe(emptyFrames.length);
|
||||
expect(empty!.what).toBe(`frames ${emptyFrames[0]}-733 vs 14`);
|
||||
expect(empty!.args.slice(empty!.args.indexOf('-start_number'), empty!.args.indexOf('-start_number') + 2)).toEqual(['-start_number', String(emptyFrames[0])]);
|
||||
// Rest: the last frame whose clock is still before the dissolve.
|
||||
const restFrame = Math.max(...Array.from({ length: 734 }, (_, i) => i).filter(i => i > 14 && m.clockMs[i]! < 11_000));
|
||||
expect(rest!.what).toBe(`frame ${restFrame} vs still.png`);
|
||||
expect(m.clockMs[restFrame + 1]).toBeGreaterThanOrEqual(11_000);
|
||||
// Cards overflow: story frames (intro landed .. dissolve) against the still, through the mask.
|
||||
expect(overflow!.what).toContain('story frames');
|
||||
expect(overflow!.args.join(' ')).toContain('/c/mask.png');
|
||||
expect(overflow!.args.join(' ')).toContain('/c/still.png');
|
||||
});
|
||||
|
||||
test('an app frame compares every window frame with the empty first frame', () => {
|
||||
const jobs = planChecks(manifest(), '/c', { ...OPTS, layout: 'app' }) as CheckJob[];
|
||||
const overflow = jobs.find(j => j.id === 'overflow')!;
|
||||
expect(overflow.expected).toBe(720);
|
||||
expect(overflow.what).toBe('every window frame vs 14, outside the mask');
|
||||
});
|
||||
|
||||
test('no mask, no overflow job; bad phases and a short tail are refused', () => {
|
||||
expect((planChecks(manifest(12, false), '/c', OPTS) as CheckJob[]).map(j => j.id)).toEqual(['seam', 'empty-beat', 'rest']);
|
||||
expect((planChecks(manifest(), '/c', { ...OPTS, dissolveAt: 12.5 }) as { error: string }).error).toContain('intro-end < dissolve-at');
|
||||
const short = { ...manifest(), frames: 734 };
|
||||
expect((planChecks(short, '/c', OPTS) as { error: string }).error).toContain('--tail');
|
||||
});
|
||||
|
||||
test('the mask is grown by one dilation per pixel and blacks out the containers on both sides', () => {
|
||||
const args = psnrArgs({ first: '/c/frames/%05d.png', start: 20, count: 5, reference: '/c/still.png', mask: '/c/mask.png', maskGrow: 3 });
|
||||
const graph = args[args.indexOf('-filter_complex') + 1]!;
|
||||
expect(graph).toContain('format=gray,dilation,dilation,dilation,format=gbrp,split[m1][m2]');
|
||||
expect(graph).toContain("[f][m1]blend=all_expr='if(gt(B,127),0,A)'[fa]");
|
||||
expect(graph).toContain('[fa][ra]psnr=stats_file=-');
|
||||
expect(graph).toContain('trim=end_frame=5');
|
||||
});
|
||||
|
||||
test('compare crops and blurs both sides the same way', () => {
|
||||
const args = compareArgs('a.png', 'b.png', { blur: 1.2, crop: '100:100:10:10' });
|
||||
expect(args[args.indexOf('-filter_complex') + 1]).toBe('[0:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[a];[1:v]format=gbrp,crop=100:100:10:10,gblur=sigma=1.2[b];[a][b]psnr=stats_file=-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PSNR parsing and verdicts', () => {
|
||||
const STATS = [
|
||||
'n:1 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
|
||||
'n:2 mse_avg:1.53 mse_r:1.40 mse_g:1.60 mse_b:1.59 psnr_avg:46.28 psnr_r:46.67 psnr_g:46.09 psnr_b:46.12',
|
||||
'garbage line',
|
||||
'n:3 mse_avg:0.00 mse_r:0.00 mse_g:0.00 mse_b:0.00 psnr_avg:inf psnr_r:inf psnr_g:inf psnr_b:inf',
|
||||
].join('\n');
|
||||
const job: CheckJob = { id: 'empty-beat', what: 'frames 711-733 vs 14', args: [], expected: 3 };
|
||||
|
||||
test('inf becomes Infinity and junk lines are skipped', () => {
|
||||
expect(parsePsnrStats(STATS)).toEqual([{ n: 1, psnr: Infinity }, { n: 2, psnr: 46.28 }, { n: 3, psnr: Infinity }]);
|
||||
expect(parsePsnrStats('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('identical frames pass; one differing frame fails with its worst value', () => {
|
||||
const allInf = parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf'));
|
||||
expect(judgeCheck(job, allInf)).toEqual({ id: 'empty-beat', pass: true, line: 'MOTION_CHECK: empty-beat pass (frames 711-733 vs 14)' });
|
||||
const v = judgeCheck(job, parsePsnrStats(STATS));
|
||||
expect(v.pass).toBe(false);
|
||||
expect(v.line).toBe('MOTION_CHECK: empty-beat fail 1 frame(s) differ, worst 46.28 dB at compared frame 2 (frames 711-733 vs 14)');
|
||||
expect(judgeCheck(job, parsePsnrStats(STATS), 45).pass).toBe(true);
|
||||
});
|
||||
|
||||
test('fewer compared frames than planned is a failure, never a pass', () => {
|
||||
const v = judgeCheck({ ...job, expected: 23 }, parsePsnrStats(STATS.replace('psnr_avg:46.28', 'psnr_avg:inf')));
|
||||
expect(v.pass).toBe(false);
|
||||
expect(v.line).toContain('compared 3 of 23 frames');
|
||||
expect(judgeCheck({ ...job, expected: 0 }, []).pass).toBe(false);
|
||||
});
|
||||
|
||||
// Value: protects=phase overrides that leave no story frame refuse the check instead of passing overflow on zero frames;
|
||||
// fails_when=the empty-span guard is dropped and overflow reports pass for 'story frames undefined-undefined'; why_new=new guard; seam=none;
|
||||
// no_claim=planning only
|
||||
test('a cards overflow with no story frames is a planning error', () => {
|
||||
const r = planChecks(manifest(), '/c', { ...OPTS, introEnd: 10.99, dissolveAt: 11 });
|
||||
expect(r).toEqual({ error: 'no window frame lies between intro-end and dissolve-at, so overflow has no story frames to check' });
|
||||
});
|
||||
|
||||
// Value: protects=a capture.json this tool did not write cannot put text into ffmpeg filter graphs or paths outside the capture dir;
|
||||
// fails_when=a field check is dropped and a string windowFrames or a ../ still reaches the encoder; why_new=new validator; seam=none;
|
||||
// no_claim=validator only; the bin calls it before building any job
|
||||
test('a capture manifest is checked before its fields reach ffmpeg', () => {
|
||||
expect(manifestError(manifest())).toBeNull();
|
||||
expect(manifestError(manifest(12, false))).toBeNull();
|
||||
expect(manifestError({ ...manifest(), schema: 2 })).toContain('schema 1');
|
||||
expect(manifestError({ ...manifest(), windowFrames: '60,metadata=mode=print' })).toContain('windowFrames must be a non-negative integer');
|
||||
expect(manifestError({ ...manifest(), cycle: 0 })).toContain('cycle must be a positive number');
|
||||
expect(manifestError({ ...manifest(), clockMs: [0] })).toContain('one number per frame');
|
||||
expect(manifestError({ ...manifest(), still: '../still.png' })).toContain('still must be a .png file name');
|
||||
expect(manifestError({ ...manifest(), mask: 'mask.png,hue=s=0' })).toContain('mask must be a .png file name');
|
||||
expect(manifestError(null)).toContain('schema 1');
|
||||
expect(manifestError({ ...manifest(), fps: 0 })).toContain('at least 1');
|
||||
expect(manifestError({ ...manifest(), windowFrames: 1_000_000_000 })).toContain('runs past its frames');
|
||||
});
|
||||
|
||||
test('mask coverage reads signalstats YAVG as a fraction of white', () => {
|
||||
const args = maskCoverageArgs('/c/mask.png', 2);
|
||||
expect(args[args.indexOf('-vf') + 1]).toBe('format=gray,dilation,dilation,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-');
|
||||
expect(parseMaskCoverage('frame:0 pts:0 pts_time:0\nlavfi.signalstats.YAVG=127.5\n')).toBe(0.5);
|
||||
expect(parseMaskCoverage('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ffmpeg is optional and named when missing', () => {
|
||||
test('resolution prefers GSTACK_FFMPEG, refuses a missing override, else uses PATH', () => {
|
||||
const which = (b: string) => (b === 'ffmpeg' ? '/usr/bin/ffmpeg' : null);
|
||||
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, p => p === '/opt/ff')).toBe('/opt/ff');
|
||||
expect(resolveFfmpeg({ GSTACK_FFMPEG: '/opt/ff' }, which, () => false)).toBeNull();
|
||||
expect(resolveFfmpeg({}, which, () => true)).toBe('/usr/bin/ffmpeg');
|
||||
expect(resolveFfmpeg({}, () => null, () => true)).toBeNull();
|
||||
});
|
||||
|
||||
test('the CLI prints FFMPEG_MISSING with install hints and exits 3', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-'));
|
||||
try {
|
||||
const r = Bun.spawnSync([BIN, 'check', dir], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: path.join(dir, 'no-ffmpeg') } });
|
||||
expect(r.exitCode).toBe(3);
|
||||
expect(r.stdout.toString().trim()).toBe(FFMPEG_MISSING);
|
||||
expect(r.stderr.toString()).toContain(`GSTACK_FFMPEG=${path.join(dir, 'no-ffmpeg')} does not exist`);
|
||||
expect(FFMPEG_MISSING).toContain('brew install ffmpeg');
|
||||
expect(FFMPEG_MISSING).toContain('apt-get install -y ffmpeg');
|
||||
expect(FFMPEG_MISSING).toContain('winget install');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a usage error exits 2 without touching ffmpeg', () => {
|
||||
const r = Bun.spawnSync([BIN, 'encode'], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: '/nonexistent/ffmpeg' } });
|
||||
expect(r.exitCode).toBe(2);
|
||||
expect(r.stderr.toString()).toContain('Usage: gstack-motion-encode check');
|
||||
});
|
||||
|
||||
// Value: protects=an unreadable capture, a bad --layout, impossible phases, a missing --name and a non-1.6:1 crop exit 2 before any ffmpeg run;
|
||||
// fails_when=a guard is dropped and ffmpeg runs on bad input or the CLI exits 0/1; why_new=only the no-subcommand usage path was tested; seam=none;
|
||||
// no_claim=a stand-in file satisfies GSTACK_FFMPEG resolution and is never executed, so real ffmpeg is not exercised
|
||||
test('bad captures and bad options exit 2 once ffmpeg resolves, before it runs', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-encode-usage-'));
|
||||
try {
|
||||
const fake = path.join(dir, 'fake-ffmpeg');
|
||||
fs.writeFileSync(fake, '');
|
||||
const cli = (...args: string[]) => {
|
||||
const r = Bun.spawnSync([BIN, ...args], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000, env: { ...process.env, GSTACK_FFMPEG: fake } });
|
||||
return { code: r.exitCode, err: r.stderr.toString() };
|
||||
};
|
||||
const empty = path.join(dir, 'empty');
|
||||
fs.mkdirSync(empty);
|
||||
const missing = cli('check', empty);
|
||||
expect(missing.code).toBe(2);
|
||||
expect(missing.err).toContain('capture.json');
|
||||
expect(missing.err).toContain('capture first with gstack-motion-capture.ts');
|
||||
const cap = path.join(dir, 'cap');
|
||||
fs.mkdirSync(cap);
|
||||
fs.writeFileSync(path.join(cap, 'capture.json'), JSON.stringify(manifest()));
|
||||
expect(cli('check', cap, '--layout', 'grid')).toEqual({ code: 2, err: '--layout must be app or cards\n' });
|
||||
const phases = cli('check', cap, '--dissolve-at', '13');
|
||||
expect(phases.code).toBe(2);
|
||||
expect(phases.err).toContain('intro-end < dissolve-at <= empty-from < cycle');
|
||||
const name = cli('export', cap, '--name', 'Bad Name');
|
||||
expect(name.code).toBe(2);
|
||||
expect(name.err).toContain('--name <kebab-case-name> is required');
|
||||
const crop = cli('export', cap, '--name', 'ok', '--crop', '1000:1000:0:0');
|
||||
expect(crop.code).toBe(2);
|
||||
expect(crop.err).toContain('is not 1.6:1');
|
||||
// Value: protects=unknown flags, unknown formats, a non-hex pad, a malformed compare crop and missing frames exit 2 before ffmpeg;
|
||||
// fails_when=a typo'd threshold or format is ignored, or export encodes a truncated capture and prints EXPORT_OK;
|
||||
// why_new=these guards are new; seam=none; no_claim=the stand-in ffmpeg is never executed
|
||||
expect(cli('check', cap, '--dissolveat', '10').err).toContain("unknown option '--dissolveat' for check");
|
||||
expect(cli('check', cap, '--dissolveat', '10').code).toBe(2);
|
||||
expect(cli('export', cap, '--name', 'ok', '--formats', 'mp4,gif')).toMatchObject({ code: 2, err: expect.stringContaining("got 'gif'") });
|
||||
expect(cli('export', cap, '--name', 'ok', '--pad', '#ffffff,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--pad must be a hex colour') });
|
||||
expect(cli('compare', 'a.png', 'b.png', '--crop', '1:1:0:0,hue=s=0')).toMatchObject({ code: 2, err: expect.stringContaining('--crop must be w:h:x:y') });
|
||||
expect(cli('export', cap, '--name', 'ok')).toMatchObject({ code: 2, err: expect.stringContaining('the capture is incomplete') });
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* /design-motion capture -> check -> export, end to end on real Chromium and
|
||||
* real ffmpeg (bin/gstack-motion-capture.ts feeding bin/gstack-motion-encode).
|
||||
* The unit tests pin the argument lists and the PSNR parser on synthetic
|
||||
* lines; this file proves the two bins agree on the capture directory
|
||||
* (frame names from the window start, capture.json, still.png and mask.png at
|
||||
* the frame scale) and that the checks judge real ffmpeg output both ways.
|
||||
* The fixture page is a 0.5 s loop at 640x100 (captured 1920x300), so a run is
|
||||
* 54 frames. Self-skips when the Playwright Chromium bundle, ffmpeg or ffprobe
|
||||
* is missing.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { chromium } from 'playwright';
|
||||
import { clockMs, stepMs } from '../lib/motion-capture';
|
||||
import { resolveFfmpeg, type CaptureManifest } from '../lib/motion-encode';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const CAPTURE = path.join(ROOT, 'bin', 'gstack-motion-capture.ts');
|
||||
const ENCODE = path.join(ROOT, 'bin', 'gstack-motion-encode');
|
||||
const CHROMIUM = process.env.GSTACK_CHROMIUM_PATH || (() => { try { return chromium.executablePath(); } catch { return ''; } })();
|
||||
const FFMPEG = resolveFfmpeg(process.env, bin => Bun.which(bin), p => fs.existsSync(p));
|
||||
const FFPROBE = Bun.which('ffprobe');
|
||||
const READY = Boolean(CHROMIUM) && fs.existsSync(CHROMIUM) && Boolean(FFMPEG) && Boolean(FFPROBE) && process.platform !== 'win32';
|
||||
const PHASES = ['--intro-end', '0.1', '--dissolve-at', '0.3', '--empty-from', '0.4', '--layout', 'cards'];
|
||||
|
||||
function page(o: { loops: boolean; contract?: boolean }): string {
|
||||
const contract = o.contract === false ? '' : `
|
||||
window.__lt = 0;
|
||||
window.__start = () => { window.__lt = 0; paint(0); };
|
||||
window.__tick = dt => { window.__lt = ${o.loops ? '(window.__lt + dt) % 0.5' : 'window.__lt + dt'}; paint(window.__lt); };`;
|
||||
return `<!doctype html><html><head><style>
|
||||
html, body { margin: 0; background: #f4f1ea; }
|
||||
.card { position: absolute; left: 40px; top: 20px; width: 400px; height: 60px; background: #fff; border-radius: 8px; overflow: hidden; }
|
||||
.bar { position: absolute; left: 0; top: 20px; width: 80px; height: 20px; background: #2a6f4f; }
|
||||
</style></head><body><section class="card"><div class="bar"></div></section><script>
|
||||
const card = document.querySelector('.card');
|
||||
const bar = document.querySelector('.bar');
|
||||
function paint(lt) {
|
||||
card.style.visibility = lt >= 0.05 && ${o.loops ? 'lt < 0.3' : 'true'} ? 'visible' : 'hidden';
|
||||
bar.style.transform = 'translateX(' + Math.round(Math.min(1, Math.max(0, (lt - 0.05) / 0.1)) * 300) + 'px)';
|
||||
}
|
||||
if (location.search.slice(1).split('&').includes('still')) paint(0.25);
|
||||
else { paint(0);${contract} }
|
||||
</script></body></html>
|
||||
`;
|
||||
}
|
||||
|
||||
function run(argv: string[]) {
|
||||
const r = Bun.spawnSync(argv, { stdout: 'pipe', stderr: 'pipe', timeout: 150_000, env: process.env });
|
||||
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
|
||||
}
|
||||
|
||||
function capture(dir: string, html: string, o: { mask?: string | false } = {}) {
|
||||
const file = path.join(dir, 'page.html');
|
||||
fs.writeFileSync(file, html);
|
||||
return run([CAPTURE, file, '--size', '640x100', '--cycle', '0.5', '--out', path.join(dir, 'cap'), ...(o.mask === false ? [] : ['--mask', o.mask ?? '.card'])]);
|
||||
}
|
||||
|
||||
describe.skipIf(!READY)('design-motion pipeline on real Chromium and ffmpeg', () => {
|
||||
// Value: protects=a looping page captures 54 frames whose manifest, still and mask the encoder accepts, checks clean and exports a one-cycle MP4;
|
||||
// fails_when=frame naming, start frame, tick units, still/mask scale or the export window drift between the two bins, a failed re-export
|
||||
// truncates the last good file, or a GIF fps that drifts against the cycle is accepted; why_new=only the paid marathon case runs both bins;
|
||||
// seam=none; no_claim=a 0.5 s synthetic loop stands in for an agent-built page; GIF quality and X upload acceptance are not checked
|
||||
test('a looping page captures, checks clean and exports one cycle', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
const cap = path.join(dir, 'cap');
|
||||
const c = capture(dir, page({ loops: true }));
|
||||
expect(c.code, c.err).toBe(0);
|
||||
expect(c.out.trim().split('\n').pop()).toBe(`CAPTURE_OK frames=54 window=14+30 scale=3.0000 dir=${cap}`);
|
||||
expect(fs.readdirSync(path.join(cap, 'frames')).sort()).toEqual(Array.from({ length: 54 }, (_, i) => `${String(i).padStart(5, '0')}.png`));
|
||||
expect(fs.existsSync(path.join(cap, 'still.png')) && fs.existsSync(path.join(cap, 'mask.png'))).toBe(true);
|
||||
const m = JSON.parse(fs.readFileSync(path.join(cap, 'capture.json'), 'utf-8')) as CaptureManifest;
|
||||
expect([m.scale, m.windowStart, m.windowFrames, m.frames, m.mask]).toEqual([3, 14, 30, 54, 'mask.png']);
|
||||
expect(m.loopTime.length).toBe(54);
|
||||
expect(m.loopTime.slice(0, 15).every(t => t === 0)).toBe(true);
|
||||
expect(m.loopTime[15]).toBeCloseTo(stepMs(15) / 1000, 9);
|
||||
for (let i = 15; i < 44; i++) expect(m.loopTime[i]!).toBeCloseTo(clockMs(i, 15) / 1000, 6);
|
||||
|
||||
const check = run([ENCODE, 'check', cap, ...PHASES]);
|
||||
expect(check.code, check.out + check.err).toBe(0);
|
||||
for (const id of ['seam', 'empty-beat', 'rest', 'overflow']) expect(check.out).toContain(`MOTION_CHECK: ${id} pass`);
|
||||
expect(check.out).toContain(`CONTACT_SHEET ${path.join(cap, 'contact-sheet.png')}`);
|
||||
expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean');
|
||||
|
||||
const exp = run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', 'mp4,800', '--pad', '#f4f1ea']);
|
||||
expect(exp.code, exp.out + exp.err).toBe(0);
|
||||
const gifSize = fs.statSync(path.join(cap, 'pipe-800x500.gif')).size;
|
||||
const broken = run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', '800', '--crop', '4000:2500:0:0']);
|
||||
expect(broken.code).toBe(1);
|
||||
expect(broken.out).toContain('EXPORT_FAILED pipe-800x500.gif');
|
||||
expect(fs.statSync(path.join(cap, 'pipe-800x500.gif')).size).toBe(gifSize);
|
||||
expect(fs.readdirSync(cap).filter(f => f.startsWith('.partial-'))).toEqual([]);
|
||||
const drift = run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', 'loop', '--gif-fps', '7']);
|
||||
expect(drift.code).toBe(2);
|
||||
expect(drift.err).toContain('must be a whole fps from 1 to 50');
|
||||
expect(run([ENCODE, 'export', cap, '--name', 'pipe', '--formats', '800', '--small-fps', '60']).code).toBe(2);
|
||||
expect(exp.out).toContain(`EXPORT_OK ${path.join(cap, 'pipe-x.mp4')}`);
|
||||
expect(exp.out).toContain(`EXPORT_OK ${path.join(cap, 'pipe-800x500.gif')}`);
|
||||
const probe = run([FFPROBE!, '-v', 'error', '-select_streams', 'v:0', '-count_frames', '-show_entries', 'stream=width,height,nb_read_frames', '-of', 'csv=p=0', path.join(cap, 'pipe-x.mp4')]);
|
||||
expect(probe.out.trim()).toBe('1920,300,30');
|
||||
|
||||
const same = run([ENCODE, 'compare', path.join(cap, 'still.png'), path.join(cap, 'still.png')]);
|
||||
expect(same.code).toBe(0);
|
||||
expect(same.out).toStartWith('PSNR inf dB');
|
||||
const sizes = run([ENCODE, 'compare', path.join(cap, 'still.png'), path.join(cap, 'contact-sheet.png')]);
|
||||
expect(sizes.code).toBe(1);
|
||||
expect(sizes.err).toContain('COMPARE_FAILED');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=a page that never returns to its empty state fails the seam and empty-beat checks on real ffmpeg output and exits 1;
|
||||
// fails_when=finite psnr_avg lines stop parsing or a failed job stops counting toward MOTION_RESULT; why_new=unit verdicts use hand-written stats lines; seam=none;
|
||||
// no_claim=only the seam/empty-beat failure class; a rest or overflow failure is not reproduced here
|
||||
test('a page that does not loop fails the seam and the empty beat', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
const c = capture(dir, page({ loops: false }));
|
||||
expect(c.code, c.err).toBe(0);
|
||||
const check = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES]);
|
||||
expect(check.code).toBe(1);
|
||||
expect(check.out).toMatch(/MOTION_CHECK: seam fail 1 frame\(s\) differ, worst \d+\.\d\d dB at compared frame \d+ \(frame 14 vs 44\)/);
|
||||
expect(check.out).toMatch(/MOTION_CHECK: empty-beat fail \d+ frame\(s\) differ/);
|
||||
expect(check.out).toContain('MOTION_CHECK: rest pass');
|
||||
expect(check.out).toContain('MOTION_CHECK: overflow pass');
|
||||
expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: 2 failed');
|
||||
const relaxed = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--min-db', '0']);
|
||||
expect(relaxed.code, relaxed.out + relaxed.err).toBe(0);
|
||||
expect(relaxed.out).toMatch(/^MOTION_SETTINGS: .*--min-db 0; defaults otherwise$/m);
|
||||
const blank = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--min-db', '']);
|
||||
expect(blank.code).toBe(2);
|
||||
expect(blank.err).toContain("--min-db must be a non-negative number, got ''");
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=a page without window.__start/__tick stops with MOTION_CONTRACT_MISSING and exit 1 before writing capture.json;
|
||||
// fails_when=the contract probe is dropped and frames of an unanimated page are recorded as a capture; why_new=no test loads a page in the capture bin; seam=none;
|
||||
// no_claim=only the missing-contract case; a contract that throws mid-capture is not exercised
|
||||
test('a page without the capture contract is refused', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
const c = capture(dir, page({ loops: true, contract: false }));
|
||||
expect(c.code).toBe(1);
|
||||
expect(c.err).toContain('MOTION_CONTRACT_MISSING');
|
||||
expect(fs.existsSync(path.join(dir, 'cap', 'capture.json'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a capture reuses an --out only when its own marker is there: a foreign still.png is refused and kept, the retry after a
|
||||
// refused page succeeds, and a later failed capture clears the previous manifest; the no-mask run ends 'clean (overflow not checked)'
|
||||
// and an unbounded --mask-grow is refused; fails_when=the marker test, the cleanup, the no-mask result or the grow bound is dropped;
|
||||
// why_new=review cycles found a refused retry and a deleted foreign dir; seam=none; no_claim=a crash mid-frame-loop is not simulated
|
||||
test('an --out is reused only when this tool wrote it', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
const cap = path.join(dir, 'cap');
|
||||
fs.mkdirSync(cap);
|
||||
fs.writeFileSync(path.join(cap, 'still.png'), 'not ours');
|
||||
const foreign = capture(dir, page({ loops: true, contract: false }));
|
||||
expect(foreign.code).toBe(2);
|
||||
expect(foreign.err).toContain('already holds still.png but no .gstack-motion-capture marker');
|
||||
expect(fs.readFileSync(path.join(cap, 'still.png'), 'utf-8')).toBe('not ours');
|
||||
fs.rmSync(path.join(cap, 'still.png'));
|
||||
|
||||
const c = capture(dir, page({ loops: true, contract: false }));
|
||||
expect(c.code).toBe(1);
|
||||
expect(c.err).toContain('MOTION_CONTRACT_MISSING');
|
||||
const retry = capture(dir, page({ loops: true }), { mask: false });
|
||||
expect(retry.code, retry.err).toBe(0);
|
||||
expect(retry.out).toContain('CAPTURE_OK frames=54');
|
||||
const check = run([ENCODE, 'check', cap, ...PHASES]);
|
||||
expect(check.code, check.out + check.err).toBe(0);
|
||||
expect(check.out).toContain('MOTION_CHECK: overflow skipped');
|
||||
expect(check.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean (overflow not checked)');
|
||||
const grow = run([ENCODE, 'check', cap, ...PHASES, '--mask-grow', '33']);
|
||||
expect(grow.code).toBe(2);
|
||||
expect(grow.err).toContain('--mask-grow must be a whole number of pixels from 0 to 32');
|
||||
|
||||
expect(capture(dir, page({ loops: true, contract: false })).code).toBe(1);
|
||||
expect(fs.existsSync(path.join(cap, 'capture.json')) || fs.existsSync(path.join(cap, 'still.png'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=a mask that paints the whole frame fails overflow in a cards layout and is reported as not checked only for an app
|
||||
// frame, instead of blacking out every pixel and passing; a --mask that matches nothing stops the capture before any frame;
|
||||
// fails_when=the coverage guard, its app-only skip or the empty-mask check is dropped; why_new=adversarial review reproduced each;
|
||||
// seam=none; no_claim=only a full-frame mask; the 90% line and a grow-driven failure are not swept
|
||||
test('a mask that covers the frame fails cards and is not checked for an app frame; an empty mask stops the capture', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
expect(capture(dir, page({ loops: true }), { mask: 'html' }).code).toBe(0);
|
||||
const cards = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES]);
|
||||
expect(cards.code).toBe(1);
|
||||
expect(cards.out).toMatch(/MOTION_CHECK: overflow fail the mask covers \d+% of the frame; in a cards layout mark only the card containers/);
|
||||
const app = run([ENCODE, 'check', path.join(dir, 'cap'), ...PHASES, '--layout', 'app']);
|
||||
expect(app.code, app.out + app.err).toBe(0);
|
||||
expect(app.out).toMatch(/MOTION_CHECK: overflow skipped \(the app frame's surfaces cover \d+% of the frame/);
|
||||
expect(app.out.trim().split('\n').pop()).toBe('MOTION_RESULT: clean (overflow not checked)');
|
||||
const empty = capture(dir, page({ loops: true }), { mask: '[data-nope]' });
|
||||
expect(empty.code).toBe(1);
|
||||
expect(empty.err).toContain("MOTION_MASK_EMPTY: --mask '[data-nope]' matches no element");
|
||||
expect(fs.readdirSync(path.join(dir, 'cap', 'frames'))).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
// Value: protects=a CSS transition that a story starts with a class toggle is seeked from the frame it appears, not from the last
|
||||
// __mark, so it eases instead of popping; fails_when=new animations are seeded at __mark again; why_new=adversarial probe showed a
|
||||
// story transition exported ~100 ms ahead while every check passed; seam=none; no_claim=one linear opacity transition
|
||||
test('a story transition started by a class toggle eases from the frame it starts', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
const c = capture(dir, `<!doctype html><html><head><style>html, body { margin: 0; background: #000; }
|
||||
#s { position: absolute; inset: 0; background: #fff; opacity: 0; transition: opacity 0.2s linear; } #s.on { opacity: 1; }</style></head>
|
||||
<body><div id="s"></div><script>const s = document.getElementById('s'); window.__lt = 0;
|
||||
window.__start = () => { window.__lt = 0; window.__mark = performance.now(); };
|
||||
window.__tick = dt => { window.__lt += dt; if (window.__lt >= 0.1) s.classList.add('on'); };
|
||||
if (location.search.includes('still')) s.classList.add('on');</script></body></html>`, { mask: false });
|
||||
expect(c.code, c.err).toBe(0);
|
||||
const probe = run([FFMPEG!, '-hide_banner', '-loglevel', 'error', '-i', path.join(dir, 'cap', 'frames', '00026.png'), '-vf', 'format=gray,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-', '-f', 'null', '-']);
|
||||
const yavg = Number(/YAVG=([\d.]+)/.exec(probe.out)?.[1]);
|
||||
expect(yavg).toBeGreaterThan(90);
|
||||
expect(yavg).toBeLessThan(170);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a page whose __tick never returns stops with MOTION_CAPTURE_FAILED instead of hanging the capture forever;
|
||||
// fails_when=the per-frame 10 s bound is dropped; why_new=adversarial probe hung at 100% renderer CPU; seam=none;
|
||||
// no_claim=only a spinning __tick; a hung screenshot is not bounded
|
||||
test('a __tick that never returns fails the capture', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
const c = capture(dir, '<!doctype html><body><script>window.__lt = 0; window.__start = () => {}; window.__tick = dt => { window.__lt += dt; if (window.__lt > 0.05) for (;;) {} };</script></body>', { mask: false });
|
||||
expect(c.code).toBe(1);
|
||||
expect(c.err).toMatch(/MOTION_CAPTURE_FAILED: frame \d+ took longer than 10 s/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=an offscreen loading=lazy image no longer hangs the still render (img.decode() never settles on it), and --still writes
|
||||
// one PNG at --scale (the Step 3 fidelity reference);
|
||||
// fails_when=the eager switch and the 30 s bound are both dropped and --still waits forever; why_new=adversarial probe hung at 90 s; seam=none;
|
||||
// no_claim=one lazy image on a local page; a slow network image is bounded only by the 30 s timer
|
||||
test('a page with an offscreen lazy image renders its still', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'motion-pipeline-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'dot.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64'));
|
||||
const file = path.join(dir, 'lazy.html');
|
||||
fs.writeFileSync(file, '<!doctype html><body style="margin:0"><div style="height:5000px"></div><img loading="lazy" src="dot.png" width="4" height="4"></body>');
|
||||
const out = path.join(dir, 'still.png');
|
||||
const still = run([CAPTURE, file, '--size', '640x100', '--still', out, '--scale', '2']);
|
||||
expect(still.code, still.err).toBe(0);
|
||||
expect(still.out.trim()).toBe(`STILL_OK ${out}`);
|
||||
expect(still.err).not.toContain('still loading');
|
||||
const png = fs.readFileSync(out);
|
||||
expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([1280, 200]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}, 200_000);
|
||||
});
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* gstack-pr-wait (lib/pr-wait.ts, lib/pr-wait-github.ts): one stdout line per
|
||||
* real event, state under the gstack state root, required checks from rulesets
|
||||
* so an unstarted one is pending, two identical polls before green, the
|
||||
* base-moved-and-touches-this-PR event, BEHIND and DIRTY, the per-PR lock, the
|
||||
* 6-hour baseline staleness and the configurable bot lists. Every GitHub read
|
||||
* goes through a fake gh runner, as test/gstack-ci-gate.test.ts does.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { GhResult } from '../lib/ci-gate';
|
||||
import { DEFAULT_ADVISORY_BOTS, DEFAULT_GATING_BOTS, PR_WAIT_EXIT, Watcher, botLists, parsePrTarget, parsePrWaitArgs, prWaitMain, stateDir, type PrWaitEnv, type PrWaitOptions } from '../lib/pr-wait';
|
||||
import { GhError, runGh } from '../lib/pr-wait-github';
|
||||
import { RESULT_CODES } from '../lib/result-codes';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const HEAD = 'a'.repeat(40);
|
||||
const BASE_A = 'b'.repeat(40);
|
||||
const BASE_B = 'c'.repeat(40);
|
||||
const REPO = 'owner/project';
|
||||
|
||||
const ok = (stdout: string): GhResult => ({ status: 0, stdout, stderr: '' });
|
||||
const err = (stderr: string, status = 1): GhResult => ({ status, stdout: '', stderr: `${stderr}\n` });
|
||||
const NOT_FOUND = err('gh: Not Found (HTTP 404)');
|
||||
|
||||
interface Author { login: string; bot?: boolean }
|
||||
const actor = ({ login, bot }: Author) => ({ login, __typename: bot ? 'Bot' : 'User' });
|
||||
const comment = (id: string, author: Author, body: string) => ({ id, url: `https://github.com/${REPO}/pull/42#issuecomment-${id}`, body, author: actor(author) });
|
||||
const review = (id: string, author: Author, state: string, body = '', commit: string | null = HEAD) =>
|
||||
({ id, url: `https://github.com/${REPO}/pull/42#pullrequestreview-${id}`, state, body, commit: commit ? { oid: commit } : null, author: actor(author) });
|
||||
const thread = (path: string, line: number | null, resolved: boolean, ...comments: ReturnType<typeof comment>[]) => ({ isResolved: resolved, path, line, comments: { nodes: comments } });
|
||||
|
||||
interface PrShape {
|
||||
state?: string; head?: string; base?: string; baseOid?: string | null; mergeState?: string;
|
||||
comments?: ReturnType<typeof comment>[]; reviews?: ReturnType<typeof review>[]; threads?: ReturnType<typeof thread>[];
|
||||
}
|
||||
const prJson = (shape: PrShape = {}) => JSON.stringify({
|
||||
data: {
|
||||
viewer: { login: 'me' },
|
||||
repository: {
|
||||
pullRequest: {
|
||||
url: `https://github.com/${REPO}/pull/42`, state: shape.state ?? 'OPEN', headRefOid: shape.head ?? HEAD,
|
||||
baseRefName: shape.base ?? 'main', mergeStateStatus: shape.mergeState ?? 'CLEAN', author: { login: 'author' },
|
||||
baseRef: shape.baseOid === null ? null : { target: { oid: shape.baseOid ?? BASE_A } },
|
||||
comments: { nodes: shape.comments ?? [] }, reviews: { nodes: shape.reviews ?? [] }, reviewThreads: { nodes: shape.threads ?? [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const checksJson = (...rows: Array<[string, string] | [string, string, string]>) =>
|
||||
JSON.stringify(rows.map(([name, bucket, description]) => ({ name, bucket, link: `https://ci.example/${name}`, description: description ?? '' })));
|
||||
|
||||
/** Scripted replies per gh command shape; a list is consumed one poll at a time, the last reply repeating. */
|
||||
interface Script {
|
||||
pr?: string | string[];
|
||||
checks?: GhResult | GhResult[];
|
||||
rulesets?: GhResult;
|
||||
protection?: GhResult;
|
||||
prFiles?: GhResult;
|
||||
compare?: Record<string, GhResult>;
|
||||
behind?: GhResult;
|
||||
}
|
||||
function fakeGh(script: Script) {
|
||||
// Function declarations, not generic arrows: pregate's import scanner reads tests as TSX, where `<T>(x) =>` is a tag.
|
||||
function queue<T>(value: T | T[] | undefined): T[] { return value === undefined ? [] : Array.isArray(value) ? [...value] : [value]; }
|
||||
function next<T>(q: T[]): T | undefined { return q.length > 1 ? q.shift() : q[0]; }
|
||||
const prs = queue(script.pr).map(ok);
|
||||
const checks = queue(script.checks);
|
||||
const calls: string[][] = [];
|
||||
const gh = (args: string[]): GhResult => {
|
||||
calls.push(args);
|
||||
const [a, b] = args;
|
||||
if (a === 'api' && b === 'graphql') return next(prs) ?? ok(prJson());
|
||||
if (a === 'pr' && b === 'checks') return next(checks) ?? ok(checksJson());
|
||||
if (a === 'pr' && b === 'view') return ok('{"number":42}');
|
||||
if (a === 'pr' && b === 'diff') return script.prFiles ?? ok('');
|
||||
if (a === 'repo' && b === 'view') return ok(`${REPO}\n`);
|
||||
if (a === 'api' && b.includes('/rules/branches/')) return script.rulesets ?? NOT_FOUND;
|
||||
if (a === 'api' && b.includes('/protection/')) return script.protection ?? NOT_FOUND;
|
||||
if (a === 'api' && b.includes('/compare/')) {
|
||||
if (args.includes('.behind_by')) return script.behind ?? ok('0\n');
|
||||
return script.compare?.[b.split('/compare/')[1]] ?? ok('');
|
||||
}
|
||||
throw new Error(`fake gh: unexpected ${args.join(' ')}`);
|
||||
};
|
||||
return Object.assign(gh, { calls });
|
||||
}
|
||||
|
||||
let dir: string;
|
||||
let stdout: string[];
|
||||
let stderr: string[];
|
||||
let clock: number;
|
||||
let alive: Set<number>;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'pr-wait-'));
|
||||
stdout = [];
|
||||
stderr = [];
|
||||
clock = 1_700_000_000_000;
|
||||
alive = new Set([4242]);
|
||||
});
|
||||
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
function env(gh: PrWaitEnv['gh'], pid = 4242, vars: Record<string, string> = {}): PrWaitEnv {
|
||||
return {
|
||||
gh, vars: { HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state'), ...vars }, pid,
|
||||
now: () => clock, sleep: () => {}, out: t => stdout.push(t), err: t => stderr.push(t), pidAlive: p => alive.has(p),
|
||||
};
|
||||
}
|
||||
const watcher = (script: Script, until: PrWaitOptions['until'] = 'ready', vars: Record<string, string> = {}) => {
|
||||
const e = env(fakeGh(script), 4242, vars);
|
||||
return new Watcher(e, REPO, 42, until, 20, botLists(e.vars));
|
||||
};
|
||||
/** Run polls until one returns a code or the budget is spent; the first poll is the baseline. */
|
||||
function pollUntilDone(w: Watcher, budget = 6): number | null {
|
||||
for (let i = 0; i < budget; i++) {
|
||||
const code = w.poll();
|
||||
if (code !== null) return code;
|
||||
w.save();
|
||||
w.baseline = false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const lines = () => stdout.join('').split('\n').filter(Boolean);
|
||||
const RULESETS = ok('free-tests\nwindows-free-tests\n');
|
||||
|
||||
describe('state lives under the gstack state root', () => {
|
||||
test('state file and lock are <root>/pr-wait/<owner>__<name>__<pr>, never ~/.local/state', () => {
|
||||
const w = watcher({});
|
||||
expect(w.stateFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.json'));
|
||||
expect(w.lockFile).toBe(join(dir, 'state', 'pr-wait', 'owner__project__42.lock'));
|
||||
expect(stateDir({ HOME: dir })).toBe(join(dir, '.gstack', 'pr-wait'));
|
||||
pollUntilDone(w, 1);
|
||||
expect(existsSync(w.stateFile)).toBe(true);
|
||||
expect(existsSync(join(dir, '.local'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a state file older than six hours is a fresh baseline: nothing from it is replayed and nothing new is reported on that poll', () => {
|
||||
const file = join(dir, 'state', 'pr-wait', 'owner__project__42.json');
|
||||
mkdirSync(join(dir, 'state', 'pr-wait'), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify({ reported: [], summaries: {}, base_oid: BASE_A, merge_state: 'CLEAN' }));
|
||||
const old = new Date(clock - 7 * 3600 * 1000);
|
||||
utimesSync(file, old, old);
|
||||
const w = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
|
||||
expect(w.baseline).toBe(true);
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const fresh = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'looks wrong')] }) });
|
||||
expect(fresh.baseline).toBe(true);
|
||||
});
|
||||
|
||||
test('a state file within six hours carries the baseline across a restart, so only what arrived meanwhile prints', () => {
|
||||
const first = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first')] }) });
|
||||
pollUntilDone(first, 1);
|
||||
expect(lines()).toEqual([]);
|
||||
clock += 60 * 60 * 1000;
|
||||
const restarted = watcher({ pr: prJson({ comments: [comment('c1', { login: 'reviewer' }, 'first'), comment('c2', { login: 'reviewer' }, 'second thought')] }) });
|
||||
expect(restarted.baseline).toBe(false);
|
||||
restarted.poll();
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 comment from reviewer: second thought https:/)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('events: one stdout line each, progress on stderr', () => {
|
||||
test('the baseline poll records what exists and prints nothing; the next poll prints only new comments, reviews and thread replies, never the author, the gh user, or edits', () => {
|
||||
const before = prJson({ comments: [comment('c1', { login: 'reviewer' }, 'old')] });
|
||||
const after = prJson({
|
||||
comments: [comment('c1', { login: 'reviewer' }, 'old, edited'), comment('c2', { login: 'author' }, 'mine'), comment('c3', { login: 'me' }, 'also mine'),
|
||||
comment('c4', { login: 'github-actions', bot: true }, 'deploy preview ready'), comment('c5', { login: 'reviewer' }, '<b>Please</b> & fix <this>')],
|
||||
reviews: [review('r1', { login: 'reviewer' }, 'CHANGES_REQUESTED', 'nope'), review('r2', { login: 'lurker' }, 'COMMENTED', '')],
|
||||
threads: [thread('lib/x.ts', 12, false, comment('t1', { login: 'reviewer' }, 'rename this'), comment('t2', { login: 'author' }, 'done'))],
|
||||
});
|
||||
const w = watcher({ pr: [before, after] }, 'any-change');
|
||||
expect(w.run()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([
|
||||
'#42 comment from reviewer: Please & fix <this> https://github.com/owner/project/pull/42#issuecomment-c5',
|
||||
'#42 review from reviewer (changes requested): nope https://github.com/owner/project/pull/42#pullrequestreview-r1',
|
||||
'#42 new thread from reviewer on lib/x.ts:12: rename this https://github.com/owner/project/pull/42#issuecomment-t1',
|
||||
]);
|
||||
expect(stderr.join('')).toContain('watching owner/project#42 until any-change; required checks: none set; every check on the head gates');
|
||||
expect(stderr.join('')).toContain('baseline recorded for owner/project#42');
|
||||
});
|
||||
|
||||
test('merged ends with exit 0; closed without merging is PR_WAIT_BLOCKED', () => {
|
||||
expect(pollUntilDone(watcher({ pr: prJson({ state: 'MERGED' }) }))).toBe(0);
|
||||
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
|
||||
stdout = [];
|
||||
expect(pollUntilDone(watcher({ pr: prJson({ state: 'CLOSED' }) }))).toBe(1);
|
||||
expect(lines()).toEqual(['#42 closed without merging: https://github.com/owner/project/pull/42', expect.stringContaining('stopped: action needed (PR_WAIT_BLOCKED; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#pr-wait-blocked)')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required checks and the green condition', () => {
|
||||
test('a required check named by the ruleset that has not started is pending, not green by omission', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'])) }, 'green');
|
||||
expect(pollUntilDone(w, 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(stderr.join('')).toContain('required checks: free-tests, windows-free-tests');
|
||||
});
|
||||
|
||||
test('a required check that failed ends the watch at once, naming it with its link, even on the baseline poll', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pending'])) }, 'green');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('an unknown bucket counts as failed, as gstack-ci-gate counts it', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'stale'], ['windows-free-tests', 'pass'])) }, 'green');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
|
||||
});
|
||||
|
||||
test('green needs two identical polls: a check set seen once is not stable', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: [ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'])), ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['late-check', 'pass']))], behind: ok('2\n') }, 'green');
|
||||
expect(w.poll()).toBeNull();
|
||||
w.baseline = false;
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 green on aaaaaaa: 3 pass; required 2/2 green; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN; 2 commits behind main']);
|
||||
});
|
||||
|
||||
test('every check on the head gates, advisory bots\' own included, and a gating bot\'s review on the head makes it ready', () => {
|
||||
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', 'Confidence score: 4/5')] });
|
||||
const held = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pending', 'Review in progress'])) });
|
||||
expect(pollUntilDone(held, 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'pass'], ['coderabbit', 'pass', 'Reviewed'])) });
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 coderabbit finished on aaaaaaa (advisory, not waited on)', '#42 ready on aaaaaaa: 3 pass; greptile done, coderabbit Reviewed (advisory); 0 unresolved threads; merge state CLEAN; 0 commits behind main']);
|
||||
});
|
||||
|
||||
test('ready is blocked by unresolved threads and by BEHIND, in that order', () => {
|
||||
const base = { checks: ok(checksJson(['build', 'pass'])), rulesets: NOT_FOUND };
|
||||
const reviewed = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
|
||||
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], threads: [thread('a.ts', 1, false, comment('t1', { login: 'reviewer' }, 'hm'))] }) }))).toBe(1);
|
||||
expect(lines()[0]).toBe('#42 blocked by 1 unresolved threads on aaaaaaa: https://github.com/owner/project/pull/42#issuecomment-t1');
|
||||
stdout = [];
|
||||
expect(pollUntilDone(watcher({ ...base, pr: prJson({ reviews: [reviewed], mergeState: 'BEHIND' }) }))).toBe(1);
|
||||
expect(lines()).toContain('#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging');
|
||||
});
|
||||
});
|
||||
|
||||
describe('base branch and merge state', () => {
|
||||
const compareKey = `${BASE_A}...${BASE_B}`;
|
||||
|
||||
test('the base moved and its new commits touch files in this PR → one event naming the overlap', () => {
|
||||
const w = watcher({
|
||||
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
|
||||
prFiles: ok('lib/pr-wait.ts\nREADME.md\n'),
|
||||
compare: { [compareKey]: ok('lib/pr-wait.ts\nCHANGELOG.md\n') },
|
||||
}, 'any-change');
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: lib/pr-wait.ts']);
|
||||
});
|
||||
|
||||
test('the base moved without touching this PR → silence, and the new base oid is remembered', () => {
|
||||
const w = watcher({
|
||||
pr: [prJson({ baseOid: BASE_A }), prJson({ baseOid: BASE_B })],
|
||||
prFiles: ok('lib/pr-wait.ts\n'),
|
||||
compare: { [compareKey]: ok('CHANGELOG.md\n') },
|
||||
}, 'any-change');
|
||||
expect(pollUntilDone(w, 3)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(JSON.parse(readFileSync(w.stateFile, 'utf8')).base_oid).toBe(BASE_B);
|
||||
});
|
||||
|
||||
test('a restarted watcher reports a base move that happened while no watcher ran', () => {
|
||||
const first = watcher({ pr: prJson({ baseOid: BASE_A }) });
|
||||
first.poll();
|
||||
first.save();
|
||||
const restarted = watcher({ pr: prJson({ baseOid: BASE_B }), prFiles: ok('x\n'), compare: { [compareKey]: ok('x\n') } }, 'any-change');
|
||||
expect(restarted.baseline).toBe(false);
|
||||
expect(restarted.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 main moved to ccccccc and touches files in this PR: x']);
|
||||
});
|
||||
|
||||
test('BEHIND is an event once per head, again after a push that stays BEHIND; DIRTY ends the watch as blocked', () => {
|
||||
const D = 'd'.repeat(40);
|
||||
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'BEHIND' }), prJson({ mergeState: 'UNKNOWN', head: D }), prJson({ mergeState: 'BEHIND', head: D })], checks: ok(checksJson(['build', 'pending'])) }, 'green');
|
||||
expect(pollUntilDone(behind, 5)).toBeNull();
|
||||
expect(lines()).toEqual([
|
||||
'#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging',
|
||||
'#42 merge state is BEHIND on ddddddd: update the branch from main before merging',
|
||||
]);
|
||||
stdout = [];
|
||||
const dirty = watcher({ pr: prJson({ mergeState: 'DIRTY' }) }, 'green');
|
||||
expect(dirty.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review bots are configuration', () => {
|
||||
const withConfig = (yaml: string) => {
|
||||
mkdirSync(join(dir, 'state'), { recursive: true });
|
||||
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
|
||||
};
|
||||
|
||||
test('defaults are tstack\'s: greptile and macroscope gate, coderabbit is advisory', () => {
|
||||
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: ['coderabbit'] });
|
||||
});
|
||||
|
||||
test('pr_wait_gating_bots decides which bot\'s review makes the PR ready', () => {
|
||||
withConfig('pr_wait_gating_bots: macroscope\n');
|
||||
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
|
||||
const w = watcher({ pr, checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] });
|
||||
expect(w.bots).toEqual({ gating: ['macroscope'], advisory: ['coderabbit'] });
|
||||
expect(pollUntilDone(w, 4)).toBeNull();
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
|
||||
});
|
||||
|
||||
test('none empties a list', () => {
|
||||
withConfig('pr_wait_advisory_bots: none\n');
|
||||
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope'], advisory: [] });
|
||||
});
|
||||
|
||||
test('pr_wait_advisory_bots: its review is reported but never waited on, while its check gates like any other', () => {
|
||||
withConfig('pr_wait_gating_bots: greptile\npr_wait_advisory_bots: coderabbit, Sourcery\n');
|
||||
const pr = prJson({ reviews: [review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '')] });
|
||||
const w = watcher({ pr, checks: ok(checksJson(['build', 'pass'], ['sourcery-ai', 'pass', 'Reviewed'])) });
|
||||
expect(w.bots.advisory).toEqual(['coderabbit', 'sourcery']);
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()[0]).toContain('ready on aaaaaaa: 2 pass; greptile done, sourcery Reviewed (advisory)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the per-PR lock', () => {
|
||||
test('a live holder refuses the second watcher with PR_WAIT_REFUSED; a dead holder is taken over and released on exit', () => {
|
||||
const first = watcher({});
|
||||
expect(first.lock()).toBeNull();
|
||||
const second = new Watcher(env(fakeGh({}), 4343), REPO, 42, 'ready', 20, botLists({}));
|
||||
expect(second.lock()).toEqual({ held: '4242' });
|
||||
alive.clear();
|
||||
expect(second.lock()).toBeNull();
|
||||
expect(readFileSync(second.lockFile, 'utf8')).toBe('4343');
|
||||
first.unlock();
|
||||
expect(existsSync(second.lockFile)).toBe(true);
|
||||
second.unlock();
|
||||
expect(existsSync(second.lockFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('prWaitMain refuses a duplicate watcher before any poll', () => {
|
||||
const w = watcher({});
|
||||
expect(w.lock()).toBeNull();
|
||||
const gh = fakeGh({});
|
||||
const code = prWaitMain(['42', '--repo', REPO], env(gh, 9999));
|
||||
expect(code).toBe(PR_WAIT_EXIT.refused);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^gstack-pr-wait: the watcher did not start, or a local error stopped it before a verdict — pid 4242 already watches owner\/project#42.*\(PR_WAIT_REFUSED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pr-wait-refused\)$/)]);
|
||||
expect(gh.calls.some(c => c[1] === 'graphql')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arguments and the gh layer', () => {
|
||||
test('targets: number, #number, URL (repo from the URL), --repo override; bad input is refused', () => {
|
||||
expect(parsePrTarget('42')).toEqual({ repo: undefined, pr: 42 });
|
||||
expect(parsePrTarget('#42', REPO)).toEqual({ repo: REPO, pr: 42 });
|
||||
expect(parsePrTarget('https://github.com/garrytan/gstack/pull/3481/files')).toEqual({ repo: 'garrytan/gstack', pr: 3481 });
|
||||
expect(parsePrTarget('')).toMatchObject({ error: expect.stringContaining('missing PR number') });
|
||||
expect(parsePrTarget('abc')).toMatchObject({ error: "'abc' is not a PR number or PR URL" });
|
||||
expect(parsePrTarget('0')).toMatchObject({ error: "'0' is not a valid PR number" });
|
||||
expect(parsePrTarget('42', 'nope')).toMatchObject({ error: "'nope' is not owner/name" });
|
||||
expect(parsePrWaitArgs(['42', '--until', 'green', '--interval', '2'])).toEqual({ target: '42', until: 'green', interval: 5, timeout: 60 });
|
||||
expect(parsePrWaitArgs(['42', '--timeout', '0'])).toMatchObject({ timeout: 0 });
|
||||
expect(parsePrWaitArgs(['42', '--timeout', 'soon'])).toMatchObject({ error: expect.stringContaining('--timeout needs') });
|
||||
expect(parsePrWaitArgs(['--until', 'soon'])).toMatchObject({ error: expect.stringContaining('--until needs') });
|
||||
expect(parsePrWaitArgs(['--bogus'])).toMatchObject({ error: 'unknown argument --bogus' });
|
||||
expect(parsePrWaitArgs(['--help'])).toEqual({ help: true });
|
||||
});
|
||||
|
||||
test('main: --help exits 0, bad input exits 2 with the code and anchor, an unreadable PR exits 2', () => {
|
||||
expect(prWaitMain(['--help'], env(fakeGh({})))).toBe(0);
|
||||
expect(stdout.join('')).toContain('--until');
|
||||
stdout = [];
|
||||
expect(prWaitMain([], env(fakeGh({})))).toBe(2);
|
||||
expect(lines()[0]).toMatch(/missing PR number.*\(PR_WAIT_REFUSED; .*#pr-wait-refused\)$/);
|
||||
stdout = [];
|
||||
const gone = (args: string[]): GhResult => (args[1] === 'view' ? err('GraphQL: Could not resolve to a PullRequest with the number of 42. (HTTP 404)') : ok(''));
|
||||
expect(prWaitMain(['42', '--repo', REPO], env(gone))).toBe(2);
|
||||
expect(lines()[0]).toContain('cannot read owner/project#42: GraphQL: Could not resolve');
|
||||
expect(RESULT_CODES.PR_WAIT_REFUSED.anchor).toBe('pr-wait-refused');
|
||||
expect(RESULT_CODES.PR_WAIT_BLOCKED.anchor).toBe('pr-wait-blocked');
|
||||
});
|
||||
|
||||
test('a transient gh failure is retried with backoff and never ends the watch; a 403 on classic protection is logged, not fatal', () => {
|
||||
let graphqlCalls = 0;
|
||||
const base = fakeGh({ checks: ok(checksJson(['build', 'pass'])), protection: err('gh: Resource not accessible by integration (HTTP 403)') });
|
||||
const flaky = (args: string[]): GhResult => {
|
||||
if (args[1] === 'graphql' && graphqlCalls++ === 0) return err('gh: Something went wrong (HTTP 502)');
|
||||
return base(args);
|
||||
};
|
||||
const e = env(flaky);
|
||||
const w = new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars));
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(graphqlCalls).toBe(2);
|
||||
expect(stderr.join('')).toContain('gh api graphql: gh: Something went wrong (HTTP 502); retry in 2s');
|
||||
expect(stderr.join('')).toContain('could not read required checks from repos/owner/project/branches/main/protection/required_status_checks: gh: Resource not accessible by integration (HTTP 403)');
|
||||
});
|
||||
|
||||
test('the bin runs on the isolated bun shebang and prints the usage', () => {
|
||||
const result = spawnSync(join(ROOT, 'bin', 'gstack-pr-wait'), ['--help'], { encoding: 'utf8', cwd: ROOT, timeout: 30_000 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('gstack-config set pr_wait_gating_bots greptile,macroscope');
|
||||
expect(readFileSync(join(ROOT, 'bin', 'gstack-pr-wait'), 'utf8').startsWith('#!/usr/bin/env -S bun --no-env-file --config=/dev/null\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pass-1 review fixes: gates agree, bounded waits, bot identity, local faults', () => {
|
||||
function withConfig(yaml: string) {
|
||||
mkdirSync(join(dir, 'state'), { recursive: true });
|
||||
writeFileSync(join(dir, 'state', 'config.yaml'), yaml);
|
||||
}
|
||||
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
|
||||
|
||||
test('green gates on every check on the head, as gstack-ci-gate does: a pending non-required check holds it and a red one blocks it', () => {
|
||||
const pending = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'pending'])) }, 'green');
|
||||
expect(pollUntilDone(pending, 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const red = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'pass'], ['windows-free-tests', 'pass'], ['quality-gate', 'fail'])) }, 'green');
|
||||
expect(red.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 check failed: quality-gate https://ci.example/quality-gate', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('a failed bot check is a failed check, gating or advisory, as gstack-ci-gate counts it; never a completed review', () => {
|
||||
const gating = watcher({ pr: prJson({ reviews: [] }), checks: ok(checksJson(['build', 'pass'], ['greptile-review', 'fail'])) });
|
||||
expect(pollUntilDone(gating)).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 check failed: greptile-review https://ci.example/greptile-review');
|
||||
stdout = [];
|
||||
const advisory = watcher({ pr: prJson({ reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'], ['coderabbit', 'fail'])) });
|
||||
expect(pollUntilDone(advisory)).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual([
|
||||
'#42 check failed: coderabbit https://ci.example/coderabbit',
|
||||
'#42 greptile finished on aaaaaaa',
|
||||
'#42 coderabbit finished on aaaaaaa: failed: coderabbit (advisory, not waited on)',
|
||||
expect.stringContaining('(PR_WAIT_BLOCKED;'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('BEHIND is keyed on the head alone: a base that keeps moving does not repeat the line, even across restarts', () => {
|
||||
const first = watcher({ pr: [prJson({ mergeState: 'BEHIND', baseOid: BASE_A }), prJson({ mergeState: 'BEHIND', baseOid: BASE_B })], checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: { [`${BASE_A}...${BASE_B}`]: ok('') } }, 'any-change');
|
||||
expect(pollUntilDone(first, 3)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
const restarted = watcher({ pr: prJson({ mergeState: 'BEHIND', baseOid: 'e'.repeat(40) }), checks: ok(checksJson(['build', 'pending'])), prFiles: ok('x\n'), compare: {} }, 'any-change');
|
||||
expect(restarted.baseline).toBe(false);
|
||||
expect(pollUntilDone(restarted, 3)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
});
|
||||
|
||||
test('a verdict keeps its exit code when the state file cannot be written; the save error goes to stderr', () => {
|
||||
const w = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
|
||||
rmSync(join(dir, 'state'), { recursive: true, force: true });
|
||||
writeFileSync(join(dir, 'state'), '');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
|
||||
expect(stderr.join('')).toContain('could not save state');
|
||||
});
|
||||
|
||||
test('a bot owns logins that start with its name, so a person called notgreptile is a reviewer, not the gating bot', () => {
|
||||
const human = review('h1', { login: 'notgreptile' }, 'COMMENTED', 'looks fine');
|
||||
const w = watcher({ pr: [prJson({}), prJson({ reviews: [human] })], checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(pollUntilDone(w, 4)).toBeNull();
|
||||
expect(lines()).toEqual(['#42 review from notgreptile (commented): looks fine https://github.com/owner/project/pull/42#pullrequestreview-h1']);
|
||||
});
|
||||
|
||||
test('ready with no gating bot configured is green plus the thread and merge-state checks', () => {
|
||||
withConfig('pr_wait_gating_bots: none\n');
|
||||
const w = watcher({ checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(w.bots.gating).toEqual([]);
|
||||
expect(pollUntilDone(w, 4)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()[0]).toContain('ready on aaaaaaa: 1 pass; no review bot yet');
|
||||
});
|
||||
|
||||
test('ready waits while the merge state is UNKNOWN and is met once GitHub knows it', () => {
|
||||
const w = watcher({ pr: [prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'UNKNOWN', reviews: [GREPTILE] }), prJson({ mergeState: 'CLEAN', reviews: [GREPTILE] })], checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(w.poll()).toBeNull();
|
||||
w.baseline = false;
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()[0]).toContain('merge state CLEAN');
|
||||
});
|
||||
|
||||
test('--timeout ends a watch that met nothing as PR_WAIT_BLOCKED, naming what is still pending; 0 waits without limit', () => {
|
||||
function timed(timeout: number) {
|
||||
const e = env(fakeGh({ checks: ok(checksJson(['build', 'pending'], ['docs', 'pass'])) }));
|
||||
e.sleep = ms => { clock += ms; };
|
||||
return new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars), timeout);
|
||||
}
|
||||
expect(timed(1).run()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 gstack-pr-wait stopped: no green after 1 minutes; still pending: build', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
stdout = [];
|
||||
const forever = timed(0);
|
||||
let polls = 0;
|
||||
const poll = forever.poll.bind(forever);
|
||||
forever.poll = () => { polls += 1; return polls < 50 ? poll() : PR_WAIT_EXIT.met; };
|
||||
expect(forever.run()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(polls).toBe(50);
|
||||
});
|
||||
|
||||
test('run() ends as blocked after ten transient failures, and at once on a non-transient gh error', () => {
|
||||
const down = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Something went wrong (HTTP 502)') : ok(''));
|
||||
const e = env(down);
|
||||
expect(new Watcher(e, REPO, 42, 'green', 20, botLists(e.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(stderr.join('').match(/poll failed \(\d+\/10\)/g)).toHaveLength(10);
|
||||
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: GitHub unreachable for 10 polls: gh: Something went wrong (HTTP 502)');
|
||||
stdout = [];
|
||||
const gone = (args: string[]): GhResult => (args[1] === 'graphql' ? err('gh: Not Found (HTTP 404)') : ok(''));
|
||||
const e2 = env(gone);
|
||||
expect(new Watcher(e2, REPO, 42, 'green', 20, botLists(e2.vars)).run()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 gstack-pr-wait stopped: gh: Not Found (HTTP 404)');
|
||||
});
|
||||
|
||||
test('a gh spawn timeout is retried like any transient failure; a missing gh is fatal', () => {
|
||||
const results: GhResult[] = [{ status: null, stdout: '', stderr: '', error: 'gh failed to run (ETIMEDOUT)' }, ok('fine')];
|
||||
const slept: number[] = [];
|
||||
const ghEnv = { gh: () => results.shift()!, sleep: (ms: number) => slept.push(ms), log: () => {} };
|
||||
expect(runGh(ghEnv, ['api', 'x']).stdout).toBe('fine');
|
||||
expect(slept).toEqual([2000]);
|
||||
const missing = { gh: (): GhResult => ({ status: null, stdout: '', stderr: '', error: 'gh is not installed or not on PATH' }), sleep: () => {}, log: () => {} };
|
||||
expect(() => runGh(missing, ['api', 'x'])).toThrow(GhError);
|
||||
});
|
||||
|
||||
test('a local fault is a refusal (exit 2 with PR_WAIT_REFUSED), so exit 1 always carries a PR_WAIT_BLOCKED line', () => {
|
||||
writeFileSync(join(dir, 'not-a-dir'), '');
|
||||
const e = env(fakeGh({}), 4242, { GSTACK_STATE_ROOT: join(dir, 'not-a-dir') });
|
||||
expect(prWaitMain(['42', '--repo', REPO], e)).toBe(PR_WAIT_EXIT.refused);
|
||||
expect(lines()[0]).toMatch(/a local error stopped the watch before a verdict: .*(ENOTDIR|EEXIST|not a directory).*\(PR_WAIT_REFUSED;/);
|
||||
});
|
||||
|
||||
test('control characters in GitHub text never start a second event line', () => {
|
||||
const w = watcher({ checks: ok(checksJson(['build\n#42 forged event', 'fail'])) }, 'green');
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 check failed: build #42 forged event https://ci.example/build #42 forged event');
|
||||
expect(lines()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('gstack-config validates the bot lists at set time and its defaults match the lib constants', () => {
|
||||
const run = (...args: string[]) => spawnSync(join(ROOT, 'bin', 'gstack-config'), args, { encoding: 'utf8', cwd: ROOT, timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: join(dir, 'state'), HOME: dir } });
|
||||
for (const bad of ['Greptile', 'a, b', 'a,', '']) {
|
||||
const r = run('set', 'pr_wait_gating_bots', bad);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain('Existing value left unchanged');
|
||||
}
|
||||
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe(DEFAULT_GATING_BOTS.join(','));
|
||||
expect(run('get', 'pr_wait_advisory_bots').stdout.trim()).toBe(DEFAULT_ADVISORY_BOTS.join(','));
|
||||
expect(run('set', 'pr_wait_advisory_bots', 'none').status).toBe(0);
|
||||
expect(run('set', 'pr_wait_gating_bots', 'greptile,macroscope.v2').status).toBe(0);
|
||||
expect(run('get', 'pr_wait_gating_bots').stdout.trim()).toBe('greptile,macroscope.v2');
|
||||
expect(botLists({ HOME: dir, GSTACK_STATE_ROOT: join(dir, 'state') })).toEqual({ gating: ['greptile', 'macroscope.v2'], advisory: [] });
|
||||
});
|
||||
|
||||
test('the bin dies on SIGTERM at once (no JS handler swallows it) and the next watcher takes over its lock', async () => {
|
||||
const fakeBin = join(dir, 'fakebin');
|
||||
mkdirSync(fakeBin);
|
||||
writeFileSync(join(fakeBin, 'gh'), `#!/bin/sh
|
||||
case "$1 $2" in
|
||||
'pr view') echo '{"number":42}' ;;
|
||||
'api graphql') echo '${prJson({})}' ;;
|
||||
'pr checks') echo '${checksJson(['build', 'pending'])}'; exit 8 ;;
|
||||
*) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;;
|
||||
esac`, { mode: 0o755 });
|
||||
const stateRoot = join(dir, 'state');
|
||||
const child = spawn(join(ROOT, 'bin', 'gstack-pr-wait'), ['42', '--repo', REPO, '--interval', '5'], { cwd: ROOT, env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, GSTACK_STATE_ROOT: stateRoot, HOME: dir } });
|
||||
const lockFile = join(stateRoot, 'pr-wait', 'owner__project__42.lock');
|
||||
const started = Date.now();
|
||||
while (!existsSync(lockFile) && Date.now() - started < 15_000) await Bun.sleep(100);
|
||||
expect(existsSync(lockFile)).toBe(true);
|
||||
child.kill('SIGTERM');
|
||||
const ended = await Promise.race([
|
||||
new Promise<string>(resolve => child.on('exit', (code, signal) => resolve(`exit ${code} ${signal}`))),
|
||||
Bun.sleep(5_000).then(() => 'alive'),
|
||||
]);
|
||||
expect(ended).toBe('exit null SIGTERM');
|
||||
expect(readFileSync(lockFile, 'utf8')).toBe(String(child.pid));
|
||||
const next = watcher({});
|
||||
expect(next.lock()).toBeNull();
|
||||
expect(readFileSync(lockFile, 'utf8')).toBe('4242');
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('state matrix cells (gstack-state-matrix: --until × bucket, merge state × --until, lock, baseline × event)', () => {
|
||||
const GREPTILE = review('g1', { login: 'greptile-apps', bot: true }, 'COMMENTED', '');
|
||||
|
||||
test('ready × fail: exit 1 with the failed-check line and PR_WAIT_BLOCKED', () => {
|
||||
const w = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) });
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 required check failed: free-tests https://ci.example/free-tests', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('any-change × pass: silent while nothing is new; one "checks finished" line once the set turns stable, then exit 0', () => {
|
||||
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'pass']))] }, 'any-change');
|
||||
expect(w.poll()).toBeNull();
|
||||
w.baseline = false;
|
||||
expect(w.poll()).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 checks finished on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state CLEAN$/)]);
|
||||
});
|
||||
|
||||
test('any-change × pending: silent, keeps polling', () => {
|
||||
expect(pollUntilDone(watcher({ checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
});
|
||||
|
||||
test('any-change × fail: the failed check is an event and the watch ends with exit 0, not blocked', () => {
|
||||
const w = watcher({ checks: [ok(checksJson(['build', 'pending'])), ok(checksJson(['build', 'fail']))] }, 'any-change');
|
||||
expect(pollUntilDone(w)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 check failed: build https://ci.example/build']);
|
||||
});
|
||||
|
||||
test('BEHIND × any-change and DIRTY × any-change: one merge-state line each, exit 0, no PR_WAIT_BLOCKED', () => {
|
||||
const behind = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'BEHIND' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
|
||||
expect(pollUntilDone(behind)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merge state is BEHIND on aaaaaaa: update the branch from main before merging']);
|
||||
stdout = [];
|
||||
const dirty = watcher({ pr: [prJson({ mergeState: 'CLEAN' }), prJson({ mergeState: 'DIRTY' })], checks: ok(checksJson(['build', 'pending'])) }, 'any-change');
|
||||
expect(pollUntilDone(dirty)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it']);
|
||||
});
|
||||
|
||||
test('DIRTY × ready: exit 1 with the DIRTY line and PR_WAIT_BLOCKED', () => {
|
||||
const w = watcher({ pr: prJson({ mergeState: 'DIRTY', reviews: [GREPTILE] }), checks: ok(checksJson(['build', 'pass'])) });
|
||||
expect(w.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()).toEqual(['#42 merge state is DIRTY on aaaaaaa: the branch conflicts with main, rebase it', expect.stringContaining('(PR_WAIT_BLOCKED;')]);
|
||||
});
|
||||
|
||||
test('UNKNOWN × green, UNKNOWN × any-change: no merge-state event; green is decided by the checks alone', () => {
|
||||
const green = watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pass'])) }, 'green');
|
||||
expect(pollUntilDone(green)).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual([expect.stringMatching(/^#42 green on aaaaaaa: 1 pass; no greptile or macroscope yet; 0 unresolved threads; merge state UNKNOWN; 0 commits behind main$/)]);
|
||||
expect(JSON.parse(readFileSync(green.stateFile, 'utf8')).merge_state).toBeNull();
|
||||
stdout = [];
|
||||
expect(pollUntilDone(watcher({ pr: prJson({ mergeState: 'UNKNOWN' }), checks: ok(checksJson(['build', 'pending'])) }, 'any-change'), 4)).toBeNull();
|
||||
expect(lines()).toEqual([]);
|
||||
});
|
||||
|
||||
test('lock: free × unlock is a no-op; dead-pid × unlock leaves the stale file for the next lock() to take over', () => {
|
||||
const w = watcher({});
|
||||
expect(() => w.unlock()).not.toThrow();
|
||||
expect(existsSync(w.lockFile)).toBe(false);
|
||||
mkdirSync(join(w.lockFile, '..'), { recursive: true });
|
||||
writeFileSync(w.lockFile, '999999');
|
||||
w.unlock();
|
||||
expect(readFileSync(w.lockFile, 'utf8')).toBe('999999');
|
||||
expect(w.lock()).toBeNull();
|
||||
expect(readFileSync(w.lockFile, 'utf8')).toBe('4242');
|
||||
});
|
||||
|
||||
test('restarted × required-check-failed and restarted × merged: the verdict line is printed again by the new process', () => {
|
||||
const first = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
|
||||
expect(first.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
stdout = [];
|
||||
const again = watcher({ rulesets: RULESETS, checks: ok(checksJson(['free-tests', 'fail'], ['windows-free-tests', 'pass'])) }, 'green');
|
||||
expect(again.baseline).toBe(false);
|
||||
expect(again.poll()).toBe(PR_WAIT_EXIT.blocked);
|
||||
expect(lines()[0]).toBe('#42 required check failed: free-tests https://ci.example/free-tests');
|
||||
stdout = [];
|
||||
const merged = watcher({ pr: prJson({ state: 'MERGED' }) }, 'green');
|
||||
expect(merged.baseline).toBe(false);
|
||||
expect(merged.poll()).toBe(PR_WAIT_EXIT.met);
|
||||
expect(lines()).toEqual(['#42 merged: https://github.com/owner/project/pull/42']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,4 +90,35 @@ describe('pregate invalidation', () => {
|
||||
expect(bad.status).toBe(EXIT.fail);
|
||||
expect(bad.err).toContain('(ARTIFACT_SCHEMA)');
|
||||
}, 30_000);
|
||||
|
||||
// Value: protects=every record gstack-pregate writes (run and clear) is mirrored byte for byte to <state root>/pregate/by-tree/<tree>.json, the path
|
||||
// gstack-ship-queue ready reads for a checkout that did not run the pregate, and a state root that cannot be written still leaves the record and
|
||||
// the exit code untouched; fails_when=the mirror is missing, lands under another path or name, holds different bytes than the record, is not
|
||||
// refreshed by clear, or an unwritable state root fails the run; why_new=the ship-queue test writes the mirror by hand and covers only the reader;
|
||||
// seam=none; no_claim=a second checkout reading it (test/gstack-ship-queue.test.ts covers ready)
|
||||
test('run and clear mirror the record to <state root>/pregate/by-tree/<tree>.json; an unwritable state root never fails the record', () => {
|
||||
const repo = fixture();
|
||||
commit(repo, { 'test/windows-paths.test.js': "const test = require('node:test');\ntest('windows paths', () => { /* touched */ });\n" });
|
||||
expect(run(repo, []).status).toBe(EXIT.ok);
|
||||
const record = fs.readFileSync(outFile(repo), 'utf8');
|
||||
const tree = (JSON.parse(record) as { tree: string }).tree;
|
||||
expect(tree).toMatch(/^[0-9a-f]{12,128}$/);
|
||||
const mirror = path.join(repo.home, 'pregate', 'by-tree', `${tree}.json`);
|
||||
expect(fs.readdirSync(path.join(repo.home, 'pregate', 'by-tree'))).toEqual([`${tree}.json`]);
|
||||
expect(fs.readFileSync(mirror, 'utf8')).toBe(record);
|
||||
expect(runBin(repo, 'gstack-pregate', ['clear', '--remote', 'windows.yml/windows-shard', '--run-url', 'https://github.com/o/r/actions/runs/42']).status).toBe(EXIT.ok);
|
||||
const cleared = fs.readFileSync(outFile(repo), 'utf8');
|
||||
expect(cleared).not.toBe(record);
|
||||
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
|
||||
expect(JSON.parse(fs.readFileSync(mirror, 'utf8')).requires_remote[0].receipt).toBe('https://github.com/o/r/actions/runs/42');
|
||||
|
||||
const blocked = path.join(repo.root, 'not-a-dir');
|
||||
fs.writeFileSync(blocked, 'a file where the state root should be\n');
|
||||
const unwritable = runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands'], { GSTACK_STATE_ROOT: path.join(blocked, 'root') });
|
||||
expect(unwritable.status, unwritable.out + unwritable.err).toBe(EXIT.ok);
|
||||
expect(unwritable.err).not.toMatch(/ENOTDIR|EACCES|by-tree/);
|
||||
expect(JSON.parse(fs.readFileSync(outFile(repo), 'utf8')).tree).toBe(tree);
|
||||
expect(fs.existsSync(path.join(blocked, 'root'))).toBe(false);
|
||||
expect(fs.readFileSync(mirror, 'utf8')).toBe(cleared);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -161,8 +161,8 @@ describe('QA-only cross-host lazy rendering', () => {
|
||||
expect(fs.readFileSync(path.join(rendered, dir, 'SKILL.md'), 'utf8')).not.toContain(body.split('\n').slice(2).join('\n').trim());
|
||||
}
|
||||
const outside = generated.artifacts.filter(artifact => artifact.host === host.name && artifact.kind === 'section'
|
||||
&& !/^(?:qa|qa-only|ship|plan-ceo-review|office-hours)\//.test(artifact.relativePath)
|
||||
&& !/\/gstack-(?:qa(?:-only)?|ship|plan-ceo-review|office-hours)\//.test(artifact.relativePath));
|
||||
&& !/^(?:qa|qa-only|ship|plan-ceo-review|office-hours|design-review)\//.test(artifact.relativePath)
|
||||
&& !/\/gstack-(?:qa(?:-only)?|ship|plan-ceo-review|office-hours|design-review)\//.test(artifact.relativePath));
|
||||
expect(outside.length > 0).toBe(host.name === 'claude');
|
||||
// C4: ship is carved on every host; external pointers are relative to the installed skill.
|
||||
const ctx = context(host.name, 'ship');
|
||||
|
||||
+171
-3
@@ -14,7 +14,7 @@ import * as path from 'node:path';
|
||||
import { bunIsolationArgs } from '../lib/bun-isolation';
|
||||
import {
|
||||
BACKSTOP_CHECK, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, changelogEntries, compareVersions, cutRelease, describePlan, gateVerdict, gitIn,
|
||||
planRelease, releaseNotes, type Gh,
|
||||
planRelease, readTrainNotes, releaseNotes, type Gh, type Git,
|
||||
} from '../scripts/release-cut';
|
||||
|
||||
const bases: string[] = [];
|
||||
@@ -154,13 +154,13 @@ describe('release-cut: planning', () => {
|
||||
const queued = checks([{ check_runs: [...GREEN, { name: 'slices-report', status: 'queued', conclusion: null }] }]);
|
||||
expect(gateVerdict(queued, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'check runs still going: slices-report', pending: true });
|
||||
const paged = checks([{ check_runs: [{ name: 'typecheck', status: 'completed', conclusion: 'success' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'success' }] }, { check_runs: [{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'success' }] }]);
|
||||
expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
|
||||
expect(gateVerdict(paged, 'o/r', 'a'.repeat(40))).toEqual({ green: true, via: BACKSTOP_CHECK });
|
||||
const pagedRed = checks([{ check_runs: GREEN }, { check_runs: [{ name: 'windows-free-tests', status: 'completed', conclusion: 'timed_out' }] }]);
|
||||
expect(gateVerdict(pagedRed, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: windows-free-tests', pending: false });
|
||||
const cancelled = checks([{ check_runs: [...GREEN, { name: 'quality', status: 'completed', conclusion: 'cancelled' }, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
|
||||
expect(gateVerdict(cancelled, 'o/r', 'a'.repeat(40))).toEqual({ green: false, why: 'red check runs: quality', pending: false });
|
||||
const skipped = checks([{ check_runs: [...GREEN, { name: 'docs', status: 'completed', conclusion: 'skipped' }] }]);
|
||||
expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true });
|
||||
expect(gateVerdict(skipped, 'o/r', 'a'.repeat(40))).toEqual({ green: true, via: BACKSTOP_CHECK });
|
||||
});
|
||||
|
||||
test('an active freeze in refs/notes/gstack-trains refuses the cut; a cleared one does not', () => {
|
||||
@@ -175,6 +175,174 @@ describe('release-cut: planning', () => {
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
expect(planRelease({ repo: f.repo, git: f.git, gh: f.gh }).ok).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=one read of the trains notes ref feeds both gates: a fetch failure other than a missing ref refuses the plan with its message, a
|
||||
// missing ref is no freeze and no evidence, a blob that is not JSON, is JSON that is not an object (null, an array, a string, a number), carries a
|
||||
// malformed merged[] or a non-string nonce, or is a train without a nonce is skipped while a freeze and a train in the same
|
||||
// ref are both read (the freeze still wins over the evidence); fails_when=a fetch error is read as "no notes" and a frozen or ungated main is
|
||||
// released, an unparsable note aborts the read, or evidence from a later note is lost once a freeze is seen; why_new=the freeze test covers one
|
||||
// readable freeze record only and the evidence tests never mix record kinds or break the fetch; seam=none; no_claim=a real origin outage
|
||||
test('readTrainNotes: a fetch error refuses, a missing ref holds nothing, junk notes are skipped, and a freeze beside train evidence still refuses', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
const failing: Git = (args) => (args[0] === 'fetch' ? { ok: false, out: 'fatal: unable to access the remote: Could not resolve host' } : f.git(args));
|
||||
const broken = planRelease({ repo: f.repo, git: failing, gh: f.gh });
|
||||
expect(broken).toEqual({ ok: false, code: 'REFUSED', message: 'cannot fetch ' + NOTES_REF + ': fatal: unable to access the remote: Could not resolve host' });
|
||||
const absent = readTrainNotes(f.git);
|
||||
expect([absent.error, absent.freeze, absent.evidence.bySha.size, absent.evidence.byTree.size]).toEqual([null, null, 0, 0]);
|
||||
|
||||
const add = (record: string, on: string) => git(f.seed, 'notes', '--ref=' + NOTES_REF, 'add', '-f', '-m', record, on);
|
||||
add('not json {', f.shas['1.0.0']!);
|
||||
for (const junk of ['null', '[]', '"train"', '7', JSON.stringify({ kind: 'train', nonce: 'bad1', state: 'green', merged: 'x' }), JSON.stringify({ kind: 'train', nonce: 'bad2', state: 'green', merged: [null, 'y', { sha: 3, tree: ['t'] }, { sha: '', tree: '' }] }), JSON.stringify({ kind: 'train', nonce: 9, state: 'green', merged: [{ sha: f.shas['1.0.0'] }] })]) {
|
||||
add(junk, f.shas['1.0.0']!);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
const read = readTrainNotes(f.git);
|
||||
expect([junk, read.error, read.freeze, read.evidence.bySha.size, read.evidence.byTree.size]).toEqual([junk, null, null, 0, 0]);
|
||||
}
|
||||
add(JSON.stringify({ schema: 1, kind: 'train', state: 'merged', merged: [{ sha: f.shas['1.0.1'], tree: 't1' }] }), f.shas['1.0.1']!);
|
||||
add(JSON.stringify({ schema: 1, kind: 'train', nonce: 'ok1', state: 'green', merged: [{ pr: '2' }, { sha: f.shas['1.0.2'] }, { tree: 'tree-only' }] }), f.shas['1.0.2']!);
|
||||
add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: null }), head);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
const mixed = readTrainNotes(f.git);
|
||||
expect(mixed.error).toBeNull();
|
||||
expect(mixed.freeze).toBe('queue frozen since train ok1: backstop red');
|
||||
expect([...mixed.evidence.bySha]).toEqual([[f.shas['1.0.2']!, 'ok1']]);
|
||||
expect([...mixed.evidence.byTree]).toEqual([['tree-only', 'ok1']]);
|
||||
f.state.checks[f.shas['1.0.2']!] = backstop('completed', 'neutral');
|
||||
const frozen = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
|
||||
expect(frozen).toEqual({ ok: false, code: 'REFUSED', message: 'queue frozen since train ok1: backstop red' });
|
||||
add(JSON.stringify({ schema: 1, kind: 'freeze', train: 'ok1', reason: 'backstop red', cleared_at: 'y' }), head);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', NOTES_REF);
|
||||
const thawed = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
|
||||
expect(thawed.ok, JSON.stringify(thawed)).toBe(true);
|
||||
if (thawed.ok) expect(thawed.plan.via).toBe('train ok1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('release-cut: train evidence gates a commit whose backstop never settled', () => {
|
||||
const train = (nonce: string, state: string, merged: Array<{ sha: string; tree: string }>) =>
|
||||
({ schema: 1, kind: 'train', nonce, state, merged: merged.map(m => ({ pr: '1', at: 'x', ...m })), updated_at: 'x' });
|
||||
const note = (f: ReturnType<typeof fixture>, record: unknown, on: string, ref = NOTES_REF) => {
|
||||
git(f.seed, 'notes', '--ref=' + ref, 'add', '-f', '-m', JSON.stringify(record), on);
|
||||
git(f.seed, 'push', '-q', '-f', 'origin', ref);
|
||||
};
|
||||
const treeOf = (f: ReturnType<typeof fixture>, sha: string) => git(f.seed, 'rev-parse', sha + '^{tree}');
|
||||
|
||||
// Value: protects=a main commit whose main-backstop check run is neutral (superseded) or absent is released when a green, merging or merged train record's
|
||||
// merged[] names its sha or its tree, and the CANDIDATE line says which train gated it; fails_when=the gate still needs a successful backstop run on
|
||||
// every commit (the weekly cut returns NO_GREEN_COMMIT for a week of train-merged commits), evidence is matched on sha only, or the plan hides what gated
|
||||
// the commit; why_new=the planning tests know only the backstop check run as evidence; seam=none; no_claim=real refs/notes/gstack-trains content (a
|
||||
// fixture record shaped like lib/ship-queue-train-record.ts TrainRecord)
|
||||
test('a neutral backstop plus a train record naming the sha, or only the tree, is a candidate gated by that train', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
f.state.checks[head] = backstop('completed', 'neutral');
|
||||
note(f, train('t1', 'merged', [{ sha: head, tree: treeOf(f, head) }]), head);
|
||||
const bySha = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(bySha.ok, JSON.stringify(bySha)).toBe(true);
|
||||
if (!bySha.ok) return;
|
||||
expect(bySha.plan.sha).toBe(head);
|
||||
expect(bySha.plan.via).toBe('train t1');
|
||||
expect(describePlan(bySha.plan, true)[0]).toBe('CANDIDATE: ' + head + ' (' + bySha.plan.date + ') gated by train t1');
|
||||
|
||||
const byTree = fixture();
|
||||
const head2 = byTree.shas['1.0.3']!;
|
||||
delete byTree.state.checks[head2];
|
||||
byTree.state.checks[head2] = GREEN.filter(c => c.name !== BACKSTOP_CHECK);
|
||||
note(byTree, train('t2', 'green', [{ sha: 'f'.repeat(40), tree: treeOf(byTree, head2) }]), head2);
|
||||
const r = planRelease({ repo: byTree.repo, git: byTree.git, gh: byTree.gh });
|
||||
expect(r.ok, JSON.stringify(r)).toBe(true);
|
||||
if (r.ok) { expect(r.plan.sha).toBe(head2); expect(r.plan.via).toBe('train t2'); }
|
||||
|
||||
const plain = planRelease({ repo: f.repo, ref: f.shas['1.0.2'], git: f.git, gh: f.gh });
|
||||
expect(plain.ok).toBe(true);
|
||||
if (plain.ok) { expect(plain.plan.via).toBe(BACKSTOP_CHECK); expect(describePlan(plain.plan, false)[0]).toBe('CANDIDATE: ' + f.shas['1.0.2'] + ' (' + plain.plan.date + ') [hotfix: --ref] gated by ' + BACKSTOP_CHECK); }
|
||||
});
|
||||
|
||||
// Value: protects=train evidence never relaxes the rest of the gate: a covered commit still needs a successful required check run (also when
|
||||
// --required-check and --backstop-check name the same check, where evidence substitutes for nothing), no red check run (a red backstop included),
|
||||
// nothing still running, and only a green, merging or merged record counts; fails_when=a covered commit with a failed free-tests run, a red windows lane
|
||||
// or a red backstop is released, a running check is not waited for, a red or superseded train record is read as evidence, or equal check names let a
|
||||
// covered commit with zero check runs through; why_new=the first train test covers the accepting path only; seam=none; no_claim=none beyond protects
|
||||
test('a covered commit is still refused by a red check run, a red backstop, a missing or failed required run, a running run, or a red/superseded record', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
const tree = treeOf(f, head);
|
||||
note(f, train('t1', 'merging', [{ sha: head, tree }]), head);
|
||||
const gate = (checks: CheckRun[]) => { f.state.checks[head] = checks; const v = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh }); return v.ok ? 'green' : v.message; };
|
||||
expect(gate([...backstop('completed', 'neutral'), { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: red check runs: windows-free-tests');
|
||||
expect(gate(backstop('completed', 'failure'))).toBe('--ref ' + head.slice(0, 12) + ' is not green: red check runs: ' + BACKSTOP_CHECK);
|
||||
expect(gate([{ name: BACKSTOP_CHECK, status: 'completed', conclusion: 'neutral' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: no ' + REQUIRED_CHECK + ' check run');
|
||||
expect(gate([{ name: REQUIRED_CHECK, status: 'completed', conclusion: 'failure' }, { name: BACKSTOP_CHECK, status: 'completed', conclusion: 'neutral' }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: ' + REQUIRED_CHECK + ' concluded failure');
|
||||
expect(gate(backstop('in_progress', null))).toBe('--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' is still running');
|
||||
expect(gate([...backstop('completed', 'neutral'), { name: 'slices-report', status: 'queued', conclusion: null }])).toBe('--ref ' + head.slice(0, 12) + ' is not green: check runs still going: slices-report');
|
||||
expect(gate(backstop('completed', 'neutral'))).toBe('green');
|
||||
f.state.checks[head] = [];
|
||||
const same = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' });
|
||||
expect(same.ok ? 'green' : same.message).toBe('--ref ' + head.slice(0, 12) + ' is not green: no ci check run');
|
||||
f.state.checks[head] = [{ name: 'ci', status: 'completed', conclusion: 'success' }];
|
||||
const sameGreen = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'ci' });
|
||||
expect(sameGreen.ok ? sameGreen.plan.via : sameGreen.message).toBe('ci');
|
||||
|
||||
for (const state of ['red', 'superseded', 'incomplete', 'free-green']) {
|
||||
note(f, train('t1', state, [{ sha: head, tree }]), head);
|
||||
expect([state, gate(backstop('completed', 'neutral'))]).toEqual([state, '--ref ' + head.slice(0, 12) + ' is not green: ' + BACKSTOP_CHECK + ' concluded neutral']);
|
||||
}
|
||||
|
||||
for (const sha of Object.values(f.shas)) f.state.checks[sha] = backstop('completed', 'neutral');
|
||||
const none = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(none.ok).toBe(false);
|
||||
if (none.ok) return;
|
||||
expect(none.code).toBe('NO_GREEN_COMMIT');
|
||||
expect(none.message).toBe('no green commit among 4 on origin/main; see the skipped list');
|
||||
expect(none.skipped).toEqual(Object.values(f.shas).reverse().map(s => s.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral'));
|
||||
note(f, train('other', 'merged', [{ sha: f.shas['1.0.1']!, tree: treeOf(f, f.shas['1.0.1']!) }]), head);
|
||||
const older = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(older.ok, JSON.stringify(older)).toBe(true);
|
||||
if (!older.ok) return;
|
||||
expect(older.plan.sha).toBe(f.shas['1.0.1']);
|
||||
expect(older.plan.via).toBe('train other');
|
||||
expect(older.plan.skipped).toEqual([head.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral', f.shas['1.0.2']!.slice(0, 12) + ': ' + BACKSTOP_CHECK + ' concluded neutral']);
|
||||
});
|
||||
|
||||
// Value: protects=--required-check, --backstop-check and --notes-ref rename what the gate reads so another repository can run the script, and the
|
||||
// defaults stay free-tests, main-backstop and refs/notes/gstack-trains for weekly-release.yml; fails_when=a renamed check is still looked up under the
|
||||
// default name, the train notes are read from the default ref only, or a flag is misread by the CLI; why_new=every other test runs under the defaults;
|
||||
// seam=none; no_claim=none beyond protects
|
||||
test('--required-check, --backstop-check and --notes-ref rename what the gate reads, through planRelease and the CLI', () => {
|
||||
const f = fixture();
|
||||
const head = f.shas['1.0.3']!;
|
||||
const renamed: CheckRun[] = [{ name: 'ci', status: 'completed', conclusion: 'success' }, { name: 'nightly-backstop', status: 'completed', conclusion: 'success' }];
|
||||
for (const sha of Object.values(f.shas)) f.state.checks[sha] = renamed;
|
||||
const defaults = planRelease({ repo: f.repo, git: f.git, gh: f.gh });
|
||||
expect(defaults.ok).toBe(false);
|
||||
if (!defaults.ok) expect(defaults.skipped![0]).toBe(head.slice(0, 12) + ': no ' + REQUIRED_CHECK + ' check run');
|
||||
const flagged = planRelease({ repo: f.repo, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop' });
|
||||
expect(flagged.ok, JSON.stringify(flagged)).toBe(true);
|
||||
if (flagged.ok) { expect(flagged.plan.sha).toBe(head); expect(flagged.plan.via).toBe('nightly-backstop'); }
|
||||
|
||||
f.state.checks[head] = [{ name: 'ci', status: 'completed', conclusion: 'success' }, { name: 'nightly-backstop', status: 'completed', conclusion: 'neutral' }];
|
||||
note(f, train('t9', 'merged', [{ sha: head, tree: treeOf(f, head) }]), head, 'refs/notes/other-trains');
|
||||
const defaultRef = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop' });
|
||||
expect(defaultRef.ok).toBe(false);
|
||||
if (!defaultRef.ok) expect(defaultRef.message).toBe('--ref ' + head.slice(0, 12) + ' is not green: nightly-backstop concluded neutral');
|
||||
const otherRef = planRelease({ repo: f.repo, ref: head, git: f.git, gh: f.gh, requiredCheck: 'ci', backstopCheck: 'nightly-backstop', notesRef: 'refs/notes/other-trains' });
|
||||
expect(otherRef.ok, JSON.stringify(otherRef)).toBe(true);
|
||||
if (otherRef.ok) expect(otherRef.plan.via).toBe('train t9');
|
||||
|
||||
if (process.platform === 'win32') return;
|
||||
const shim = path.join(f.base, 'shim');
|
||||
fs.mkdirSync(shim);
|
||||
fs.writeFileSync(path.join(shim, 'gh'), '#!/usr/bin/env bash\ncase "$*" in\n *check-runs*) echo \'' + JSON.stringify([{ check_runs: f.state.checks[head] }]) + '\' ;;\n *) exit 1 ;;\nesac\n', { mode: 0o755 });
|
||||
const run = (...args: string[]) => spawnSync(process.execPath, [...bunIsolationArgs(), 'run', path.join(path.resolve(import.meta.dir, '..'), 'scripts', 'release-cut.ts'), ...args],
|
||||
{ cwd: f.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, PATH: shim + path.delimiter + process.env.PATH } });
|
||||
const cli = run('--repo', f.repo, '--dry-run', '--ref', head, '--required-check', 'ci', '--backstop-check', 'nightly-backstop', '--notes-ref', 'refs/notes/other-trains');
|
||||
expect(cli.status, cli.stdout + cli.stderr).toBe(0);
|
||||
expect(cli.stdout.split('\n')[0]).toBe('CANDIDATE: ' + head + ' (' + git(f.seed, 'show', '-s', '--format=%cs', head) + ') [hotfix: --ref] gated by train t9');
|
||||
const unnamed = run('--repo', f.repo, '--dry-run', '--ref', head, '--required-check', 'ci', '--backstop-check', 'nightly-backstop');
|
||||
expect(unnamed.status).toBe(1);
|
||||
expect(unnamed.stdout.split('\n')[0]).toBe('REFUSED: --ref ' + head.slice(0, 12) + ' is not green: nightly-backstop concluded neutral');
|
||||
});
|
||||
});
|
||||
|
||||
describe('release-cut: cutting, hotfixes and the forward-only stable', () => {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Restamp mention scanning and rewriting (gbrain #7640, gstack 1.91.246.0): a
|
||||
* PR that stamped X lost its slot, merged the base, and the base was X by
|
||||
* then, so the branch's VERSION equalled the base's (no branch stamp) and its
|
||||
* quoted `since: 'X'` rows never moved: the mention scanner could not pass
|
||||
* the opening quote and, with no branch stamp, nothing else named X. Pins
|
||||
* the quoted forms (single, double, unquoted unchanged, quote style kept) in
|
||||
* both the scanner and the rewrite table, the equal-version rule (the base
|
||||
* version moves only on lines the branch added), and the negative control
|
||||
* (a base-side quoted row carrying that version stays).
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { rewriteStamps, tentativeMentions } from '../lib/restamp';
|
||||
import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture';
|
||||
|
||||
const repos: FixtureRepo[] = [];
|
||||
afterEach(() => { while (repos.length) cleanup(repos.pop()!); });
|
||||
|
||||
const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out);
|
||||
|
||||
describe('quoted stamp forms (pure)', () => {
|
||||
test('rewriteStamps: since: with a single or double quote moves and keeps its quote; the unquoted form is unchanged', () => {
|
||||
expect(rewriteStamps("{ since: '1.2.3.0', id: 'a' }", '1.2.3.0', '1.2.4.0')).toEqual({ text: "{ since: '1.2.4.0', id: 'a' }", count: 1 });
|
||||
expect(rewriteStamps('{ since: "1.2.3.0", id: "a" }', '1.2.3.0', '1.2.4.0')).toEqual({ text: '{ since: "1.2.4.0", id: "a" }', count: 1 });
|
||||
expect(rewriteStamps("since:'1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "since:'1.2.4.0'", count: 1 });
|
||||
expect(rewriteStamps('since: 1.2.3.0', '1.2.3.0', '1.2.4.0')).toEqual({ text: 'since: 1.2.4.0', count: 1 });
|
||||
// A quote is an exact boundary too: a longer version behind it is not X.
|
||||
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0.1'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
// An opened quote must close right after X: a quoted prerelease or a mismatched pair is not the form
|
||||
// (the tree receipt and gate reuse share this table, so a looser match would call a real change a stamp).
|
||||
expect(rewriteStamps("since: '1.2.3.0-beta'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps('since: "1.2.3.0-beta"', '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0\"", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps('since: 1.2.3.0-beta', '1.2.3.0', '1.2.4.0').count).toBe(1);
|
||||
});
|
||||
|
||||
test('rewriteStamps: (was X) accepts a matching quote pair and keeps it; a mismatched pair is not the form; a quoted vX already matched', () => {
|
||||
expect(rewriteStamps("(was '1.2.3.0')", '1.2.3.0', '1.2.4.0')).toEqual({ text: "(was '1.2.4.0')", count: 1 });
|
||||
expect(rewriteStamps('(was "1.2.3.0")', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was "1.2.4.0")', count: 1 });
|
||||
expect(rewriteStamps('(was 1.2.3.0)', '1.2.3.0', '1.2.4.0')).toEqual({ text: '(was 1.2.4.0)', count: 1 });
|
||||
expect(rewriteStamps("(was '1.2.3.0\")", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
expect(rewriteStamps("'v1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "'v1.2.4.0'", count: 1 });
|
||||
// A bare pin equal to X is still not a stamp form, quoted or not.
|
||||
expect(rewriteStamps("pinned: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
});
|
||||
|
||||
test('rewriteStamps: a policy template that overlaps the built-in quoted form counts the mention once', () => {
|
||||
const r = rewriteStamps(" { since: '1.2.3.0', id: 'x', text: 't' },", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]);
|
||||
expect(r).toEqual({ text: " { since: '1.2.4.0', id: 'x', text: 't' },", count: 1 });
|
||||
});
|
||||
|
||||
test('tentativeMentions: quoted since: rows and template forms on branch-added lines are candidates like the unquoted ones', () => {
|
||||
const added = new Map([['src/notice.ts', new Set([
|
||||
" { since: '1.2.4.0', id: 'single', text: 't' },",
|
||||
' { since: "1.2.5.0", id: "double", text: "t" },',
|
||||
' since: 1.2.6.0',
|
||||
'<!-- x-tree-stamp: 1.2.7.0 -->',
|
||||
" pinned: '1.2.8.0'",
|
||||
])]]);
|
||||
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0']);
|
||||
expect(tentativeMentions(added, '1.2.3.0', '1.2.9.0', ['<!-- x-tree-stamp: {version} -->']).sort()).toEqual(['1.2.4.0', '1.2.5.0', '1.2.6.0', '1.2.7.0']);
|
||||
// The fork-version floor and the allocated version still apply to quoted mentions.
|
||||
expect(tentativeMentions(added, '1.2.5.0', '1.2.6.0').sort()).toEqual([]);
|
||||
// A quoted prerelease, a mismatched pair and an unclosed (was are not candidates; the bare prerelease still is.
|
||||
const odd = new Map([['x', new Set(["since: '1.2.4.0-rc'", 'since: "1.2.5.0\'', "(was '1.2.6.0'x", '(was 1.2.7.0-rc)', 'since: 1.2.8.0-rc'])]]);
|
||||
expect(tentativeMentions(odd, '1.2.3.0', '1.2.9.0').sort()).toEqual(['1.2.8.0']);
|
||||
});
|
||||
});
|
||||
|
||||
/** A gbrain-shaped fixture: a quoted-row source file in stamp_files, with and without the since: template. */
|
||||
const NOTICE = 'src/notice.ts';
|
||||
const row = (v: string, id: string) => ` { since: '${v}', id: '${id}', text: '${id}' },`;
|
||||
const notice = (rows: string[]) => ['export const ROWS = [', ...rows, '];', ''].join('\n');
|
||||
function policy(templates: boolean) {
|
||||
return { ...BASE_POLICY, release_tool: null, stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md', NOTICE], ...(templates ? { stamp_patterns: ["since: '{version}'"] } : {}) };
|
||||
}
|
||||
function write(repo: FixtureRepo, rel: string, text: string): void {
|
||||
fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, rel), text);
|
||||
}
|
||||
/** The repo's own stamp on the branch, the way gbrain's release:restamp leaves it: VERSION and the rows at X. */
|
||||
function stampBranch(repo: FixtureRepo, v: string, rows: string[]): void {
|
||||
write(repo, 'VERSION', v + '\n');
|
||||
write(repo, NOTICE, notice(rows));
|
||||
repo.git('commit', '-qam', `chore: bump version (v${v})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reporter's state: the PR stamped 1.2.4.0 (VERSION and its row), another PR took the slot and the base
|
||||
* is 1.2.4.0 with its own quoted 1.2.4.0 row, the PR merged the base. VERSION equals the base's on both sides.
|
||||
*/
|
||||
function lostSlot(repo: FixtureRepo): void {
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
stampBranch(repo, '1.2.4.0', [row('1.2.3.0', 'base_row'), '', '', '', row('1.2.4.0', 'mine')]);
|
||||
// The other PR takes the slot through the real stamp: VERSION 1.2.4.0, its CHANGELOG entry headed, its own 1.2.4.0 row.
|
||||
makePr(repo, 'pr-other');
|
||||
write(repo, NOTICE, notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
|
||||
repo.git('commit', '-qam', 'other row');
|
||||
expect(restamp(repo, ['--next']).status).toBe(EXIT.ok);
|
||||
repo.git('commit', '-qam', 'stamp other');
|
||||
mergeToMain(repo, 'pr-other');
|
||||
repo.git('checkout', '-q', 'pr-mine');
|
||||
mergeMain(repo);
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.4.0\n');
|
||||
expect(repo.git('show', 'origin/main:VERSION')).toBe('1.2.4.0');
|
||||
}
|
||||
|
||||
describe('a PR whose VERSION already equals the base (gbrain #7640)', () => {
|
||||
test.each([['with the since: template', true], ['built-in quoted form alone', false]])('%s: the PR\'s quoted row moves, the base\'s own quoted rows stay, and the stage says why', (_, templates) => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-', policy: policy(templates) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
const dry = restamp(repo, ['--next', '--dry-run']);
|
||||
expect(dry.status).toBe(EXIT.ok);
|
||||
expect(dry.out).toMatch(/^- \{ since: '1\.2\.4\.0', id: 'mine', text: 'mine' \},$/m);
|
||||
expect(dry.out).toMatch(/^\+ \{ since: '1\.2\.5\.0', id: 'mine', text: 'mine' \},$/m);
|
||||
expect(repo.git('status', '--porcelain')).toBe('');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(r.out).toContain('RESTAMP: 1.2.4.0 -> 1.2.5.0 (stamped;');
|
||||
const sp = stageDetail(r.out, 'stamp_paths')!;
|
||||
expect(sp[1]).toBe('done');
|
||||
expect(sp[2]).toBe(`1 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0); VERSION equals base 1.2.4.0, so 1.2.4.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
|
||||
// The base's rows are byte-identical to origin/main's: the 1.2.4.0 row is the base's release and never moved.
|
||||
expect(repo.git('show', `origin/main:${NOTICE}`) + '\n').toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row')]));
|
||||
const journal = JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json'));
|
||||
expect(journal.inputs).toMatchObject({ old_version: '1.2.4.0', new_version: '1.2.5.0' });
|
||||
expect(journal.stages.find((s: any) => s.name === 'stamp_paths').detail).toContain('1 moved, 1 kept');
|
||||
});
|
||||
|
||||
test('negative control: with no branch-added mention of the base version nothing moves, every base-side quoted row stays, and the stage says so', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-neg-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
// The PR adds a row of its own in another form entirely, plus an unrelated line in the stamp file.
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
|
||||
repo.git('commit', '-qam', 'my row');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
const sp = stageDetail(r.out, 'stamp_paths')!;
|
||||
expect(sp[1]).toBe('skipped');
|
||||
expect(sp[2]).toBe('no tentative mention newer than the fork version 1.2.3.0 and none of base 1.2.3.0 on a line this branch added; VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (src/notice.ts: 0 moved, 1 kept)');
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), " { since: 'TBD', id: 'mine', text: 'mine' },"]));
|
||||
expect(r.out).not.toMatch(/^[-+] \{ since: '1\.2\.3\.0'/m);
|
||||
});
|
||||
|
||||
test('a branch forked at the base version (fork equals base, nothing stamped) still moves its own base-version row and keeps the base\'s', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-fork-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]));
|
||||
write(repo, 'docs/pr-mine.md', 'Requires v1.2.3.0 or later.\n');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'my row');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 2 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base\'s own lines (docs/pr-mine.md: 1 moved, 0 kept; src/notice.ts: 1 moved, 1 kept)');
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]));
|
||||
expect(readFile(repo, 'docs/pr-mine.md')).toBe('Requires v1.2.4.0 or later.\n');
|
||||
expect(readFile(repo, 'docs/notes.md')).toBe('Previous release v1.2.3.0.\nleft-pad pinned at 1.2.3 (was 1.2.2)\n');
|
||||
});
|
||||
|
||||
test('provenance is by position: a branch-added line whose text equals a base line moves alone, and the base copy stays', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-dup-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'dup')]));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.3.0', 'dup')]));
|
||||
repo.git('commit', '-qam', 'my row, same text as the base row');
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe(`1 mention(s) in 1 file(s) (1.2.3.0 -> 1.2.4.0); VERSION equals base 1.2.3.0, so 1.2.3.0 moved only on lines this branch added and stayed on the base's own lines (${NOTICE}: 1 moved, 1 kept)`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'dup'), '', '', '', row('1.2.4.0', 'dup')]));
|
||||
});
|
||||
|
||||
test('diff.interHunkContext joining two hunks around a base row still places each branch row by position', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-ihc-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, NOTICE, notice([' { since: "0.0.0.0", id: "first_placeholder" },', row('1.2.3.0', 'base_row'), ' { since: "0.0.0.0", id: "second_placeholder" },']));
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
repo.git('config', 'diff.interHunkContext', '1');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.3.0', 'second')]));
|
||||
repo.git('commit', '-qam', 'two rows around the base row');
|
||||
// The joined hunk carries the base row as context between the two added lines.
|
||||
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toMatch(/\n\+ \{ since: '1\.2\.3\.0', id: 'first'.*\n \{ since: '1\.2\.3\.0', id: 'base_row'.*\n-.*\n\+ \{ since: '1\.2\.3\.0', id: 'second'/);
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 2 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'first'), row('1.2.3.0', 'base_row'), row('1.2.4.0', 'second')]));
|
||||
});
|
||||
|
||||
test('a CRLF checkout (core.autocrlf, eol=crlf) whose diff is LF-normalized still finds the branch-added row', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-crlf-', policy: policy(true) }); repos.push(repo);
|
||||
write(repo, '.gitattributes', `${NOTICE} text eol=crlf\n`);
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row')]));
|
||||
repo.git('config', 'core.autocrlf', 'true');
|
||||
repo.git('add', '-A'); repo.git('commit', '-qm', 'notice rows'); repo.git('push', '-q', 'origin', 'main');
|
||||
makePr(repo, 'pr-mine');
|
||||
write(repo, NOTICE, notice([row('1.2.3.0', 'base_row'), row('1.2.3.0', 'mine')]).replace(/\n/g, '\r\n'));
|
||||
repo.git('commit', '-qam', 'my row');
|
||||
expect(repo.git('diff', '--unified=0', 'origin/main', '--', NOTICE)).toContain("+ { since: '1.2.3.0', id: 'mine'");
|
||||
const r = restamp(repo, ['--next']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.3.0', 'base_row'), row('1.2.4.0', 'mine')]).replace(/\n/g, '\r\n'));
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('a stamp interrupted after VERSION was written retries as a stamp, not a noop: the journal restores the equal-base provenance', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-retry-', policy: policy(true) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
const dir = path.join(repo.work, path.dirname(NOTICE));
|
||||
fs.chmodSync(dir, 0o555);
|
||||
try {
|
||||
const first = restamp(repo, ['--next']);
|
||||
expect(first.status).not.toBe(EXIT.ok);
|
||||
expect(first.err).toMatch(/EACCES|permission denied/i);
|
||||
} finally { fs.chmodSync(dir, 0o755); }
|
||||
expect(readFile(repo, 'VERSION')).toBe('1.2.5.0\n');
|
||||
expect(readFile(repo, NOTICE)).toContain(row('1.2.4.0', 'mine'));
|
||||
const again = restamp(repo, ['--next']);
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(again.out).toContain('RESTAMP: 1.2.5.0 -> 1.2.5.0 (stamped;');
|
||||
expect(stageDetail(again.out, 'stamp_paths')![2]).toContain(`${NOTICE}: 1 moved, 1 kept`);
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.4.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
|
||||
expect(JSON.parse(readFile(repo, '.gstack/tmp/restamp-journal.json')).status).toBe('complete');
|
||||
});
|
||||
|
||||
test('--was still rewrites every line, the base\'s rows included: it is not the fix for this state', () => {
|
||||
const repo = makeFixtureRepo({ prefix: 'restamp-mentions-was-', policy: policy(true) }); repos.push(repo);
|
||||
lostSlot(repo);
|
||||
const r = restamp(repo, ['--next', '--was', '1.2.4.0']);
|
||||
expect(r.status).toBe(EXIT.ok);
|
||||
expect(stageDetail(r.out, 'stamp_paths')![2]).toBe('2 mention(s) in 1 file(s) (1.2.4.0 -> 1.2.5.0)');
|
||||
expect(readFile(repo, NOTICE)).toBe(notice([row('1.2.5.0', 'other'), row('1.2.3.0', 'base_row'), '', '', '', row('1.2.5.0', 'mine')]));
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,26 @@ describe('issue intent (pure)', () => {
|
||||
expect(mentionsIn('v1.2.3 (#301) release notes').map(x => x.issue)).toEqual([301]);
|
||||
});
|
||||
|
||||
// Value: protects=a parenthesised named reference "(name #N, #M)", "(name PR #N)" or "(name issue #N)" is a cross-repo reference to that name, never a
|
||||
// same-repo Refs line or a promotion candidate, while "(see #12)", "(closing #12)", "(cf. #12)", "(v1.2 #3)", "(#301)" and "Fixes #12" keep their
|
||||
// meaning; fails_when=the bare-#N rule reads the numbers as this repository's issues (Refs lines and fix-#N prompts for issues that do not exist here), or
|
||||
// a closure, reference, filler or version word in the parentheses is taken for a repository name (the issue then vanishes from Refs and from the fix-#N
|
||||
// offer as a phantom cross-repo reference); why_new=the intent tests cover owner/repo#N as the only cross form; seam=none; no_claim=none beyond protects
|
||||
test('a parenthesised named reference is a cross-repo reference, never a same-repo Refs or promotion candidate', () => {
|
||||
const named = mentionsIn('ship-queue: screen verdict from the run (gbrain #7625, #7606)\n\nPorts the fix from (gbrain PR #7630) and (acme/tools issue #4).\n');
|
||||
expect(named.map(x => [x.repo ?? '', x.issue, x.intent])).toEqual([['gbrain', 7625, 'refs'], ['gbrain', 7606, 'refs'], ['gbrain', 7630, 'refs'], ['acme/tools', 4, 'refs']]);
|
||||
const links = resolveLinks(named, [], () => true);
|
||||
expect(links).toEqual({ fixes: [], refs: [], flakes: [], cross: ['acme/tools#4', 'gbrain#7606', 'gbrain#7625', 'gbrain#7630'], unreadable: [] });
|
||||
expect(renderIssueLines(links)).toEqual(['Refs acme/tools#4', 'Refs gbrain#7606', 'Refs gbrain#7625', 'Refs gbrain#7630']);
|
||||
expect(promotionCandidates(named, links)).toEqual([]);
|
||||
for (const word of ['see', 'fixes', 'refs', 'related', 'part of', 'follow-up', 'closes', 'PR', 'issue', 'closing', 'addresses', 'fixing', 'implements', 'cf.', 'e.g.', 'i.e.', 'bug', 'upstream PR', 'v1.2', 'v1.2.3-rc1', 'supersedes', 'duplicate of']) {
|
||||
expect([word, mentionsIn(`retry (${word} #12)`).map(x => [x.repo ?? '', x.issue, x.intent])]).toEqual([word, [['', 12, 'refs']]]);
|
||||
}
|
||||
expect(mentionsIn('v1.2.3 (#301) release notes').map(x => [x.repo ?? '', x.issue])).toEqual([['', 301]]);
|
||||
expect(mentionsIn('Fixes #12').map(x => [x.repo ?? '', x.issue, x.intent])).toEqual([['', 12, 'fixes']]);
|
||||
expect(mentionsIn('same line (gbrain #7625) and #13 here').map(x => [x.repo ?? '', x.issue])).toEqual([['gbrain', 7625], ['', 13]]);
|
||||
});
|
||||
|
||||
test('resolution: explicit --issue is closure, unreadable closure becomes Refs, a flake issue is never a plain Ref', () => {
|
||||
const links = resolveLinks(mentionsIn('Fixes #14\nFixes #15\nsee #40 and #16'), [16], n => n !== 15, [{ issue: 40, run: '7' }]);
|
||||
expect(links).toEqual({ fixes: [14, 16], refs: [15], flakes: [{ issue: 40, run: '7' }], cross: [], unreadable: [15] });
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Operational gaps seen on live trains, through the real
|
||||
* bin/gstack-ship-queue against the train fixture: the screen verdict is read
|
||||
* from the run (ready refuses a red head, verify --screen prints it, train
|
||||
* build refuses the pin), a lagging GitHub merge ref gets a scratch merge
|
||||
* (#3465), `train prune --stale` settles trains whose PRs merged elsewhere,
|
||||
* and `train watch` resumes from the record after an interruption.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { EXIT } from '../lib/headless-artifacts';
|
||||
import { cleanup, runBin } from './helpers/restamp-fixture';
|
||||
import {
|
||||
GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainJson, trainFixture, type Rule, type TrainFixture,
|
||||
} from './helpers/ship-queue-train-fixture';
|
||||
|
||||
const fixtures: TrainFixture[] = [];
|
||||
const make = (policy: Record<string, unknown> = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; };
|
||||
afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); });
|
||||
|
||||
const next = (out: string) => line(out, 'NEXT:');
|
||||
const result = (out: string) => line(out, 'GSTACK_RESULT:');
|
||||
|
||||
function readyFixture() {
|
||||
const fx = make();
|
||||
const head = openPr(fx, '1');
|
||||
fx.write(d => { d.ci[head] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const tree = runBin(fx.repo, 'gstack-wtree', []).out.trim();
|
||||
const pregate = path.join(fx.repo.root, 'pregate.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
return { fx, head, pregate };
|
||||
}
|
||||
const receiptOf = (fx: TrainFixture, pr: string, head: string) => JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `${pr}-${head}.json`), 'utf8'));
|
||||
|
||||
const RED_SCREEN: Rule = { status: 'completed', conclusion: 'failure', plan: { planned: 3 }, binding: { verdict: 'RED', flaky: ['c-flaky'] }, outcomes: { red: ['judge-voice-tone'], flakyLines: ['c-flaky rule FLAKY: failed in slice 2, passed on its one rerun'] } };
|
||||
const GREEN_SCREEN: Rule = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, binding: { verdict: 'GREEN', flaky: ['c-flaky'] }, outcomes: { flakyLines: ['c-flaky rule FLAKY: failed in slice 2, passed on its one rerun'] } };
|
||||
|
||||
describe('the screen verdict comes from the run', () => {
|
||||
// Value: protects=a RED screen run (runs 40269992077 and 40275443393 on 2026-10-26 were VERDICT RED while the lane reported green) can never be READY'd, followed or seated;
|
||||
// fails_when=ready or train build trust the lane's choice without reading the run's gate binding; why_new=no test read a screen's verdict; seam=the gh shim's gate-binding and report-verdict artifacts; no_claim=evals.yml's real upload step
|
||||
test('a RED screen refuses ready under either choice and is never re-dispatched; verify --screen prints the red and flaky cases; train build refuses the pin', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = RED_SCREEN; });
|
||||
const first = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([first.status, line(first.out, 'SCREEN_DISPATCH:')]).toEqual([EXIT.ok, expect.stringMatching(/^SCREEN_DISPATCH: evals\.yml run 100 /)]);
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(again.status).toBe(EXIT.refused);
|
||||
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 RED red=judge-voice-tone flaky=c-flaky (verdict RED, reconcile exit 1, run concluded failure, flaky 1)');
|
||||
expect(line(again.out, 'FLAKY:')).toBe('FLAKY: c-flaky rule FLAKY: failed in slice 2, passed on its one rerun');
|
||||
expect(again.err).toMatch(/the screen recorded for [0-9a-f]{12} \(run 100\) is RED on judge-voice-tone; this head is not READY under any screen choice; fix: .*\(QUEUE_SCREEN_RED\)/);
|
||||
expect(next(again.out)).toBe('NEXT: repair, push a new head, run /ship, then gstack-ship-queue ready --pr 1 --screen');
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'RED', run: 100, red: ['judge-voice-tone'], flaky: ['c-flaky'] });
|
||||
const noScreen = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect([noScreen.status, noScreen.err]).toEqual([EXIT.refused, expect.stringContaining('(QUEUE_SCREEN_RED)')]);
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect(verify.status).toBe(EXIT.fail);
|
||||
expect(line(verify.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 RED red=judge-voice-tone flaky=c-flaky (verdict RED, reconcile exit 1, run concluded failure, flaky 1)');
|
||||
expect(verify.out.split('\n')[1]).toMatch(/^screen: screen policy=narrow nonce=pr\.screen\.narrow-\S+ run=100 verdict=RED$/);
|
||||
expect(result(verify.out)).toMatch(/status=refused run=.*1-/);
|
||||
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect(build.status).toBe(EXIT.refused);
|
||||
expect(build.err).toMatch(/#1's screen run 100 is RED on judge-voice-tone .*a red head is not seated.*\(QUEUE_SCREEN_RED\)/);
|
||||
expect(next(build.out)).toBe('NEXT: none');
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains'))).toBe(false);
|
||||
}, 120_000);
|
||||
|
||||
test('a GREEN screen with flaky cases is followed, recorded and printed by verify --screen and train build; a run that bound no verdict is re-dispatched', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(again.status).toBe(EXIT.ok);
|
||||
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 GREEN flaky=c-flaky (verdict GREEN, reconcile exit 0, run concluded success, flaky 1)');
|
||||
expect(line(again.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 100 (same merge SHA, base and policy; no duplicate)');
|
||||
expect(again.out.split('\n')[1]).toMatch(/^screen: screen policy=narrow nonce=\S+ run=100 verdict=GREEN$/);
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'GREEN', run: 100, red: [], flaky: ['c-flaky'] });
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect([verify.status, verify.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(verify.out, 'FLAKY:')).toBe('FLAKY: c-flaky rule FLAKY: failed in slice 2, passed on its one rerun');
|
||||
expect(next(verify.out)).toBe(`NEXT: gstack-ship-queue train build --prs 1@${head},...`);
|
||||
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([build.status, line(build.out, 'SCREEN:'), line(build.out, 'ORDER:')]).toEqual([EXIT.ok, 'SCREEN: #1 run 100 GREEN (flaky: c-flaky)', 'ORDER: 1:#1(screened)']);
|
||||
fx.write(d => { delete d.artifacts['100']['gate-binding-a1']; });
|
||||
const unbound = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect(unbound.status).toBe(EXIT.refused);
|
||||
expect(unbound.err).toMatch(/has no verdict to read: run 100 concluded success and bound no verdict \(no gate-binding artifact.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(next(unbound.out)).toBe('NEXT: gstack-ship-queue ready --pr 1 --screen (dispatches a fresh screen)');
|
||||
const redispatch = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(redispatch.status).toBe(EXIT.ok);
|
||||
expect(line(redispatch.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 101 .*\(not following run 100 concluded success and bound no verdict .*: a screen that bound no verdict is not a screen\)$/);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, reuse: { run: 77, flaky: ['c-reused'] } }; });
|
||||
const reusedDispatch = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--broad', '--screen']);
|
||||
expect(reusedDispatch.status).toBe(EXIT.ok);
|
||||
const reused = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect([reused.status, line(reused.out, 'SCREEN_VERDICT:')]).toEqual([EXIT.ok, 'SCREEN_VERDICT: #1 run 102 GREEN flaky=c-reused (reused the GREEN verdict of run 77 (main-moved): same own diff on a moved main, flaky 1)']);
|
||||
const noRun = queue(fx, ['verify', '--pr', '2', '--screen']);
|
||||
expect(noRun.err).toMatch(/cannot read PR #2's head through gh.*\(QUEUE_PROOF_MISSING\)/);
|
||||
const unscreened = openPr(fx, '3');
|
||||
readyReceipt(fx, '3', unscreened);
|
||||
const none = queue(fx, ['verify', '--pr', '3', '--screen']);
|
||||
expect([none.status, line(none.out, 'SCREEN_VERDICT:')]).toEqual([EXIT.ok, 'SCREEN_VERDICT: #3 none (READY --no-screen)']);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a screen that concluded success with no slice (18 of 41 PR screens on 2026-10-26, superseded by a push before the plan) is NOT-RUN at verdict, not a screen and not a stale ref;
|
||||
// fails_when=verify --screen calls it GREEN, HOLLOW or QUEUE_SCREEN_STALE_REF, or ready follows it; why_new=the hollow outcome reused the stale-ref code; seam=the gh shim's run with no plan artifact; no_claim=why evals.yml planned nothing
|
||||
// Value: protects=train build --dry-run (with or without --stack-tests) writes nothing: the screen verdict it reads is printed, not recorded in the READY receipt or pushed to refs/notes/gstack-trains, as its DRY-RUN line says;
|
||||
// fails_when=admitSlots persists the read verdict under --dry-run (the notes ref moves and the receipt gains a verdict before any real build); why_new=the other dry-run tests only check the trains dir; seam=none; no_claim=a verdict that was already recorded by ready or verify
|
||||
test('train build --dry-run prints the screen verdict it read without recording it in the receipt or pushing the notes ref', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toBeUndefined();
|
||||
const notes = fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains');
|
||||
const build = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([build.status, line(build.out, 'SCREEN:'), line(build.out, 'DRY-RUN:')]).toEqual([EXIT.ok, 'SCREEN: #1 run 100 GREEN (flaky: c-flaky)', 'DRY-RUN: nothing written, pushed or dispatched']);
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toBeUndefined();
|
||||
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).toBe(notes);
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect([verify.status, receiptOf(fx, '1', head).screen.verdict?.outcome]).toEqual([EXIT.ok, 'GREEN']);
|
||||
expect(fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains')).not.toBe(notes);
|
||||
}, 120_000);
|
||||
|
||||
test('a screen run that concluded success with no slice is NOT-RUN: verify --screen refuses with QUEUE_SCREEN_NOT_RUN, the receipt records NOT_RUN, and ready --screen dispatches afresh', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; });
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']).status).toBe(EXIT.ok);
|
||||
const verify = queue(fx, ['verify', '--pr', '1', '--screen']);
|
||||
expect(verify.status).toBe(EXIT.refused);
|
||||
expect(line(verify.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 NOT-RUN (run 100 planned no cases (no paid-plan artifact; concluded success))');
|
||||
expect(verify.err).toMatch(/the screen of #1 \(run 100\) concluded success with no slice: run 100 planned no cases .*; it is not a screen, and this head is not screened; fix: .*\(QUEUE_SCREEN_NOT_RUN\)/);
|
||||
expect(next(verify.out)).toBe('NEXT: gstack-ship-queue ready --pr 1 --screen (dispatches a fresh screen)');
|
||||
expect(receiptOf(fx, '1', head).screen.verdict).toMatchObject({ outcome: 'NOT_RUN', run: 100 });
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([again.status, dispatches(fx, 'evals.yml').length]).toEqual([EXIT.ok, 2]);
|
||||
expect(line(again.out, 'SCREEN_VERDICT:')).toBe('SCREEN_VERDICT: #1 run 100 NOT-RUN (run 100 planned no cases (no paid-plan artifact; concluded success))');
|
||||
expect(line(again.out, 'SCREEN_DISPATCH:')).toMatch(/not following run 100 planned no cases .*: a screen that planned nothing is not a screen\)$/);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('#3465: the screen is planned on a scratch merge when GitHub\'s merge ref lags', () => {
|
||||
// Value: protects=a CHANGELOG-only conflict (every main stamp leaves one) no longer blocks --screen: the screen tests the head merged onto current main by the train's rules;
|
||||
// fails_when=ready still refuses on the lagging ref, pushes nothing, or the scratch merge's parents are not (base, head); why_new=the stale-ref test only asserted the refusal; seam=none; no_claim=evals.yml's checkout of the pushed branch
|
||||
test('a lagging or missing merge ref gets a scratch merge pushed as screen/<pr>-<head12>; a real conflict is QUEUE_SCREEN_STALE_REF; prune --stale removes the branch once the PR merged', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const { repo } = fx;
|
||||
fx.write(d => { d.rules['evals.yml'] = GREEN_SCREEN; });
|
||||
repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main');
|
||||
const cl = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8');
|
||||
fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), cl.replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- another PR, stamped\n'));
|
||||
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n');
|
||||
repo.git('commit', '-qam', 'v1.2.4.0 stamp');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
repo.git('checkout', '-q', 'pr-1');
|
||||
const dry = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen', '--dry-run']);
|
||||
expect([dry.status, dry.err]).toEqual([EXIT.ok, '']);
|
||||
const main = repo.git('rev-parse', 'origin/main');
|
||||
expect(line(dry.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${head.slice(0, 12)} onto main@${main.slice(0, 12)} \\(refs/pull/1/merge [0-9a-f]{12} merges onto [0-9a-f]{12}, not main@${main.slice(0, 12)}\\); would push screen/1-${head.slice(0, 12)} \\(dry run\\)$`));
|
||||
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
|
||||
const real = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([real.status, real.err]).toEqual([EXIT.ok, '']);
|
||||
const branch = `screen/1-${head.slice(0, 12)}`;
|
||||
expect(line(real.out, 'SCREEN_MERGE:')).toContain(`; pushed ${branch}`);
|
||||
const scratch = repo.git('ls-remote', 'origin', `refs/heads/${branch}`).split(/\s/)[0]!;
|
||||
expect(scratch).toMatch(/^[0-9a-f]{40}$/);
|
||||
repo.git('fetch', '-q', 'origin', `refs/heads/${branch}`);
|
||||
expect([repo.git('rev-parse', `${scratch}^1`), repo.git('rev-parse', `${scratch}^2`)]).toEqual([repo.git('rev-parse', 'origin/main'), head]);
|
||||
expect(repo.git('show', `${scratch}:VERSION`)).toBe('1.2.4.0');
|
||||
expect(repo.git('show', `${scratch}:CHANGELOG.md`)).toContain('- #1 change');
|
||||
expect(repo.git('show', `${scratch}:CHANGELOG.md`)).toContain('- another PR, stamped');
|
||||
const d = dispatches(fx, 'evals.yml');
|
||||
expect(d.length).toBe(1);
|
||||
expect((d[0]!.inputs as any)).toMatchObject({ screen_merge_sha: scratch, expected_sha: scratch, screen_pr: '1' });
|
||||
expect(receiptOf(fx, '1', head).screen).toMatchObject({ choice: 'screen', merge_sha: scratch, branch });
|
||||
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect([missing.status, line(missing.out, 'SCREEN_DISPATCH:')]).toEqual([EXIT.ok, 'SCREEN_DISPATCH: following run 100 (same merge SHA, base and policy; no duplicate)']);
|
||||
expect(line(missing.out, 'SCREEN_MERGE:')).toContain('(origin has no refs/pull/1/merge)');
|
||||
const kept = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(kept.out, 'KEEP:')).toBe(`KEEP: ${branch} (#1 OPEN)`);
|
||||
fx.write(data => { (data.prs['1'] as any).state = 'MERGED'; });
|
||||
const pruned = queue(fx, ['train', 'prune', '--stale']);
|
||||
expect(line(pruned.out, 'DELETED:')).toBe(`DELETED: ${branch} (#1 MERGED)`);
|
||||
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
|
||||
fx.write(data => { (data.prs['1'] as any).state = 'OPEN'; });
|
||||
repo.git('checkout', '-q', '-B', 'mainsim2', 'origin/main');
|
||||
fs.writeFileSync(path.join(repo.work, 'pr-1.txt'), 'main added the same file\n');
|
||||
repo.git('add', 'pr-1.txt');
|
||||
repo.git('commit', '-qm', 'main adds pr-1.txt');
|
||||
repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main');
|
||||
repo.git('checkout', '-q', 'pr-1');
|
||||
const conflict = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(conflict.status).toBe(EXIT.refused);
|
||||
expect(conflict.err).toMatch(/#1 conflicts with main@[0-9a-f]{12} beyond the CHANGELOG and the stamp files \(pr-1\.txt\); GitHub's merge ref lags too \(origin has no refs\/pull\/1\/merge\).*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(next(conflict.out)).toBe('NEXT: resolve the conflict with main, push, run /ship, then gstack-ship-queue ready --pr 1 --screen');
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('train prune --stale and train watch', () => {
|
||||
function greenTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) {
|
||||
const h1 = openPr(fx, '1');
|
||||
const h2 = openPr(fx, '2');
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
fx.write(d => { d.rules['evals.yml'] = gate; });
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`, '--free-timeout', '1']);
|
||||
return { h1, h2, r, nonce: nonceOf(r.out) };
|
||||
}
|
||||
|
||||
// Value: protects=a merging or green train whose PRs all merged elsewhere (gate.train-23af94b3c5e9 and -6755ab829acf on 2026-10-26) can be pruned without hand-editing refs/notes/gstack-trains;
|
||||
// fails_when=prune skips merging/green trains, prunes one with an OPEN PR, or prunes without --stale; why_new=prune only knew terminal states; seam=the gh shim's PR states; no_claim=GitHub's real PR state
|
||||
test('--stale settles a merging or green train whose every PR is MERGED or CLOSED on GitHub, logging each PR; an OPEN PR keeps it; without --stale nothing changes', () => {
|
||||
const fx = make();
|
||||
const { nonce } = greenTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
expect(trainJson(fx, nonce).state).toBe('green');
|
||||
const plain = queue(fx, ['train', 'prune']);
|
||||
expect(line(plain.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green)`);
|
||||
const open = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(open.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green; #1 OPEN, #2 OPEN still need the train)`);
|
||||
fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; });
|
||||
const half = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(half.out, 'KEEP:')).toBe(`KEEP: train/${nonce} (green; #2 OPEN still needs the train)`);
|
||||
fx.write(d => { (d.prs['2'] as any).state = 'CLOSED'; });
|
||||
const dry = queue(fx, ['train', 'prune', '--stale', '--dry-run']);
|
||||
expect(line(dry.out, 'STALE:')).toBe(`STALE: ${nonce} (green): #1 MERGED, #2 CLOSED; would mark stale`);
|
||||
expect(line(dry.out, 'WOULD-DELETE:')).toBe(`WOULD-DELETE: train/${nonce} (stale)`);
|
||||
expect(trainJson(fx, nonce).state).toBe('green');
|
||||
const pruned = queue(fx, ['train', 'prune', '--stale']);
|
||||
expect([pruned.status, line(pruned.out, 'STALE:'), line(pruned.out, 'DELETED:')]).toEqual([EXIT.ok, `STALE: ${nonce} (green): #1 MERGED, #2 CLOSED`, `DELETED: train/${nonce} (stale)`]);
|
||||
const t = trainJson(fx, nonce);
|
||||
expect(t.state).toBe('stale');
|
||||
expect(t.effects.map((e: any) => e.what)).toContain('marked stale by train prune --stale: #1 MERGED, #2 CLOSED');
|
||||
expect(fx.repo.git('ls-remote', 'origin', `refs/heads/train/${nonce}`)).toBe('');
|
||||
const status = queue(fx, ['train', 'status']);
|
||||
expect(status.out).toContain('TRAIN: none live');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a stale train record or READY receipt leaves refs/notes/gstack-trains through one command under an exact lease, with the local files, instead of a hand-run git notes remove on every machine;
|
||||
// fails_when=forget removes the wrong notes, deletes locally when the push failed, or forgets a merging train; why_new=no CLI removed notes (merge-train skill, known gaps); seam=none; no_claim=a concurrent writer's race (the lease is exercised only as a fast-forward)
|
||||
test('train forget removes a settled train (attempts included) or a READY receipt from the notes ref and the state root, leaves other records alone, keeps everything (the local notes ref too) when the lease is rejected, and refuses a train still building, running or merging', () => {
|
||||
const fx = make();
|
||||
const { nonce, h1 } = greenTrain(fx);
|
||||
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
|
||||
const trainDir = path.join(fx.state, 'ship-queue', 'trains', nonce);
|
||||
expect(fs.existsSync(path.join(trainDir, 'attempts', 'a1.json'))).toBe(true);
|
||||
const dry = queue(fx, ['train', 'forget', '--train', nonce, '--dry-run']);
|
||||
expect([dry.status, line(dry.out, 'FORGET:')]).toEqual([EXIT.ok, `FORGET: train ${nonce} (green; 1 attempt(s))`]);
|
||||
expect(dry.out).toContain(` note gstack-trains:train:${nonce}`);
|
||||
expect(dry.out).toContain(` note gstack-trains:attempt:${nonce}:a1`);
|
||||
expect(fs.existsSync(trainDir)).toBe(true);
|
||||
const notesBefore = fx.repo.git('ls-remote', 'origin', 'refs/notes/gstack-trains').split(/\s/)[0]!;
|
||||
const forgot = queue(fx, ['train', 'forget', '--train', nonce]);
|
||||
expect([forgot.status, forgot.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(forgot.out, 'FORGOTTEN:')).toMatch(new RegExp(`^FORGOTTEN: 2 note\\(s\\) removed; notes pushed to origin refs/notes/gstack-trains \\(lease ${notesBefore.slice(0, 12)}\\); 1 local path\\(s\\) deleted$`));
|
||||
expect(fs.existsSync(trainDir)).toBe(false);
|
||||
const clean = path.join(fx.repo.root, 'clean-state');
|
||||
const status = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: clean });
|
||||
expect(status.out).toContain('TRAIN: none live');
|
||||
expect(fs.existsSync(path.join(clean, 'ship-queue', 'trains', nonce))).toBe(false);
|
||||
expect(fs.existsSync(path.join(clean, 'ship-queue', 'ready', repoKey(fx), `1-${h1}.json`))).toBe(false);
|
||||
const again = queue(fx, ['train', 'forget', '--train', nonce]);
|
||||
expect(again.err).toMatch(new RegExp(`no train record ${nonce} here or in refs/notes/gstack-trains.*\\(QUEUE_PROOF_MISSING\\)`));
|
||||
const h3 = openPr(fx, '3');
|
||||
fx.write(d => { d.ci[h3] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const pregate = path.join(fx.repo.root, 'pregate3.json');
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h3, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
expect(queue(fx, ['ready', '--pr', '3', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
|
||||
const h4 = openPr(fx, '4');
|
||||
fx.write(d => { d.ci[h4] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree: runBin(fx.repo, 'gstack-wtree', []).out.trim(), head: h4, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
expect(queue(fx, ['ready', '--pr', '4', '--pregate', pregate, '--no-screen']).status).toBe(EXIT.ok);
|
||||
const receipt = queue(fx, ['train', 'forget', '--receipt', `3@${h3}`]);
|
||||
expect([receipt.status, line(receipt.out, 'FORGET:')]).toEqual([EXIT.ok, `FORGET: READY receipt #3 at ${h3.slice(0, 12)} (screen no-screen)`]);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready', repoKey(fx), `3-${h3}.json`))).toBe(false);
|
||||
const rebuilt = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: path.join(fx.repo.root, 'clean-state-2') });
|
||||
expect(rebuilt.status).toBe(EXIT.ok);
|
||||
expect(fs.existsSync(path.join(fx.repo.root, 'clean-state-2', 'ship-queue', 'ready', repoKey(fx), `4-${h4}.json`))).toBe(true);
|
||||
expect(fs.existsSync(path.join(fx.repo.root, 'clean-state-2', 'ship-queue', 'ready', repoKey(fx), `3-${h3}.json`))).toBe(false);
|
||||
expect(queue(fx, ['train', 'forget', '--receipt', '3@abc']).err).toMatch(/--receipt takes <pr>@<full head sha>.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(queue(fx, ['train', 'forget']).status).toBe(EXIT.usage);
|
||||
const { nonce: second } = (() => { readyReceipt(fx, '3', h3); readyReceipt(fx, '4', h4); const r = queue(fx, ['train', 'build', '--prs', `3@${h3},4@${h4}`, '--free-timeout', '1']); return { nonce: nonceOf(r.out) }; })();
|
||||
expect(queue(fx, ['train', 'verdict', '--train', second]).status).toBe(EXIT.ok);
|
||||
const hook = path.join(fx.repo.origin, 'hooks', 'pre-receive');
|
||||
fs.mkdirSync(path.dirname(hook), { recursive: true });
|
||||
fs.writeFileSync(hook, '#!/bin/sh\nwhile read o n r; do case "$r" in refs/notes/*) echo "notes frozen" >&2; exit 1;; esac; done\nexit 0\n', { mode: 0o755 });
|
||||
const localNotes = fx.repo.git('rev-parse', 'refs/notes/gstack-trains');
|
||||
const rejected = queue(fx, ['train', 'forget', '--train', second]);
|
||||
expect(rejected.status).toBe(EXIT.refused);
|
||||
expect(rejected.err).toMatch(/the notes ref was not updated \(git push refs\/notes\/gstack-trains rejected: .*notes frozen.*; the local notes ref was restored\); nothing deleted locally.*\(QUEUE_PROOF_MISSING\)/);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'trains', second, 'train.json'))).toBe(true);
|
||||
expect(fx.repo.git('rev-parse', 'refs/notes/gstack-trains')).toBe(localNotes);
|
||||
expect(fx.repo.git('notes', '--ref=refs/notes/gstack-trains', 'list').split('\n').length).toBeGreaterThan(0);
|
||||
fs.rmSync(hook);
|
||||
const trainFile = path.join(fx.state, 'ship-queue', 'trains', second, 'train.json');
|
||||
for (const state of ['dispatch-unknown', 'merging']) {
|
||||
const t = JSON.parse(fs.readFileSync(trainFile, 'utf8'));
|
||||
t.state = state;
|
||||
fs.writeFileSync(trainFile, JSON.stringify(t));
|
||||
const live = queue(fx, ['train', 'forget', '--train', second]);
|
||||
expect(live.status).toBe(EXIT.refused);
|
||||
expect(live.err).toMatch(new RegExp(`is ${state}; forget settles nothing that is still building, running or merging .*\\(QUEUE_PROOF_MISSING\\)`));
|
||||
expect(fs.existsSync(trainFile)).toBe(true);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a restarted controller re-arms with one command: watch continues from the record (free tests, gate dispatch, the pending run, the verdict) and prints NEXT;
|
||||
// fails_when=watch dispatches twice, blocks past --timeout, silently replaces a malformed --free-timeout with the default, or does not reach the verdict after a restart; why_new=no resumable follow existed (hand-run gh run watch chains died with the machine); seam=GSTACK_SHIP_QUEUE_POLL_MS; no_claim=real GitHub run timing
|
||||
test('watch resumes from free-pending through the gate dispatch to the verdict, times out with its own NEXT, and never dispatches twice', () => {
|
||||
const fx = make();
|
||||
fx.write(d => { d.rules['free-tests.yml'] = { status: 'in_progress', conclusion: null }; });
|
||||
const { nonce, r } = greenTrain(fx, { ...GREEN_GATE, status: 'in_progress', conclusion: null });
|
||||
expect(r.status).toBe(EXIT.refused);
|
||||
expect(trainJson(fx, nonce).state).toBe('free-pending');
|
||||
const waiting = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
|
||||
expect([waiting.status, result(waiting.out)]).toEqual([EXIT.refused, expect.stringContaining('status=incomplete')]);
|
||||
expect(waiting.out).toContain(`WATCH: train ${nonce} state=free-pending (resumable; rerun this command after an interruption)`);
|
||||
expect(next(waiting.out)).toBe(`NEXT: gstack-ship-queue train watch --train ${nonce} --timeout 1`);
|
||||
expect(dispatches(fx, 'evals.yml')).toEqual([]);
|
||||
fx.write(d => { for (const run of d.runs) if (run.workflow === 'free-tests.yml') Object.assign(run, { status: 'completed', conclusion: 'success' }); });
|
||||
const gate = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
|
||||
expect(gate.status).toBe(EXIT.refused);
|
||||
expect(gate.out).toContain(`WATCH: train build --train ${nonce}: free tests green, gate dispatched`);
|
||||
expect(gate.err).toMatch(/is still in_progress at the watch timeout.*\(QUEUE_TRAIN_PENDING\)/);
|
||||
expect(next(gate.out)).toBe(`NEXT: gstack-ship-queue train watch --train ${nonce} --timeout 1`);
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(1);
|
||||
expect(trainJson(fx, nonce).state).toBe('gate-pending');
|
||||
const again = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '1']);
|
||||
expect([again.status, dispatches(fx, 'evals.yml').length]).toEqual([EXIT.refused, 1]);
|
||||
fx.write(d => { for (const run of d.runs) if (run.workflow === 'evals.yml') Object.assign(run, { status: 'completed', conclusion: 'success' }); });
|
||||
const done = queue(fx, ['train', 'watch', '--train', nonce, '--timeout', '5']);
|
||||
expect([done.status, done.err]).toEqual([EXIT.ok, '']);
|
||||
expect(done.out).toContain('WATCH: gate run completed; reading the verdict');
|
||||
expect(line(done.out, 'VERDICT:')).toMatch(new RegExp(`^VERDICT: GREEN train ${nonce} run 10[0-9] `));
|
||||
expect(next(done.out)).toBe(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce}`);
|
||||
const idle = queue(fx, ['train', 'watch', '--train', nonce]);
|
||||
expect([idle.status, line(idle.out, 'WATCH: nothing')]).toEqual([EXIT.ok, 'WATCH: nothing to wait for in state green']);
|
||||
expect(next(idle.out)).toBe(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce}`);
|
||||
expect(queue(fx, ['train', 'watch']).status).toBe(EXIT.usage);
|
||||
expect(queue(fx, ['train', 'watch', '--train', nonce, '--free-timeout', 'abc']).status).toBe(EXIT.usage);
|
||||
expect(queue(fx, ['train', 'watch', '--train', 'no-such-train']).err).toMatch(/no train record no-such-train.*\(QUEUE_PROOF_MISSING\)/);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('verify --post-merge', () => {
|
||||
// Value: protects=after a train merges, the coordinator watches main's push workflows at the merged sha until they settle instead of a background gh chain a restart kills (GBrain practice 8);
|
||||
// fails_when=a pending check is called settled, a red check is called green, a pending or failed commit status (the legacy status API, which ciAtHead counts) is reported as red with no name or as green, or the command needs process state; why_new=no post-merge watch existed; seam=the gh shim's check runs and commit statuses at a sha; no_claim=which workflows main runs
|
||||
test('waits for every check run at the sha to complete, prints each, is green only when none failed, red with the failed names, pending or red on a commit status, and resumable after a timeout', () => {
|
||||
const fx = make();
|
||||
const sha = fx.repo.git('rev-parse', 'HEAD');
|
||||
fx.write(d => { d.ci[sha] = [{ name: 'free-tests', status: 'completed', conclusion: 'success' }, { name: 'windows-free-tests', status: 'in_progress', conclusion: null }, { name: 'main-backstop', status: 'completed', conclusion: 'neutral' }]; });
|
||||
const pending = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect(pending.status).toBe(EXIT.refused);
|
||||
expect(line(pending.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} pending (passed=1 failed=0 pending=1 skipped=1)`);
|
||||
expect(pending.out).toContain('CHECK: windows-free-tests in_progress');
|
||||
expect(pending.err).toMatch(new RegExp(`main's push workflows at ${sha.slice(0, 12)} have not settled .*windows-free-tests.*\\(QUEUE_TRAIN_PENDING\\)`));
|
||||
expect(next(pending.out)).toBe(`NEXT: gstack-ship-queue verify --post-merge ${sha} --timeout 1`);
|
||||
fx.write(d => { d.ci[sha]![1] = { name: 'windows-free-tests', status: 'completed', conclusion: 'failure' }; });
|
||||
const red = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect(red.status).toBe(EXIT.fail);
|
||||
expect(line(red.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} red (passed=1 failed=1 pending=0 skipped=1)`);
|
||||
expect(red.out).toContain('CHECK: windows-free-tests failure');
|
||||
expect(red.err).toMatch(new RegExp(`main's push workflows at ${sha.slice(0, 12)} are red: windows-free-tests.*\\(QUEUE_POST_MERGE_RED\\)`));
|
||||
expect(next(red.out)).toBe('NEXT: read the red run, repair on a new PR or roll back; gstack-ship-queue train status names the freeze if the backstop filed one');
|
||||
fx.write(d => { d.ci[sha]![1] = { name: 'windows-free-tests', status: 'completed', conclusion: 'success' }; });
|
||||
const green = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([green.status, green.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(green.out, 'POST_MERGE:')).toBe(`POST_MERGE: ${sha.slice(0, 12)} green (passed=2 failed=0 pending=0 skipped=1)`);
|
||||
expect(green.out.split('\n').filter(l => l.startsWith('CHECK: ')).sort()).toEqual(['CHECK: free-tests success', 'CHECK: main-backstop neutral', 'CHECK: windows-free-tests success']);
|
||||
expect(next(green.out)).toBe('NEXT: none');
|
||||
fx.write(d => { d.statuses = { [sha]: [{ state: 'pending', context: 'ci/vendor' }] }; });
|
||||
const statusPending = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([statusPending.status, line(statusPending.out, 'POST_MERGE:')]).toEqual([EXIT.refused, `POST_MERGE: ${sha.slice(0, 12)} pending (passed=2 failed=0 pending=1 skipped=1)`]);
|
||||
expect(statusPending.err).toMatch(/have not settled at the timeout: a commit status still running.*\(QUEUE_TRAIN_PENDING\)/);
|
||||
fx.write(d => { d.statuses = { [sha]: [{ state: 'failure', context: 'ci/vendor' }] }; });
|
||||
const statusRed = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([statusRed.status, line(statusRed.out, 'POST_MERGE:')]).toEqual([EXIT.fail, `POST_MERGE: ${sha.slice(0, 12)} red (passed=2 failed=1 pending=0 skipped=1)`]);
|
||||
expect(statusRed.err).toMatch(/are red: a commit status \(no check run failed\).*\(QUEUE_POST_MERGE_RED\)/);
|
||||
fx.write(d => { d.statuses = {}; d.ci[sha] = []; });
|
||||
const none = queue(fx, ['verify', '--post-merge', sha, '--timeout', '1']);
|
||||
expect([none.status, line(none.out, 'POST_MERGE:')]).toEqual([EXIT.refused, `POST_MERGE: ${sha.slice(0, 12)} none (passed=0 failed=0 pending=0 skipped=0)`]);
|
||||
expect(none.err).toMatch(new RegExp(`no check run has attached to ${sha.slice(0, 12)} yet.*\\(QUEUE_TRAIN_PENDING\\)`));
|
||||
expect(queue(fx, ['verify', '--post-merge', 'not-a-sha']).status).toBe(EXIT.usage);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('ready --dispatch-ci', () => {
|
||||
// Value: protects=a PR that conflicts with the base (a CHANGELOG-only conflict after every stamp) gets its free workflows dispatched on the branch, waited for, and its requires-remote pregate lanes cleared with the run URLs in one command, replacing the four-step hand procedure;
|
||||
// fails_when=the dispatch happens on a fork head, a red run clears a lane, a lane whose workflow was not dispatched is cleared, ready still says ci: none, or the CI_DISPATCH lines push the screen: line in front of READY (identifiers first); why_new=the ci-hint was advisory prose; seam=the gh shim's workflow run, run list and check runs; no_claim=GitHub's real dispatch latency
|
||||
test('dispatches the named workflows on a conflicting PR, waits for their runs, clears the matching pregate lanes with the run URLs and then READYs; a fork head and a red run are refused', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => {
|
||||
(d.prs['1'] as any).mergeable = 'CONFLICTING';
|
||||
d.ci[head] = [];
|
||||
d.rules['free-tests.yml'] = { ci: 'free-tests', headSha: head };
|
||||
d.rules['windows-free-tests.yml'] = { ci: 'windows-free-tests', headSha: head };
|
||||
});
|
||||
const tree = JSON.parse(fs.readFileSync(pregate, 'utf8')).tree;
|
||||
fs.writeFileSync(pregate, JSON.stringify({ schema_version: 1, tree, head, verdict: 'requires-remote', checks: [{ id: 'unit', status: 'pass' }], requires_remote: [
|
||||
{ lane: 'free-tests.yml/free-tests', platform: 'linux', workflow: 'free-tests.yml', job: 'free-tests', tests: ['test/a.test.ts'], receipt: null },
|
||||
{ lane: 'windows-free-tests.yml/windows-free-tests', platform: 'windows', workflow: 'windows-free-tests.yml', job: 'windows-free-tests', tests: ['test/a.test.ts'], receipt: null },
|
||||
{ lane: 'macos-free-tests.yml/macos', platform: 'macos', workflow: 'macos-free-tests.yml', job: 'macos', tests: ['test/b.test.ts'], receipt: null }] }));
|
||||
const plain = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen']);
|
||||
expect([plain.status, line(plain.out, 'ci:')]).toEqual([EXIT.refused, `ci: none at ${head.slice(0, 12)} (passed=0 failed=0 pending=0 skipped=0)`]);
|
||||
const r = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml,windows-free-tests.yml', '--timeout', '5']);
|
||||
expect(r.out.split('\n').filter(l => l.startsWith('CI_DISPATCH:'))).toEqual([
|
||||
'CI_DISPATCH: free-tests.yml --ref pr-1 run 100 https://example.invalid/runs/100 success',
|
||||
'CI_DISPATCH: windows-free-tests.yml --ref pr-1 run 101 https://example.invalid/runs/101 success']);
|
||||
expect(r.out.split('\n').filter(l => l.startsWith('PREGATE_CLEARED:'))).toEqual([
|
||||
'PREGATE_CLEARED: free-tests.yml/free-tests https://example.invalid/runs/100',
|
||||
'PREGATE_CLEARED: windows-free-tests.yml/windows-free-tests https://example.invalid/runs/101']);
|
||||
expect(line(r.out, 'ci:')).toBe(`ci: green at ${head.slice(0, 12)} (passed=2 failed=0 pending=0 skipped=0)`);
|
||||
expect(line(r.out, 'PREGATE:')).toMatch(/^PREGATE: requires-remote .*/);
|
||||
expect(r.err).toMatch(/1 requires-remote obligation\(s\) without a receipt: macos-free-tests\.yml\/macos.*\(QUEUE_NOT_READY\)/);
|
||||
const saved = JSON.parse(fs.readFileSync(pregate, 'utf8'));
|
||||
expect(saved.requires_remote.map((l: any) => l.receipt)).toEqual(['https://example.invalid/runs/100', 'https://example.invalid/runs/101', null]);
|
||||
expect(dispatches(fx).map(l => l.workflow)).toEqual(['free-tests.yml', 'windows-free-tests.yml']);
|
||||
const again = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
|
||||
expect(dispatches(fx).length).toBe(2);
|
||||
expect(line(again.out, 'CI_DISPATCH:')).toBe('CI_DISPATCH: free-tests.yml not dispatched (run 100 already concluded success at this head)');
|
||||
const cleared = JSON.parse(fs.readFileSync(pregate, 'utf8'));
|
||||
cleared.requires_remote[2].receipt = 'https://example.invalid/runs/7';
|
||||
fs.writeFileSync(pregate, JSON.stringify(cleared));
|
||||
const ok = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
|
||||
expect([ok.status, ok.err]).toEqual([EXIT.ok, '']);
|
||||
const okLines = ok.out.split('\n');
|
||||
expect(okLines[0]).toBe('CI_DISPATCH: free-tests.yml not dispatched (run 100 already concluded success at this head)');
|
||||
expect(okLines[okLines.findIndex(l => l.startsWith('READY #1 ')) + 1]).toBe('screen: no-screen');
|
||||
fx.write(d => { d.rules['free-tests.yml'] = { ci: 'free-tests', conclusion: 'failure', headSha: head }; d.ci[head] = []; d.runs.length = 0; });
|
||||
const red = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml', '--timeout', '5']);
|
||||
expect([red.status, line(red.out, 'CI_DISPATCH:')]).toEqual([EXIT.refused, 'CI_DISPATCH: free-tests.yml --ref pr-1 run 102 https://example.invalid/runs/102 failure']);
|
||||
expect(red.err).toMatch(new RegExp(`free-tests\\.yml run 102 concluded failure at ${head.slice(0, 12)}; a red run clears no lane.*\\(QUEUE_NOT_READY\\)`));
|
||||
expect(JSON.parse(fs.readFileSync(pregate, 'utf8')).requires_remote[0].receipt).toBe('https://example.invalid/runs/100');
|
||||
fx.write(d => { (d.prs['1'] as any).isCrossRepository = true; });
|
||||
const fork = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', 'free-tests.yml']);
|
||||
expect([fork.status, fork.err]).toEqual([EXIT.refused, expect.stringMatching(/#1's head is on a fork.*not dispatched.*\(QUEUE_TRAIN_UNTRUSTED\)/)]);
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--no-screen', '--dispatch-ci', '../evil.yml']).status).toBe(EXIT.usage);
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('train build --dry-run --stack-tests', () => {
|
||||
// Value: protects=a PR whose new tests pass on its own branch but fail once stacked behind another PR's change is caught before the train spends a gate (#3460 on #3451, 2026-10-26);
|
||||
// fails_when=the new test files run against the PR head instead of the integrated prefix, a failing prefix still dry-runs green, or a PR without new tests (a helper under test/ included: bun exits 1 when a positional filter matches no test file) is misreported; why_new=no pre-dispatch stack check existed; seam=bun test in a scratch worktree; no_claim=the repo's full suite on the prefix
|
||||
test('runs each PR\'s new test files on its integrated prefix T<k>: a pass and a helper-only PR are reported, a failure refuses the dry run and names the prefix, and the ordinary dry run skips it', () => {
|
||||
const fx = make();
|
||||
const h1 = openPr(fx, '1', { file: 'test/pr-1_test.ts', text: "import { expect, test } from 'bun:test';\ntest('pr-1', () => { expect(1).toBe(1); });\n" });
|
||||
const h2 = openPr(fx, '2', { file: 'test/pr-2.test.ts', text: "import { existsSync } from 'node:fs';\nimport { expect, test } from 'bun:test';\ntest('pr-2 wants no pr-1 test beside it', () => { expect(existsSync('test/pr-1_test.ts')).toBe(false); });\n" });
|
||||
const h3 = openPr(fx, '3', { file: 'test/helpers/pr-3-helper.ts', text: 'export const three = 3;\n' });
|
||||
readyReceipt(fx, '1', h1);
|
||||
readyReceipt(fx, '2', h2);
|
||||
readyReceipt(fx, '3', h3);
|
||||
const plain = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--dry-run']);
|
||||
expect([plain.status, plain.out.includes('STACK_TESTS:')]).toEqual([EXIT.ok, false]);
|
||||
const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--dry-run', '--stack-tests']);
|
||||
expect(r.status).toBe(EXIT.fail);
|
||||
expect(r.out.split('\n').filter(l => l.startsWith('STACK_TESTS:'))).toEqual([
|
||||
'STACK_TESTS: #1 T1 1 new test file(s) pass (test/pr-1_test.ts)',
|
||||
expect.stringMatching(/^STACK_TESTS: #2 T2 1 new test file\(s\) FAIL \(test\/pr-2\.test\.ts\): .*\(fail\) pr-2 wants no pr-1 test beside it.*1 fail$/),
|
||||
'STACK_TESTS: #3 T3 no new test files']);
|
||||
expect(r.err).toMatch(/#2's new tests fail on the integrated prefix T2 \(test\/pr-2\.test\.ts\); they pass on the PR head alone only if nothing before it in the train changes what they read.*\(QUEUE_TRAIN_STACK_RED\)/);
|
||||
expect(next(r.out)).toBe(`NEXT: fix #2's tests against T2, or reorder with --order, then gstack-ship-queue train build --prs 1@${h1},2@${h2},3@${h3} --dry-run --stack-tests`);
|
||||
expect(line(r.out, 'DRY-RUN:')).toBe('DRY-RUN: nothing written, pushed or dispatched');
|
||||
const reordered = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2},3@${h3}`, '--order', '2,1,3', '--dry-run', '--stack-tests']);
|
||||
expect(reordered.status).toBe(EXIT.ok);
|
||||
expect(reordered.out.split('\n').filter(l => l.startsWith('STACK_TESTS:'))).toEqual([
|
||||
'STACK_TESTS: #2 T1 1 new test file(s) pass (test/pr-2.test.ts)',
|
||||
'STACK_TESTS: #1 T2 1 new test file(s) pass (test/pr-1_test.ts)',
|
||||
'STACK_TESTS: #3 T3 no new test files']);
|
||||
expect(fx.repo.git('worktree', 'list', '--porcelain')).not.toContain('gstack-stack-tests-');
|
||||
expect(queue(fx, ['train', 'build', '--prs', `1@${h1}`, '--stack-tests']).status).toBe(EXIT.usage);
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -196,9 +196,9 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(queue(fx, ['ready', '--pr', '1', '--broad']).status).toBe(EXIT.usage);
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success);
|
||||
// fails_when=the merge ref's second parent is not compared with the PR head, or a missing merge ref keeps the old QUEUE_NOT_READY code; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
|
||||
test('--screen refuses QUEUE_SCREEN_STALE_REF when refs/pull/<n>/merge lags the PR head or is missing, dispatching nothing; a recomputed merge ref admits it', () => {
|
||||
// Value: protects=--screen never dispatches against a merge ref GitHub has not recomputed for the PR head (GSTA-95 on #3431: a stale ref made evals.yml plan 0 cases and report success): a lagging, missing or unfetchable ref is replaced by a scratch merge of the head onto current main (#3465), and an unreachable remote is still refused;
|
||||
// fails_when=the merge ref's parents are not compared with the PR head and current main, or the scratch merge is skipped; why_new=the --screen test above only sees a fresh merge ref; seam=none; no_claim=GitHub's real recompute timing (a bare origin stands in)
|
||||
test('--screen plans the screen on a scratch merge when refs/pull/<n>/merge lags the PR head, is missing or cannot be fetched; an unreachable remote is POLICY_SOURCE_UNAVAILABLE; a recomputed merge ref is used as is', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
const { repo } = fx;
|
||||
fs.appendFileSync(path.join(repo.work, 'pr-1.txt'), 'pushed after the merge ref was computed\n');
|
||||
@@ -208,41 +208,41 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
fx.write(d => { (d.prs['1'] as any).headRefOid = moved; (d.prs['1'] as any).body = shippedBody('1', moved); d.ci[moved] = [{ status: 'completed', conclusion: 'success' }]; });
|
||||
const pregate2 = path.join(repo.root, 'pregate2.json');
|
||||
fs.writeFileSync(pregate2, JSON.stringify({ schema_version: 1, tree: runBin(repo, 'gstack-wtree', []).out.trim(), head: moved, verdict: 'pass', checks: [{ id: 'unit', status: 'pass' }] }));
|
||||
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(stale.status).toBe(EXIT.refused);
|
||||
expect(stale.err).toMatch(new RegExp(`refs/pull/1/merge \\([0-9a-f]{12}\\) merges ${head.slice(0, 12)}, not the PR head ${moved.slice(0, 12)}.*\\(QUEUE_SCREEN_STALE_REF\\)`));
|
||||
expect(line(stale.out, 'NEXT:')).toBe('NEXT: resolve the conflict with main (or wait for GitHub to recompute refs/pull/1/merge), push, then gstack-ship-queue ready --pr 1 --screen; a screen dispatched on a stale merge ref plans no cases');
|
||||
const stale = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([stale.status, stale.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(stale.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: scratch merge [0-9a-f]{12} of #1@${moved.slice(0, 12)} onto main@[0-9a-f]{12} \\(refs/pull/1/merge [0-9a-f]{12} merges ${head.slice(0, 12)}, not ${moved.slice(0, 12)}\\); would push screen/1-${moved.slice(0, 12)} \\(dry run\\)$`));
|
||||
repo.git('push', '-q', 'origin', ':refs/pull/1/merge');
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(missing.status).toBe(EXIT.refused);
|
||||
expect(missing.err).toMatch(/has no refs\/pull\/1\/merge.*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
const missing = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([missing.status, missing.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(missing.out, 'SCREEN_MERGE:')).toContain('(origin has no refs/pull/1/merge); would push');
|
||||
const tree = repo.git('rev-parse', `${moved}^{tree}`);
|
||||
const main = repo.git('rev-parse', 'origin/main');
|
||||
const badMerge = spawnSync('git', ['-C', repo.origin, 'hash-object', '-t', 'commit', '-w', '--stdin', '--literally'], { input: `tree ${tree}\nparent ${main}\nparent ${moved}\nauthor A <a@example.com> 1700000000 +0000\ncommitter B 1700000000 +0000\n\nmerge without a committer email\n`, encoding: 'utf8', timeout: 30_000 }).stdout.trim();
|
||||
spawnSync('git', ['-C', repo.origin, 'update-ref', 'refs/pull/1/merge', badMerge], { timeout: 30_000 });
|
||||
repo.git('config', 'fetch.fsckObjects', 'true');
|
||||
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
expect(unfetchable.status).toBe(EXIT.refused);
|
||||
expect(unfetchable.err).toMatch(/could not fetch refs\/pull\/1\/merge from origin: .*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(line(unfetchable.out, 'NEXT:')).toBe('NEXT: check the remote, then gstack-ship-queue ready --pr 1 --screen');
|
||||
const unfetchable = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([unfetchable.status, unfetchable.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(unfetchable.out, 'SCREEN_MERGE:')).toMatch(/\(refs\/pull\/1\/merge [0-9a-f]{12} could not be fetched\); would push/);
|
||||
repo.git('config', '--unset', 'fetch.fsckObjects');
|
||||
const originUrl = repo.git('remote', 'get-url', 'origin');
|
||||
repo.git('remote', 'set-url', 'origin', path.join(repo.root, 'no-such-origin.git'));
|
||||
const unlisted = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen']);
|
||||
repo.git('remote', 'set-url', 'origin', originUrl);
|
||||
expect(unlisted.status).toBe(EXIT.refused);
|
||||
expect(unlisted.err).toMatch(/could not list origin's refs: .*\(QUEUE_SCREEN_STALE_REF\)/);
|
||||
expect(unlisted.err).toMatch(/git fetch origin main failed: .*\(POLICY_SOURCE_UNAVAILABLE\)/);
|
||||
expect(unlisted.err.split('\n').filter(Boolean).length).toBe(1);
|
||||
expect(dispatches(fx)).toEqual([]);
|
||||
expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'ready'))).toBe(false);
|
||||
repo.git('push', '-q', '-f', 'origin', `${mergeRefCommit(fx, moved)}:refs/pull/1/merge`);
|
||||
const fresh = queue(fx, ['ready', '--pr', '1', '--pregate', pregate2, '--screen', '--dry-run']);
|
||||
expect([fresh.status, fresh.err]).toEqual([EXIT.ok, '']);
|
||||
expect(line(fresh.out, 'SCREEN_MERGE:')).toMatch(new RegExp(`^SCREEN_MERGE: GitHub's refs/pull/1/merge [0-9a-f]{12} merges the head ${moved.slice(0, 12)} onto main@[0-9a-f]{12}$`));
|
||||
expect(line(fresh.out, 'SCREEN_DISPATCH:')).toContain('(dry run: not dispatched)');
|
||||
expect(repo.git('ls-remote', 'origin', 'refs/heads/screen/*')).toBe('');
|
||||
}, 120_000);
|
||||
|
||||
// Value: protects=a finished screen that planned 0 cases never counts as a screen: ready dispatches a fresh one instead of adopting it and train build seats the slot unscreened with a SCREEN: line;
|
||||
// fails_when=the census is not read (a hollow run is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
|
||||
// fails_when=the census is not read (a not-run screen is followed, or ORDER says screened); why_new=no test reads a screen run's record; seam=none; no_claim=evals.yml's real artifact (the shim's record stands in)
|
||||
test('a screen run that planned 0 cases is not followed by ready and is counted as not-run by train build', () => {
|
||||
const { fx, head, pregate } = readyFixture();
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: null }; });
|
||||
@@ -260,7 +260,7 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 0 } }; });
|
||||
const emptyPlan = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(line(emptyPlan.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 102 .*not following run 101 planned no cases/);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 } }; });
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success', plan: { planned: 3 }, binding: { verdict: 'GREEN' } }; });
|
||||
const live = queue(fx, ['ready', '--pr', '1', '--pregate', pregate, '--screen']);
|
||||
expect(live.status).toBe(EXIT.ok);
|
||||
expect(line(live.out, 'SCREEN_DISPATCH:')).toMatch(/^SCREEN_DISPATCH: evals\.yml run 103 .*not following run 102 planned no cases \(an empty plan manifest; concluded success\)/);
|
||||
@@ -269,10 +269,11 @@ describe('ready under a train policy: the screen choice', () => {
|
||||
expect(line(followed.out, 'SCREEN_DISPATCH:')).toBe('SCREEN_DISPATCH: following run 103 (same merge SHA, base and policy; no duplicate)');
|
||||
expect(dispatches(fx, 'evals.yml').length).toBe(4);
|
||||
const liveBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'in_progress', conclusion: null }; });
|
||||
expect([liveBuild.status, line(liveBuild.out, 'ORDER:'), line(liveBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 GREEN']);
|
||||
fx.write(d => { for (const run of d.runs) if ((run as any).databaseId === 103) Object.assign(run, { status: 'in_progress', conclusion: null }); });
|
||||
const runningBuild = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', '']);
|
||||
expect([runningBuild.status, line(runningBuild.out, 'ORDER:'), line(runningBuild.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', 'SCREEN: #1 run 103 still running; counted as screened']);
|
||||
fx.write(d => { for (const run of d.runs) if ((run as any).databaseId === 103) Object.assign(run, { status: 'completed', conclusion: 'success' }); });
|
||||
fx.write(d => { d.rules['evals.yml'] = { status: 'completed', conclusion: 'success' }; d.artifacts['103']['paid-plan'] = { reuse: 'confirmed' }; });
|
||||
const unreadable = queue(fx, ['train', 'build', '--prs', `1@${head}`, '--dry-run']);
|
||||
expect([unreadable.status, line(unreadable.out, 'ORDER:'), line(unreadable.out, 'SCREEN:')]).toEqual([EXIT.ok, 'ORDER: 1:#1(screened)', "SCREEN: #1 run 103's paid-plan manifest.json is unreadable; counted as screened, unverified"]);
|
||||
|
||||
@@ -1342,3 +1342,87 @@ Write ${workDir}/gate-output.md listing what you fixed and every remaining findi
|
||||
expect(out).toContain('accepted');
|
||||
}, CAPTURE_MS);
|
||||
});
|
||||
|
||||
describeIfSelected('Design motion showcase E2E', ['design-motion-showcase'], () => {
|
||||
let workDir: string;
|
||||
let gstackHome: string;
|
||||
|
||||
beforeAll(() => {
|
||||
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-motion-'));
|
||||
gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-motion-home-'));
|
||||
const run = (cmd: string, args: string[]) => spawnSync(cmd, args, { cwd: workDir, stdio: 'pipe', timeout: 5000 });
|
||||
run('git', ['init', '-b', 'main']);
|
||||
run('git', ['config', 'user.email', 'test@test.com']);
|
||||
run('git', ['config', 'user.name', 'Test']);
|
||||
fs.copyFileSync(path.join(ROOT, 'test', 'fixtures', 'design-motion-finalized.html'), path.join(workDir, 'finalized.html'));
|
||||
run('git', ['add', '.']);
|
||||
run('git', ['commit', '-m', 'finalized page']);
|
||||
const full = fs.readFileSync(path.join(ROOT, 'design-motion', 'SKILL.md'), 'utf-8');
|
||||
const text = full.slice(full.indexOf('# /design-motion:'))
|
||||
.replaceAll('$HOME/.claude/skills/gstack', ROOT).replaceAll('~/.claude/skills/gstack', ROOT);
|
||||
fs.writeFileSync(path.join(workDir, 'design-motion.md'), text);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const d of [workDir, gstackHome]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
|
||||
});
|
||||
|
||||
testConcurrentIfSelected('design-motion-showcase', async () => {
|
||||
const result = await runSkillTest({
|
||||
prompt: `Read design-motion.md: the /design-motion skill. Run it on finalized.html in this directory, the user's /design-html page, as the source.
|
||||
Settled answers, so do not use AskUserQuestion: showcase context; separate cards (the two .card sections); stories: the bars breathe and settle on the design heights, a soft highlight steps down the invoice rows; no camera; a 6 second cycle instead of 12 (scale the phases to it); name "ledgerline"; the frame is 640x400.
|
||||
Read each section the skill points to before its step. Build page.html with the capture contract, verify the still, capture with a mask on the cards, run the motion checks, fix and recheck until they pass, then export. Do not open a browser window and do not install anything.
|
||||
Finally write ${workDir}/motion-report.md with every MOTION_CHECK line, the MOTION_RESULT line, and each EXPORT_OK line exactly as printed.`,
|
||||
workingDirectory: workDir,
|
||||
maxTurns: 60,
|
||||
timeout: CAPTURE_LONG_MS,
|
||||
testName: 'design-motion-showcase',
|
||||
runId,
|
||||
env: { GSTACK_HOME: gstackHome },
|
||||
});
|
||||
|
||||
logCost('/design-motion showcase', result);
|
||||
recordE2E(evalCollector, '/design-motion showcase', 'Design motion showcase E2E', result);
|
||||
expect(result.exitReason).toBe('success');
|
||||
const sectionRead = (name: string) => {
|
||||
if (result.toolCalls.some(c => c.tool === 'Read' && String(c.input?.file_path ?? '').endsWith(`/design-motion/sections/${name}`))) return true;
|
||||
const printed = result.toolCalls
|
||||
.filter(c => c.tool === 'Bash' && String(c.input?.command ?? '').includes(`design-motion/sections/${name}`))
|
||||
.map(c => String(c.output ?? '')).join('\n');
|
||||
const lines = fs.readFileSync(path.join(ROOT, 'design-motion', 'sections', name), 'utf-8').split('\n').map(l => l.trimEnd()).filter(Boolean);
|
||||
return lines.every(line => printed.includes(line));
|
||||
};
|
||||
expect(sectionRead('fidelity.md')).toBe(true);
|
||||
expect(sectionRead('motion.md')).toBe(true);
|
||||
const commands = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? '')).join('\n');
|
||||
const after = (re: RegExp, from: number) => { const i = commands.slice(from).search(re); return i < 0 ? -1 : from + i; };
|
||||
const capture = after(/gstack-motion-capture\.ts\b[^\n]*--out\b/, 0);
|
||||
expect(capture).toBeGreaterThanOrEqual(0);
|
||||
const check = after(/gstack-motion-encode['"]?\s+check\b/, capture);
|
||||
expect(check).toBeGreaterThan(capture);
|
||||
expect(after(/gstack-motion-encode['"]?\s+export\b/, check)).toBeGreaterThan(check);
|
||||
|
||||
const designs = path.join(gstackHome, 'projects');
|
||||
const motionDirs = fs.existsSync(designs)
|
||||
? fs.readdirSync(designs).flatMap(slug => {
|
||||
const d = path.join(designs, slug, 'designs');
|
||||
return fs.existsSync(d) ? fs.readdirSync(d).filter(n => n.includes('-motion-')).map(n => path.join(d, n)) : [];
|
||||
})
|
||||
: [];
|
||||
expect(motionDirs.length).toBe(1);
|
||||
const page = fs.readFileSync(path.join(motionDirs[0]!, 'page.html'), 'utf-8');
|
||||
expect(page).toContain('__start');
|
||||
expect(page).toContain('__tick');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(motionDirs[0]!, 'cap', 'capture.json'), 'utf-8'));
|
||||
expect(manifest.windowFrames).toBe(360);
|
||||
expect(manifest.mask).toBe('mask.png');
|
||||
|
||||
const report = fs.readFileSync(path.join(workDir, 'motion-report.md'), 'utf-8');
|
||||
for (const id of ['seam', 'empty-beat', 'rest', 'overflow']) expect(report).toContain(`MOTION_CHECK: ${id} pass`);
|
||||
expect(report).toContain('MOTION_RESULT: clean');
|
||||
for (const file of ['ledgerline-x.mp4', 'ledgerline-loop.gif', 'ledgerline-800x500.gif']) {
|
||||
expect(report).toContain(file);
|
||||
expect(fs.statSync(path.join(motionDirs[0]!, 'cap', file)).size).toBeGreaterThan(10_000);
|
||||
}
|
||||
}, CAPTURE_LONG_MS);
|
||||
});
|
||||
@@ -45,7 +45,9 @@ describe('stamp templates (pure)', () => {
|
||||
expect(rewriteStamps('"version": "1.2.3.0",', '1.2.3.0', '1.2.4.0', ['"version": "{version}"']).count).toBe(1);
|
||||
expect(rewriteStamps('<!-- x-plugin-tree-stamp: 1.2.3.0 -->', '1.2.3.0', '1.2.4.0', ['<!-- x-plugin-tree-stamp: {version} -->']).text).toBe('<!-- x-plugin-tree-stamp: 1.2.4.0 -->');
|
||||
expect(rewriteStamps("since: '1.2.3.01'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(0);
|
||||
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0').count).toBe(0);
|
||||
// A quoted since: row is a built-in form too (gbrain #7640): the template and the built-in count it once.
|
||||
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0')).toEqual({ text: "since: '1.2.4.0'", count: 1 });
|
||||
expect(rewriteStamps("since: '1.2.3.0'", '1.2.3.0', '1.2.4.0', ["since: '{version}'"]).count).toBe(1);
|
||||
});
|
||||
|
||||
test('policy: stamp_patterns are bounded literal templates with exactly one {version}; stamp_files are contained globs', () => {
|
||||
|
||||
Reference in new issue
Block a user