mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 16:34:00 +01:00
* feat(design-html): craft section and free craft check, adapted from Elaya design skills (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(GSTA-92): restore the full CHANGELOG under the Unreleased entry; drop a $-digit literal from craft.md; count only craft-check executions in design-html-slop-gate Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-shotgun,design-html): carry the chosen archetype into approved.json; persist accepted craft exceptions in finalized.json (GSTA-92 phase 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(design-review,plan-design-review): craft audit carved on every host, Persuade plan checks, craft tells as catalog entries (GSTA-92 phase 2) Size pins and caps raised to measured sizes only: design-review (13 hosts), plan-design-review (12), design-consultation (9), design-consultation maxSizeRatio, and the new design-review parity guard. Garry 2026-10-25 "Do it" (GSTA-92). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * refactor(design-catalog): craft tells are checker-only entries; trim design-review and plan-design-review growth to the planned size (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(touchfiles): design-html-slop-gate selects on the craft check it runs (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(touchfiles): design-review and plan-design-review cases select on the craft resolver; slop gate on the catalog (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(placeholders): allow the craft audit page path placeholder (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (GSTA-92) A malformed or missing --accepted exceptions file now gets the full craft check with nothing accepted, after a warning, still exiting 2. Before, it printed nothing, and the craft section treats a missing CRAFT_RESULT as 'check unavailable', so one bad finalized.json switched the gate off (found by both adversarial passes). The header comment and the craft section now document the accepted-count suffix on CRAFT_RESULT. Tests cover duplicate carried-forward exception ids and the slop-gate touchfiles selection. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: scope --accepted to the same page; record current counts (GSTA-92) The craft section passed --accepted whenever a finalized.json sat beside the page, so a same-day Start fresh build inherited the old page's exceptions. It now passes them only when evolving or rerunning a page it finalized, records the count the check reports now, and writes [] when nothing was accepted instead of the template's placeholder entry. A test pins exit 2 for a clean page with a broken exceptions file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: note the archetype handoff in the shotgun and design-html loops (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: craft audit capture, exceptions and trigger from /ship review (GSTA-92) - craft-audit: no CRAFT_RESULT line means unavailable (exit 2 after a malformed --accepted record still printed findings); --accepted only for a /design-html finalized page, never for needs-proof or lorem; never add [Needs proof] markers to a live page; Aside capture via pg.evaluate; the saved raw DOM is deleted in the checking block and never reported - manifest trigger covers an explicit craft audit (skeleton -6 bytes; pins lowered to measured size, union ratio back under its cap) - autoFixEntries reads RENDERED; DESIGN_CRAFT_RULE needs exactly one lead - tests: checkerOnly contract, resolver throw paths, Persuade checks in both skills, resolver touchfile selection, exception state cells - CHANGELOG: host counts and the audit's new rules Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: craft audit live-page limits, claim handling and guards (GSTA-92) - craft-audit: linked CSS/JS is unchecked on a saved page (judge by eye); a mount-node shell is framework source; --accepted takes that screen's finalized.json or none; on a live page a claim without proof is filed and asked about, never marked or cut; the saved page is removed whatever the exit code; shorter fix-order and Tailwind prose keeps the union under cap - tests: exact-lead red cases, autoFixEntries in the checkerOnly union, the audit's outcome/exception/session-data clauses; carve guard checks the session-data rule and records measured sizes (skeleton cap 137_767) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: craft check skips mount-node shells and script bodies; audit capture order (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * revert: craft checker back to bed645a's reviewed behavior; textless mount-node shell judged by eye (GSTA-92) cycle-cap exception (GSTA-92 lane, 2026-10-25): reverted 216987e's checker half to bed645a's reviewed behavior; byte-neutral prose reword; no new logic Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: craft audit judges any mount-node shell by eye; pin the clause (GSTA-92) second cycle-cap exception: GStack Minder's decision under Garry's 'do it' on phase 2 (GSTA-92, 2026-10-25): one deleted word plus one test Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: craft resolver placeholders, carved-on-every-host list, design-craft resolver (GSTA-92 /ship docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes after main merge: claim rule covers repo files, craft temp var, guard tests (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: craft audit files failed Persuade checks as deferred High, judges source tells by eye; stderr pin (GSTA-92) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(design-review): never cut a claim a rule or printed fix says to drop Pass R3 (native adversarial, Medium): the CRAFT_CHECK fix text for round-number and needs-proof says to drop or remove the claim, which could lead Phase 8 to delete a real claim. The audit sentence now covers both a rule and the printed fix: defer the claim and ask for the proof. Byte-neutral (80 -> 80); design-review/SKILL.md 137,767, union 145,037, unchanged. The test pin moves with the sentence; no carve-guard mustContain held this sentence. third cycle-cap exception (Captain, 2026-10-26): zero-byte reword closing the content-deletion path; test pin and carve-guard mustContain moved Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * design-craft: reconcile ids with the design lint (layout-transition is static-tier; legacy transition-all exceptions map to it) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
285 lines
20 KiB
TypeScript
285 lines
20 KiB
TypeScript
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, 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, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN = path.join(ROOT, 'bin', 'gstack-design-craft');
|
|
|
|
const DIRTY = `<!doctype html><html><head><style>.hero{height:100vh;transition: all .3s}</style></head><body>
|
|
<nav><a href="#">Docs</a><a href="/">Home</a><a href="/pricing">Pricing</a></nav>
|
|
<h1>Elevate your workflow seamlessly</h1><p>Trusted by Acme and John Doe. 99.99% uptime, 50% faster, 10,000+ happy users. [Needs proof: logo]</p>
|
|
<img src="a.png"><p>Oops! Something broke. Lorem ipsum.</p>
|
|
<script>window.addEventListener('scroll', () => {}); alert('hi'); showAlert(1);</script></body></html>`;
|
|
|
|
const CLEAN = `<!doctype html><html lang="en"><head><title>Ledgerline: close the books in 2 days</title>
|
|
<meta name="description" content="Close faster."><meta property="og:image" content="/og.png"><link rel="icon" href="/favicon.svg">
|
|
<style>.hero{min-height:100vh;min-height:100dvh;transition: transform .4s var(--ease-out)} .w{width:100%}</style></head>
|
|
<body><a href="#main">Skip to content</a><a href="#pricing">Pricing</a><nav><a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a></nav>
|
|
<main id="main"><h1>Close the books in 2 days, not 9</h1><p>Teams at Halvorsen Freight cut month-end by 47.2%. Save 20% yearly. 100% open source. 30-day refund.</p><img src="x.png" alt=""></main>
|
|
<script>const io = new IntersectionObserver(() => {}); window.showAlert = () => {};</script></body></html>`;
|
|
|
|
function run(file?: string, ...extra: string[]) {
|
|
const r = Bun.spawnSync([BIN, ...(file ? [file] : []), ...extra], { stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
|
|
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() };
|
|
}
|
|
|
|
describe('design-craft check', () => {
|
|
test('a page with every tell reports each one with its count', () => {
|
|
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, '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,
|
|
});
|
|
});
|
|
|
|
test('a finished page is clean: CSS 100%, a 100vh line with its 100dvh fallback, in-page anchors, a showAlert helper and inlined Pretext are not flagged', () => {
|
|
expect(checkCraft(CLEAN)).toEqual([]);
|
|
const pretext = fs.readFileSync(path.join(ROOT, 'design-html', 'vendor', 'pretext.js'), 'utf-8');
|
|
expect(checkCraft(CLEAN.replace('</body>', `<script type="module">${pretext}</script></body>`))).toEqual([]);
|
|
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*/);
|
|
expect(words.length).toBeGreaterThan(5);
|
|
for (const word of words) expect(BUZZWORDS).toContain(word);
|
|
expect(checkCraft('<p>Unlocking seamless, next gen workflows that elevate teams</p>').map(f => [f.id, f.count])).toEqual([['marketing-buzzword', 4]]);
|
|
expect(checkCraft('<p>Take the elevator to the elevation map; streamers sold here</p>')).toEqual([]);
|
|
});
|
|
|
|
test('the skip link must reach an existing id, and aria-current is required only for route navigation', () => {
|
|
const ids = (html: string) => checkCraft(html).map(f => f.id);
|
|
expect(ids(CLEAN.replace('id="main"', 'id="content"'))).toEqual(['missing-skip-link']);
|
|
const sectionNav = CLEAN.replace('<a href="/" aria-current="page">Home</a><a href="/pricing">Pricing</a>', '<a href="#features">Features</a><a href="#faq">FAQ</a>');
|
|
expect(ids(sectionNav)).toEqual([]);
|
|
expect(ids(CLEAN.replace(' aria-current="page"', ''))).toEqual(['missing-aria-current']);
|
|
});
|
|
|
|
test('attribute look-alikes, encoded markers and 100vh ordering are read correctly', () => {
|
|
const ids = (html: string) => checkCraft(html).map(f => f.id);
|
|
expect(ids('<img src="a.png" data-alt="x">')).toEqual(['img-no-alt']);
|
|
expect(ids('<p>[Needs proof: logo]</p>')).toEqual(['needs-proof']);
|
|
expect(ids(CLEAN.replace('id="main"', 'data-id="main"'))).toEqual(['missing-skip-link']);
|
|
expect(ids('<style>.a{min-height:100dvh;min-height:100vh}</style>')).toEqual(['vh-height']);
|
|
expect(ids('<style>.a{min-height:100vh !important;min-height:100dvh}</style>')).toEqual(['vh-height']);
|
|
});
|
|
|
|
test('every word and name the craft section bans is one the checker flags', () => {
|
|
const section = fs.readFileSync(path.join(ROOT, 'design-html', 'sections', 'craft.md.tmpl'), 'utf-8');
|
|
const buzz = section.match(/No buzzwords: ([^\n]+)\.\n/)?.[1].replace(/"/g, '').split(/,\s*/) ?? [];
|
|
expect(buzz.length).toBeGreaterThan(10);
|
|
for (const word of buzz) expect(BUZZWORDS).toContain(word);
|
|
const names = section.match(/No placeholder people or brands \(([^)]+)\)/)?.[1].split(/,\s*/) ?? [];
|
|
expect(names.length).toBe(5);
|
|
for (const name of names) expect(checkCraft(`<p>${name}</p>`).map(f => f.id)).toEqual([/Doe/.test(name) ? 'placeholder-name' : 'placeholder-brand']);
|
|
});
|
|
|
|
test('skip link is judged by position and target; alert() counts only in script; svg text is copy', () => {
|
|
const ids = (html: string) => checkCraft(html).map(f => f.id);
|
|
expect(ids(CLEAN.replace('>Skip to content<', '>Zum Inhalt springen<'))).toEqual([]);
|
|
expect(ids(CLEAN.replace('<a href="#main">Skip to content</a>', ''))).toEqual(['missing-skip-link']);
|
|
expect(ids('<p>Set a price alert (free)</p>')).toEqual([]);
|
|
expect(ids('<button onclick="alert(1)">Go</button>')).toEqual(['alert']);
|
|
expect(ids('<svg><text>Acme</text></svg>')).toEqual(['placeholder-brand']);
|
|
});
|
|
|
|
test('copy rules read visible text only; head rules skip a fragment', () => {
|
|
expect(visibleText('<style>.a{content:"Acme"}</style><script>// John Doe</script><p>Halvorsen</p>')).toBe(' Halvorsen ');
|
|
expect(checkCraft('<section><h2>Pricing</h2><a href="/buy">Start free trial</a></section>')).toEqual([]);
|
|
});
|
|
|
|
test('the CLI prints CRAFT_CHECK lines and exits 1 on tells, 0 when clean, 2 on usage', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-'));
|
|
try {
|
|
fs.writeFileSync(path.join(dir, 'dirty.html'), DIRTY);
|
|
fs.writeFileSync(path.join(dir, 'clean.html'), CLEAN);
|
|
const dirty = run(path.join(dir, 'dirty.html'));
|
|
expect(dirty.code).toBe(1);
|
|
expect(dirty.out).toContain('CRAFT_CHECK: scroll-listener 1 ');
|
|
expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 18 issue(s)');
|
|
const clean = run(path.join(dir, 'clean.html'));
|
|
expect(clean).toMatchObject({ code: 0, out: 'CRAFT_RESULT: clean\n' });
|
|
expect(run().code).toBe(2);
|
|
expect(run(path.join(dir, 'missing.html')).code).toBe(2);
|
|
fs.writeFileSync(path.join(dir, 'empty.html'), ' \n');
|
|
expect(run(path.join(dir, 'empty.html')).code).toBe(2);
|
|
fs.writeFileSync(path.join(dir, 'page.tsx'), 'export const P = () => <p>Acme</p>;');
|
|
expect(run(path.join(dir, 'page.tsx'))).toMatchObject({ code: 2, out: '' });
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('accepted craft exceptions (finalized.json craft_exceptions)', () => {
|
|
test('an exception covers its id up to the accepted count; a higher count stays an issue and names the accepted count', () => {
|
|
const findings = checkCraft(DIRTY);
|
|
const { issues, accepted } = acceptFindings(findings, [
|
|
{ id: 'placeholder-brand', count: 1, reason: 'Acme is the real customer' },
|
|
{ id: 'round-number', count: 2, reason: 'supplied figures' },
|
|
{ id: 'scroll-listener', count: 5, reason: 'unused' },
|
|
]);
|
|
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()]) {
|
|
expect(acceptFindings([{ id: 'round-number', count: 3, fix: 'use the supplied figure' }], list)).toEqual({ issues: [], accepted: [{ id: 'round-number', count: 3, reason: 'supplied figures' }] });
|
|
}
|
|
});
|
|
|
|
test('parseCraftExceptions reads the finalized.json design-html writes and rejects a malformed list', () => {
|
|
const skill = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8');
|
|
const block = skill.slice(skill.indexOf('Write `finalized.json` alongside the HTML')).match(/```json\n([\s\S]*?)```/)![1];
|
|
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();
|
|
}
|
|
});
|
|
|
|
test('the CLI reports accepted tells as CRAFT_ACCEPTED lines: exit 0 when only accepted ones remain, 1 for the rest, 2 on a bad exceptions file after a full check with none accepted', () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-craft-accepted-'));
|
|
try {
|
|
const page = path.join(dir, 'finalized.html');
|
|
const record = path.join(dir, 'finalized.json');
|
|
fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme'));
|
|
fs.writeFileSync(record, JSON.stringify({ mode: 'evolve', craft_exceptions: [{ id: 'placeholder-brand', count: 1, reason: 'Acme Freight is the customer' }] }));
|
|
expect(run(page, '--accepted', record)).toMatchObject({ code: 0, out: 'CRAFT_ACCEPTED: placeholder-brand 1 Acme Freight is the customer\nCRAFT_RESULT: clean (1 accepted)\n' });
|
|
expect(run(page).code).toBe(1);
|
|
fs.writeFileSync(page, CLEAN.replace('Halvorsen Freight', 'Acme and Nexus'));
|
|
const twice = run(page, '--accepted', record);
|
|
expect(twice.code).toBe(1);
|
|
expect(twice.out).toBe('CRAFT_CHECK: placeholder-brand 2 use a supplied customer, or a fitting name in sample data (1 accepted earlier: Acme Freight is the customer)\nCRAFT_RESULT: 1 issue(s)\n');
|
|
fs.writeFileSync(page, DIRTY);
|
|
const dirty = run(page, '--accepted', record);
|
|
expect(dirty.code).toBe(1);
|
|
expect(dirty.out).toContain('CRAFT_ACCEPTED: placeholder-brand 1 ');
|
|
expect(dirty.out.trim().split('\n').at(-1)).toBe('CRAFT_RESULT: 17 issue(s), 1 accepted');
|
|
fs.writeFileSync(path.join(dir, 'old.json'), JSON.stringify({ mode: 'freeform' }));
|
|
expect(run(page, '--accepted', path.join(dir, 'old.json')).out).toBe(run(page).out);
|
|
fs.writeFileSync(path.join(dir, 'bad.json'), '{"craft_exceptions":[{"id":"oops"}]}');
|
|
fs.writeFileSync(path.join(dir, 'broken.json'), '{');
|
|
for (const name of ['bad.json', 'broken.json', 'missing.json']) {
|
|
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' });
|
|
expect(run(page, '--accepted').code).toBe(2);
|
|
expect(run(page, '--strict').code).toBe(2);
|
|
expect(run(page, '--accepted', record, 'extra').code).toBe(2);
|
|
} finally {
|
|
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', () => {
|
|
const ARCHETYPES = ['classic-hero', 'long-form-story', 'minimal-conversion', 'comparison'];
|
|
const read = (...p: string[]) => fs.readFileSync(path.join(ROOT, ...p), 'utf-8');
|
|
|
|
test('design-shotgun records the chosen archetype and outline in approved.json, and design-html takes them as given', () => {
|
|
const shotgun = read('design-shotgun', 'SKILL.md');
|
|
const craft = read('design-html', 'sections', 'craft.md');
|
|
const html = read('design-html', 'SKILL.md');
|
|
for (const id of ARCHETYPES) {
|
|
expect(shotgun).toContain(`\`${id}\``);
|
|
expect(craft).toContain(`\`${id}\``);
|
|
}
|
|
const save = shotgun.slice(shotgun.indexOf('## Step 6: Save & Next Steps'));
|
|
expect(save).toMatch(/add the chosen variant's `archetype` id and `sections`[^.]*to it with the Edit tool/);
|
|
expect(html.slice(html.indexOf('### Case A'), html.indexOf('### Case B'))).toContain('any `archetype`\nand `sections`');
|
|
expect(craft).toMatch(/When approved\.json carries an `archetype`[^.]*: take it and its `sections` outline as given[^.]*do not re-pick or re-ask/);
|
|
});
|
|
|
|
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', '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');
|
|
expect(html).toMatch(/gstack-design-craft <path-to-finalized\.html>` \(plus `--accepted` per the section\)/);
|
|
expect(craft).toContain('records each one in `craft_exceptions`');
|
|
expect(craft).toContain('append `--accepted <that finalized.json>` to the command');
|
|
expect(craft).toContain('CRAFT_ACCEPTED: <id> <count> <reason>');
|
|
});
|
|
});
|