mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-10 23:19:09 +02:00
fix(design): run the DOM dump in the page on both engines; align doctrine with the catalog
The DOM-dump script is an arrow function, not a self-calling IIFE: Aside's `pg.evaluate($_DUMP)` receives the function and runs it in the page (the IIFE form executed in the repl sandbox, where `document` does not exist), and the fallback engine calls it with `$B js "($_DUMP)()" --out --raw`. Hygiene widens to every URL-bearing attribute (src, srcset per candidate, poster, action, formaction, data, ping, cite lose their query strings and fragments) and to data: URLs inside existing <style> nodes. The persist and scan blocks restate REPORT_DIR and RUN_ID literally instead of relying on a shell variable from an earlier block; the baseline's targetSet is defined per mode (repo-relative paths in source mode, page slugs in DOM mode) so DOM-mode deltas can match; the PR-body Detector line lists the states the probe can actually print. The DOM fixture is re-captured with the new script from outside the repo (the engine walks up from cwd for DESIGN.md, which the metadata now records). Doctrine contradictions the design specialist found: the landing-page motion rule matches the one-authored-moment reflex; the background rule names the catalog's halo/spotlight/stripe/grid slop instead of asking for gradients; the universal font rule is scoped to the display voice with the body/UI exceptions; "two typefaces max" allows the mono; the methodology's banned-font line renders BANNED_FONTS; Courier New is banned outright; the Brutalist, Retro-Futuristic, and Playful menu entries stop recommending system stacks, glow, and bounce; the coherence nudge uses the decoration vocabulary; Path A's gate names the display voice; font-loading prose points at the source the procedure verified; centered-everything is MEDIUM (an aggregate heuristic); the mockup guard reads "Never by default (unless the brief above asks for it)". The checklist's AUTO-FIX list renders the catalog's auto-fix rules; category 9 and the Hard Rules pointer count from the same partition helpers (detectorSlopEntries, judgmentTellEntries); the handoff list renders from HANDOFF_COMMANDS; a missing catalog id fails gen-skill-docs by name. gstack's own DESIGN.md gains border tokens and Decisions Log rows for its live-feed pulse and 11px mono labels. frontend-scope is case-sensitive like the bash arm. gen-skill-docs shares one emitGenerated helper for sections and lib-derived assets; renderCatalog keeps the one style with a caller. Tests: shared sliceBetween that fails on a missing end marker, the slop-gate fixture's real end marker, an isolated browse daemon for the DOM-mode E2E, the DOM hygiene test gated to CI or opt-in, docs notes for the two superseded plan sentences. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
b4d88a0126
commit
da6f0ff2f6
+19
-25
@@ -14,7 +14,7 @@ import { spawnSync } from 'child_process';
|
||||
import {
|
||||
DESIGN_SLOP_CATALOG, HANDOFF_COMMANDS, OVERUSED_FONTS_DISPLAY, BANNED_FONTS, MOCKUP_NEVER_NAMES,
|
||||
FONTS_BODY_UI_OK, FONTS_MONO_OK, FONTS_VERIFIED_FREE,
|
||||
catalogEntry, entryForImpeccableId, renderCatalog, selectCatalog,
|
||||
catalogEntry, catalogEntries, entryForImpeccableId, renderCatalog, selectCatalog, detectorSlopEntries, judgmentTellEntries,
|
||||
} from '../lib/design-catalog';
|
||||
import { AI_SLOP_BLACKLIST } from '../scripts/resolvers/constants';
|
||||
|
||||
@@ -138,44 +138,38 @@ describe('fonts', () => {
|
||||
});
|
||||
|
||||
test('banned fonts and overused fonts do not overlap; mono list is mono', () => {
|
||||
for (const f of BANNED_FONTS) expect(OVERUSED_FONTS_DISPLAY).not.toContain(f);
|
||||
for (const f of BANNED_FONTS) { expect(OVERUSED_FONTS_DISPLAY).not.toContain(f); expect(f).not.toMatch(/\(/); } // no role qualifiers: banned means every role
|
||||
for (const f of FONTS_MONO_OK) expect(f).toMatch(/Mono|Code/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderCatalog', () => {
|
||||
test('ids style brackets only detector-known ids', () => {
|
||||
const out = renderCatalog({ kind: 'slop', style: 'ids' });
|
||||
expect(out).toContain('- [nested-cards] ');
|
||||
expect(out).toContain('- [side-tab] ');
|
||||
for (const e of DESIGN_SLOP_CATALOG.filter(x => !x.impeccableId)) {
|
||||
expect(out).not.toContain(`[${e.id}]`);
|
||||
}
|
||||
// gstack-only prose still renders, unbracketed
|
||||
expect(out).toContain('- ' + catalogEntry('hero-metrics')!.prose);
|
||||
});
|
||||
|
||||
describe('renderCatalog + partitions', () => {
|
||||
test('bullets style renders prose only, no ids anywhere', () => {
|
||||
const out = renderCatalog({ kind: 'slop', style: 'bullets' });
|
||||
const out = renderCatalog({ kind: 'slop' });
|
||||
expect(out).not.toMatch(/^- \[/m);
|
||||
expect(out.split('\n').length).toBe(selectCatalog({ kind: 'slop' }).length);
|
||||
for (const line of AI_SLOP_BLACKLIST) expect(out).toContain(`- ${line}`);
|
||||
});
|
||||
|
||||
test('compact style is one line of id: name pairs', () => {
|
||||
const out = renderCatalog({ kind: 'quality', style: 'compact' });
|
||||
expect(out.includes('\n')).toBe(false);
|
||||
expect(out).toContain('low-contrast: Low contrast text');
|
||||
expect(out.split('; ').length).toBe(selectCatalog({ kind: 'quality' }).length);
|
||||
});
|
||||
|
||||
test('filters compose: category and omitImpact', () => {
|
||||
const copy = selectCatalog({ kind: 'slop', category: 'copy' });
|
||||
expect(copy.every(e => e.category === 'copy')).toBe(true);
|
||||
test('omitImpact filters', () => {
|
||||
const noPolish = selectCatalog({ kind: 'slop', omitImpact: ['polish'] });
|
||||
expect(noPolish.some(e => e.impact === 'polish')).toBe(false);
|
||||
expect(noPolish.length).toBeLessThan(selectCatalog({ kind: 'slop' }).length);
|
||||
});
|
||||
|
||||
test('detector-known slop and judgment tells partition the non-legacy slop entries', () => {
|
||||
const detector = detectorSlopEntries();
|
||||
const tells = judgmentTellEntries();
|
||||
expect(detector.every(e => e.impeccableId && !e.legacyBlacklist && e.kind === 'slop')).toBe(true);
|
||||
expect(tells.every(e => !e.impeccableId && !e.legacyBlacklist && e.kind === 'slop')).toBe(true);
|
||||
expect(detector.length + tells.length + 11).toBe(selectCatalog({ kind: 'slop' }).length);
|
||||
expect(detectorSlopEntries({ omitPolish: true }).every(e => e.impact !== 'polish')).toBe(true);
|
||||
});
|
||||
|
||||
test('catalogEntries throws with the missing id', () => {
|
||||
expect(() => catalogEntries(['nested-cards', 'no-such-id'])).toThrow('no-such-id');
|
||||
expect(catalogEntries(['nested-cards'])[0].name).toBe('Nested cards');
|
||||
});
|
||||
});
|
||||
|
||||
const MOCKUP_NEVER_IDS = ['kicker-above-heading', 'icon-tile-stack', 'gradient-text', 'ai-color-palette', 'cream-palette', 'nested-cards', 'dark-glow', 'pulsing-dot', 'identical-cards', 'hero-metrics'];
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* lib/dom-dump.js hygiene, exercised in a real browser through gstack's own
|
||||
* browse binary (`$B eval <file> --out <path> --raw`, the same fallback path
|
||||
* /design-review renders). Self-skips when no browse binary is built
|
||||
* (`bun run build:gates`), like the other render gates.
|
||||
*
|
||||
* Pins the rules the DOM dump promises before a page leaves the browser:
|
||||
* input values dropped, long data: URLs replaced (attributes, inlined CSS, and
|
||||
* existing <style> nodes), <meta content> emptied (viewport kept), query
|
||||
* strings cut from every URL attribute, script bodies emptied, linked
|
||||
* stylesheets inlined with author hex restored, cross-origin sheets named in
|
||||
* the trailing note, inlined <link> nodes removed.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { DOM_DUMP_STYLE_ATTR, DOM_DUMP_NOTE_PREFIX } from '../lib/dom-dump-script';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const CANDIDATES = [path.join(ROOT, 'browse', 'dist', 'browse'), path.join(os.homedir(), '.claude', 'skills', 'gstack', 'browse', 'dist', 'browse')];
|
||||
const BROWSE = CANDIDATES.find(p => fs.existsSync(p));
|
||||
const POSIX = process.platform !== 'win32';
|
||||
// Launching Chromium is load-sensitive (a cold daemon can miss the CLI's health
|
||||
// window on a busy dev box). Runs in CI and on explicit opt-in; skips otherwise.
|
||||
const OPTED_IN = Boolean(process.env.CI || process.env.GSTACK_DOM_DUMP_HYGIENE);
|
||||
|
||||
describe.skipIf(!BROWSE || !POSIX || !OPTED_IN)('lib/dom-dump.js in a real DOM (CI or GSTACK_DOM_DUMP_HYGIENE=1)', () => {
|
||||
test('applies every hygiene rule and inlines the linked stylesheet', async () => {
|
||||
const site = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dom-dump-site-'));
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-dom-dump-out-'));
|
||||
const server = Bun.serve({
|
||||
hostname: '127.0.0.1', port: 0,
|
||||
fetch(req) {
|
||||
const p = new URL(req.url).pathname.replace(/^\//, '') || 'index.html';
|
||||
const f = path.join(site, p);
|
||||
return fs.existsSync(f) ? new Response(Bun.file(f)) : new Response('nope', { status: 404 });
|
||||
},
|
||||
});
|
||||
const big = 'data:image/png;base64,' + 'A'.repeat(1500);
|
||||
fs.writeFileSync(path.join(site, 'styles.css'), '.hero { background: linear-gradient(135deg, #6366f1, #8b5cf6); } .x { background-image: url("' + big + '"); }\n');
|
||||
fs.writeFileSync(path.join(site, 'index.html'), `<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="description" content="SECRET DESCRIPTION">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="http://127.0.0.1:1/cross-origin.css">
|
||||
<script>window.__x = "SCRIPT BODY";</script></head><body>
|
||||
<input value="SECRET INPUT"><textarea>SECRET TEXT</textarea>
|
||||
<a href="/page?token=SECRET">link</a>
|
||||
<img src="/img.png?sig=SECRETSIG" srcset="/a.png?s=SECRETSET 1x, /b.png?s=SECRETSET2 2x">
|
||||
<form action="/submit?csrf=SECRETCSRF"><button formaction="/alt?f=SECRETFORM">go</button></form>
|
||||
<style>.inline { background: url("${big}") }</style>
|
||||
<div data-long="${'L'.repeat(40)}" data-short="ok" title="${big}">x</div>
|
||||
<img src="${big}">
|
||||
</body></html>`);
|
||||
const url = `http://127.0.0.1:${server.port}/index.html`;
|
||||
// Own daemon: BROWSE_STATE_FILE scopes the state dir, lock, port file, and
|
||||
// profile to this test, so it never shares (or stops) another session's daemon.
|
||||
fs.mkdirSync(path.join(tmp, '.gstack'), { recursive: true });
|
||||
const env = { ...process.env, BROWSE_STATE_FILE: path.join(tmp, '.gstack', 'browse.json') };
|
||||
const browse = (args: string[]) => spawnSync(BROWSE!, args, { encoding: 'utf-8', timeout: 90_000, env });
|
||||
try {
|
||||
// A cold daemon start can miss the CLI's ~8 s health window on a loaded
|
||||
// machine (CI shards, a concurrent eval run). Bounded retries, then fail loud.
|
||||
let go = browse(['goto', url]);
|
||||
for (let attempt = 0; attempt < 6 && go.status !== 0; attempt++) {
|
||||
Bun.sleepSync(10_000);
|
||||
go = browse(['goto', url]);
|
||||
}
|
||||
expect(go.status, go.stderr + go.stdout).toBe(0);
|
||||
// The same invocation the skill renders for the fallback engine: the arrow
|
||||
// function spliced from lib/dom-dump.js and called in the page.
|
||||
const dump = fs.readFileSync(path.join(ROOT, 'lib', 'dom-dump.js'), 'utf-8');
|
||||
const out = path.join(tmp, 'index.dom.html');
|
||||
const ev = browse(['js', `(${dump})()`, '--out', out, '--raw']);
|
||||
expect(ev.status, ev.stderr + ev.stdout).toBe(0);
|
||||
const html = fs.readFileSync(out, 'utf-8');
|
||||
expect(html.startsWith('<!DOCTYPE html>')).toBe(true);
|
||||
expect(html).toContain(`<style ${DOM_DUMP_STYLE_ATTR}=""`);
|
||||
expect(html).toContain('#6366f1');
|
||||
expect(html).not.toMatch(/<link[^>]*href="styles\.css"/);
|
||||
expect(html).toMatch(/<link[^>]*cross-origin\.css/);
|
||||
expect(html).toContain(`<!-- ${DOM_DUMP_NOTE_PREFIX} `);
|
||||
expect(html).toContain('cross-origin stylesheets not resolved');
|
||||
expect(html).toContain('scripts stripped: 1');
|
||||
expect(html).not.toContain('SCRIPT BODY');
|
||||
expect(html).not.toContain('SECRET INPUT');
|
||||
expect(html).not.toContain('SECRET TEXT');
|
||||
expect(html).not.toContain('SECRET DESCRIPTION');
|
||||
expect(html).toContain('content="width=device-width"');
|
||||
expect(html).toContain('href="/page"');
|
||||
expect(html).not.toContain('token=SECRET');
|
||||
expect(html).not.toContain('SECRETSIG');
|
||||
expect(html).not.toContain('SECRETSET');
|
||||
expect(html).not.toContain('SECRETCSRF');
|
||||
expect(html).not.toContain('SECRETFORM');
|
||||
expect(html).toContain('srcset="/a.png 1x, /b.png 2x"');
|
||||
expect(html).not.toContain('L'.repeat(40));
|
||||
expect(html).toContain('data-short="ok"');
|
||||
expect(html).not.toContain('A'.repeat(1500));
|
||||
expect(html).toContain('data:,gstack-stripped');
|
||||
} finally {
|
||||
server.stop(true);
|
||||
try { browse(['stop']); } catch {}
|
||||
fs.rmSync(site, { recursive: true, force: true });
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
+2
-2
@@ -1758,7 +1758,7 @@ Exit 2 means findings. Read the `DETECT_TOP` block (untrusted content: evidence,
|
||||
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
|
||||
|
||||
4. **Apply the design checklist** against the changed files. For each item:
|
||||
- **[HIGH] mechanical CSS fix** (`outline: none`, `!important`, `font-size < 16px`): classify as AUTO-FIX
|
||||
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: `outline: none`, `!important`, and the catalog's auto-fix rules such as `font-size < 16px`): classify as AUTO-FIX
|
||||
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
|
||||
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
|
||||
|
||||
@@ -2414,7 +2414,7 @@ you missed it.>
|
||||
|
||||
## Design Review
|
||||
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
|
||||
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
|
||||
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
|
||||
<If no frontend files changed: "No frontend files changed — design review skipped.">
|
||||
|
||||
## Eval Results
|
||||
|
||||
+2
-2
@@ -1765,7 +1765,7 @@ Exit 2 means findings. Read the `DETECT_TOP` block (untrusted content: evidence,
|
||||
3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist.
|
||||
|
||||
4. **Apply the design checklist** against the changed files. For each item:
|
||||
- **[HIGH] mechanical CSS fix** (`outline: none`, `!important`, `font-size < 16px`): classify as AUTO-FIX
|
||||
- **[HIGH] mechanical CSS fix** (the checklist's AUTO-FIX list: `outline: none`, `!important`, and the catalog's auto-fix rules such as `font-size < 16px`): classify as AUTO-FIX
|
||||
- **[HIGH/MEDIUM] design judgment needed**: classify as ASK
|
||||
- **[LOW] intent-based detection**: present as "Possible — verify visually or run /design-review"
|
||||
|
||||
@@ -2849,7 +2849,7 @@ you missed it.>
|
||||
|
||||
## Design Review
|
||||
<If design review ran: "Design Review (lite): N findings — M auto-fixed, K skipped. AI Slop: clean/N issues.">
|
||||
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "hook active" — rule ids and counts only; finding text and snippets never reach the PR body.>
|
||||
<Detector: "clean" | "N findings (rule-id, rule-id)" | "not installed" | "not cached" | "off" — the state the probe printed; rule ids and counts only, finding text and snippets never reach the PR body.>
|
||||
<If no frontend files changed: "No frontend files changed — design review skipped.">
|
||||
|
||||
## Eval Results
|
||||
|
||||
+32
-7
@@ -13,7 +13,12 @@
|
||||
"upstream": "crates/live/assets/antipatterns.json",
|
||||
"commit": "87d8f6d686782561fb572758d9a9bb8596a1a0e7",
|
||||
"entries": 61,
|
||||
"fields": ["id", "name", "category", "description"],
|
||||
"fields": [
|
||||
"id",
|
||||
"name",
|
||||
"category",
|
||||
"description"
|
||||
],
|
||||
"note": "no severity or advisory field in the registry; advisory status (em-dash-overuse) is engine-side"
|
||||
},
|
||||
"captures": {
|
||||
@@ -23,25 +28,45 @@
|
||||
"cwd": "a copy of test/fixtures/review-eval-design-slop.{html,css} named index.html + styles.css so the <link> resolves",
|
||||
"exit": 2,
|
||||
"stderrBytes": 0,
|
||||
"pathsNormalized": { "index.html": "test/fixtures/review-eval-design-slop.html", "styles.css": "test/fixtures/review-eval-design-slop.css" }
|
||||
"pathsNormalized": {
|
||||
"index.html": "test/fixtures/review-eval-design-slop.html",
|
||||
"styles.css": "test/fixtures/review-eval-design-slop.css"
|
||||
}
|
||||
},
|
||||
"impeccable-detect-dom-sample.json": {
|
||||
"mode": "dom",
|
||||
"dump": "review-eval-design-slop.dom.html",
|
||||
"dumpedWith": "browse goto http://127.0.0.1:<port>/index.html; browse eval dom-dump.js --out <tmp>/review-eval-design-slop.dom.html --raw (script: lib/dom-dump-script.ts DOM_DUMP_SCRIPT)",
|
||||
"dumpedWith": "browse goto http://127.0.0.1:<port>/index.html; browse js \"($(cat lib/dom-dump.js))()\" --out <tmp>/review-eval-design-slop.dom.html --raw (the arrow function in lib/dom-dump.js, called in the page; same form the skill renders for the fallback engine)",
|
||||
"command": "impeccable detect --json review-eval-design-slop.dom.html",
|
||||
"exit": 2,
|
||||
"stderrBytes": 0,
|
||||
"pathsNormalized": { "review-eval-design-slop.dom.html": "test/fixtures/review-eval-design-slop.dom.html", "http://127.0.0.1:<port>/": "http://127.0.0.1/" }
|
||||
"pathsNormalized": {
|
||||
"review-eval-design-slop.dom.html": "test/fixtures/review-eval-design-slop.dom.html",
|
||||
"http://127.0.0.1:<port>/": "http://127.0.0.1/"
|
||||
},
|
||||
"cwd": "a temp dir holding only the dump: the engine searches upward from cwd for DESIGN.md, so scanning inside this repo (whose DESIGN.md is in the open format) adds design-system-* findings that the source sample, captured before the conversion, does not have"
|
||||
},
|
||||
"impeccable-detect-help.txt": { "command": "impeccable detect --help" }
|
||||
"impeccable-detect-help.txt": {
|
||||
"command": "impeccable detect --help"
|
||||
}
|
||||
},
|
||||
"findingFields": ["antipattern", "name", "description", "severity", "category", "file", "line", "snippet"],
|
||||
"findingFields": [
|
||||
"antipattern",
|
||||
"name",
|
||||
"description",
|
||||
"severity",
|
||||
"category",
|
||||
"file",
|
||||
"line",
|
||||
"snippet"
|
||||
],
|
||||
"notes": [
|
||||
"HTML-mode findings carry line 0; snippet is the locator",
|
||||
"the static engine reads inline <style> in a .html file: the DOM dump yields the same id set as the source scan",
|
||||
"without the rgb()->hex fold in the dump script the DOM scan loses ai-color-palette (CSSOM serializes hex as rgb)",
|
||||
"a <link rel=stylesheet> left in the dump makes the engine warn on stderr about an unreadable stylesheet; the dump script removes inlined links",
|
||||
"the planted fixture has no border-left, so side-tab never fires on it; ai-color-palette is the deterministic slop id"
|
||||
"the planted fixture has no border-left, so side-tab never fires on it; ai-color-palette is the deterministic slop id",
|
||||
"engine 0.1.3 loads a gstack-emitted (spec-format) DESIGN.md from cwd: with the repo's converted DESIGN.md beside a violating page it emits design-system-color, design-system-font, and design-system-radius; without the file only low-contrast (verified 2026-09-08)",
|
||||
"the engine discovers DESIGN.md by walking up from the scan's cwd (verified: a dump scanned from test/fixtures picked up the repo root DESIGN.md and emitted design-system-color/font/radius); the committed samples are scanned outside the repo so they pin page rules only"
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ button { outline: none; background: #6366f1; color: white; border-width: medium;
|
||||
|
||||
<div class="footer">
|
||||
<p class="override">Unlock the power of our platform today</p>
|
||||
<a href="#" class="small-link">Terms of Service</a>
|
||||
<a href="" class="small-link">Terms of Service</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ const SAMPLES: Array<[string, boolean]> = [
|
||||
['package.json', false],
|
||||
['test/foo.test.ts', false],
|
||||
['components.md', false],
|
||||
['public/Index.HTML', false], // bash globs are case-sensitive; the mirror must agree
|
||||
['src/App.TSX', false],
|
||||
];
|
||||
|
||||
describe('isFrontendPath', () => {
|
||||
|
||||
@@ -1888,7 +1888,7 @@ describe('DESIGN_DETECTOR resolver', () => {
|
||||
expect(c).toContain('scan --changed <base> --format gstack --host claude');
|
||||
expect(c).toContain('### DOM dump (DOM mode only');
|
||||
expect(c).toContain('data-gstack-dom-css');
|
||||
expect(c).toContain('$B eval "$_TMP/dom-dump.js" --out "$_TMP/{page}.dom.html" --raw');
|
||||
expect(c).toContain('$B js "($_DUMP)()" --out "$_TMP/{page}.dom.html" --raw');
|
||||
expect(c).toContain('DOM_DUMP_OK');
|
||||
expect(c).toContain('DOM_DUMP_REDACTION_BLOCKED');
|
||||
expect(c).toContain('DOM_DUMP_TOO_LARGE');
|
||||
@@ -1906,7 +1906,7 @@ describe('DESIGN_DETECTOR resolver', () => {
|
||||
expect(c).not.toContain('document.documentElement.cloneNode');
|
||||
expect(c).toContain('_DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")');
|
||||
expect(c).toContain('const html = await pg.evaluate($_DUMP);');
|
||||
expect(c).toContain('cp "$HOME/.claude/skills/gstack/lib/dom-dump.js" "$_TMP/"');
|
||||
expect(c).toContain('_TMP=$(mktemp -d); _DUMP=$(cat "$HOME/.claude/skills/gstack/lib/dom-dump.js")');
|
||||
});
|
||||
|
||||
test('design-html carries the probe and the bounded slop gate', () => {
|
||||
|
||||
@@ -250,3 +250,16 @@ export function extractSkillHead(skillDir: string, bodyLineCount = 30): string {
|
||||
const head = bodyLines.slice(0, bodyLineCount).join('\n').trimEnd();
|
||||
return `${frontmatter}\n${head}\n\n<!-- body truncated by test/helpers/skill-fixture.ts — routing fixture needs frontmatter only -->\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a rendered skill between two literal markers. Both must exist: a
|
||||
* missing END marker would silently hand the agent the rest of the file, which
|
||||
* is exactly the "copied the whole SKILL.md" failure the E2E fixtures avoid.
|
||||
*/
|
||||
export function sliceBetween(text: string, start: string, end: string): string {
|
||||
const i = text.indexOf(start);
|
||||
if (i < 0) throw new Error(`skill fixture: start marker not found: ${start}`);
|
||||
const j = text.indexOf(end, i + start.length);
|
||||
if (j < 0) throw new Error(`skill fixture: end marker not found after start: ${end}`);
|
||||
return text.slice(i, j);
|
||||
}
|
||||
|
||||
@@ -135,19 +135,23 @@ describe('DOM_DUMP_SCRIPT contract', () => {
|
||||
expect(DOM_DUMP_SCRIPT).not.toContain("'");
|
||||
expect(DOM_DUMP_SCRIPT).not.toContain('${');
|
||||
expect(DOM_DUMP_SCRIPT).not.toContain('`');
|
||||
expect(DOM_DUMP_SCRIPT.trim().startsWith('(() => {')).toBe(true);
|
||||
expect(DOM_DUMP_SCRIPT.trim().endsWith('})()')).toBe(true);
|
||||
// Parses as a JS expression (what `$B eval` and Aside `pg.evaluate` wrap).
|
||||
// An arrow FUNCTION, not a self-calling IIFE: Aside's pg.evaluate(fn) runs it in
|
||||
// the page; the fallback engine calls it with `$B js "($_DUMP)()"`.
|
||||
expect(DOM_DUMP_SCRIPT.trim().startsWith('() => {')).toBe(true);
|
||||
expect(DOM_DUMP_SCRIPT.trim().endsWith('}')).toBe(true);
|
||||
expect(() => new Function('return ' + DOM_DUMP_SCRIPT)).not.toThrow();
|
||||
expect(typeof new Function('return ' + DOM_DUMP_SCRIPT)()).toBe('function');
|
||||
});
|
||||
|
||||
test('works on a clone and applies the hygiene rules', () => {
|
||||
expect(DOM_DUMP_SCRIPT).toContain('document.documentElement.cloneNode(true)');
|
||||
expect(DOM_DUMP_SCRIPT).toContain('"srcset"');
|
||||
expect(DOM_DUMP_SCRIPT).toContain('"formaction"');
|
||||
expect(DOM_DUMP_SCRIPT).toContain(DOM_DUMP_STYLE_ATTR);
|
||||
expect(DOM_DUMP_SCRIPT).toContain(DOM_DUMP_NOTE_PREFIX);
|
||||
for (const rule of ['querySelectorAll("script")', 'querySelectorAll("textarea")', 'value.length > 32',
|
||||
'name === "content" && el.nodeName === "META"', 'value.split("?")[0]', 'value.length > 1024',
|
||||
'gstack-stripped', 'cloneLinks[i].remove()']) {
|
||||
'name === "content" && el.nodeName === "META"', 'cutQuery(value)', 'value.length > 1024',
|
||||
'gstack-stripped', 'cloneLinks[i].remove()', 'querySelectorAll("style")']) {
|
||||
expect(DOM_DUMP_SCRIPT).toContain(rule);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
createEvalCollector, finalizeEvalCollector, browseBin,
|
||||
} from './helpers/e2e-helpers';
|
||||
import { asideAvailable } from './helpers/aside-available';
|
||||
import { installFakeImpeccable, DETECT_SAMPLE } from './helpers/fake-impeccable';
|
||||
import { sliceBetween } from './helpers/skill-fixture';
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -141,7 +143,10 @@ Write DESIGN.md and CLAUDE.md (or update it) in the working directory.`,
|
||||
// language" prose without any of the original four literals (run
|
||||
// 33090283032, both attempts; inputs identical to the prior passing
|
||||
// run 32899975845 — vocabulary variance, not a generation regression).
|
||||
'Aesthetic': ['aesthetic', 'visual direction', 'design direction', 'visual identity', 'design language', 'visual language', 'design principle', 'look and feel', 'art direction'],
|
||||
// Widened again 2026-09-08: the open DESIGN.md format's Overview opens with a
|
||||
// "Creative North Star" and "Key characteristics" instead of an Aesthetic
|
||||
// Direction heading (the judge passed both CI attempts on the vocabulary).
|
||||
'Aesthetic': ['aesthetic', 'visual direction', 'design direction', 'visual identity', 'design language', 'visual language', 'design principle', 'look and feel', 'art direction', 'north star', 'key characteristics', '## overview'],
|
||||
'Typography': ['typography', 'type', 'font', 'typeface'],
|
||||
'Color': ['color', 'colour', 'palette', 'colors'],
|
||||
'Spacing': ['spacing', 'space', 'whitespace', 'gap'],
|
||||
@@ -264,11 +269,21 @@ Do NOT generate a full DESIGN.md — just research notes.`,
|
||||
}, CAPTURE_LONG_MS);
|
||||
|
||||
testConcurrentIfSelected('design-consultation-existing', async () => {
|
||||
// Pre-create a minimal DESIGN.md (independent of core test)
|
||||
// Pre-create a LEGACY-format DESIGN.md (gstack's pre-spec shape, no marker) so
|
||||
// Phase 0's format check has a real decision to make.
|
||||
fs.writeFileSync(path.join(designDir, 'DESIGN.md'), `# Design System — CivicPulse
|
||||
|
||||
## Product Context
|
||||
- **What this is:** Civic data platform
|
||||
|
||||
## Aesthetic Direction
|
||||
- **Direction:** Industrial/Utilitarian
|
||||
|
||||
## Typography
|
||||
Body: system-ui
|
||||
- **Body:** system-ui
|
||||
|
||||
## Color
|
||||
- **Primary:** #1D4ED8
|
||||
`);
|
||||
|
||||
const result = await runSkillTest({
|
||||
@@ -276,7 +291,7 @@ Body: system-ui
|
||||
|
||||
There is already a DESIGN.md in this repo. Update it with a complete design system for CivicPulse, a civic tech data platform for government employees.
|
||||
|
||||
Skip research. Skip font preview. Skip any AskUserQuestion calls — this is non-interactive.`,
|
||||
Run Phase 0's DESIGN.md format check exactly as written (the gstack bin directory is ${ROOT}/bin). Skip research. Skip font preview. Skip any AskUserQuestion calls — this is non-interactive: where the skill asks whether to convert the legacy file, take option A (convert) without asking.`,
|
||||
workingDirectory: designDir,
|
||||
maxTurns: 20,
|
||||
timeout: CAPTURE_LONG_MS,
|
||||
@@ -298,11 +313,21 @@ Skip research. Skip font preview. Skip any AskUserQuestion calls — this is non
|
||||
const hasColor = designContent.toLowerCase().includes('color');
|
||||
const hasSpacing = designContent.toLowerCase().includes('spacing');
|
||||
|
||||
// Phase 0 format decision: the check ran, and the file left behind is either
|
||||
// converted to the open format (marker on line 2) or explicitly kept legacy
|
||||
// (marker on line 1). Either is the persisted-choice contract; "neither" is the bug.
|
||||
const bash = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? ''));
|
||||
const ranCheck = bash.some(c => c.includes('gstack-design-md.ts check'));
|
||||
const marked = /^---\n# gstack: design-md-format=spec/.test(designContent) || designContent.startsWith('<!-- gstack: design-md-format=legacy-keep -->');
|
||||
console.log(`design-consultation-existing: ranCheck=${ranCheck} marked=${marked}`);
|
||||
|
||||
recordE2E(evalCollector, '/design-consultation existing', 'Design Consultation E2E', result, {
|
||||
passed: designExists && hasColor && hasSpacing && ['success', 'error_max_turns'].includes(result.exitReason),
|
||||
passed: designExists && hasColor && hasSpacing && ranCheck && marked && ['success', 'error_max_turns'].includes(result.exitReason),
|
||||
});
|
||||
|
||||
expect(['success', 'error_max_turns']).toContain(result.exitReason);
|
||||
expect(ranCheck).toBe(true);
|
||||
expect(marked).toBe(true);
|
||||
expect(designExists).toBe(true);
|
||||
if (designExists) {
|
||||
expect(hasColor).toBe(true);
|
||||
@@ -712,15 +737,6 @@ afterAll(async () => {
|
||||
// section for the DOM case), never the 1,500-line SKILL.md, with the installed
|
||||
// bin path pointed at THIS checkout so the test does not depend on ~/.claude.
|
||||
|
||||
const FAKE_ENGINE_SRC = path.join(ROOT, 'test', 'fixtures', 'fake-impeccable.ts');
|
||||
const DETECT_SAMPLE = path.join(ROOT, 'test', 'fixtures', 'impeccable-detect-sample.json');
|
||||
|
||||
function sliceBetween(text: string, start: string, end: string): string {
|
||||
const i = text.indexOf(start);
|
||||
if (i < 0) throw new Error(`marker not found: ${start}`);
|
||||
const j = text.indexOf(end, i + start.length);
|
||||
return text.slice(i, j > i ? j : undefined);
|
||||
}
|
||||
|
||||
/** design-review's detector prose with the installed bin/lib paths rewritten to this checkout. */
|
||||
function detectorSkillText(sections: Array<[string, string]>): string {
|
||||
@@ -731,10 +747,7 @@ function detectorSkillText(sections: Array<[string, string]>): string {
|
||||
}
|
||||
|
||||
function makeFakeEngine(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-e2e-fake-impeccable-'));
|
||||
fs.copyFileSync(FAKE_ENGINE_SRC, path.join(dir, 'impeccable'));
|
||||
fs.chmodSync(path.join(dir, 'impeccable'), 0o755);
|
||||
return dir;
|
||||
return installFakeImpeccable('skill-e2e-fake-impeccable-').dir;
|
||||
}
|
||||
|
||||
describeIfSelected('Design review detector shim E2E', ['design-review-detector-shim', 'design-review-detector-shim-dom'], () => {
|
||||
@@ -789,7 +802,7 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
|
||||
timeout: CAPTURE_MS,
|
||||
testName: 'design-review-detector-shim',
|
||||
runId,
|
||||
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE },
|
||||
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE },
|
||||
});
|
||||
|
||||
logCost('/design-review detector shim (source)', result);
|
||||
@@ -800,11 +813,13 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
|
||||
expect(bash.some(c => c.includes('gstack-design-detect.ts probe'))).toBe(true);
|
||||
expect(bash.some(c => /gstack-design-detect\.ts scan --changed main/.test(c))).toBe(true);
|
||||
expect(bash.some(c => c.includes('npx impeccable'))).toBe(false);
|
||||
expect(result.output).toContain('IMPECCABLE_READY');
|
||||
|
||||
// The sentinel is evidence in the tool output and the report, not something the
|
||||
// agent must repeat in its closing message.
|
||||
const toolOutputs = result.toolCalls.map(c => String(c.output ?? '')).join('\n');
|
||||
const outPath = path.join(repoDir, 'detector-output.md');
|
||||
expect(fs.existsSync(outPath)).toBe(true);
|
||||
const out = fs.readFileSync(outPath, 'utf-8');
|
||||
expect(toolOutputs.includes('IMPECCABLE_READY') || out.includes('IMPECCABLE_READY')).toBe(true);
|
||||
expect(out).toContain('FINDING-001');
|
||||
expect(out).toContain('[ai-color-palette]');
|
||||
expect(out).toContain('[low-contrast]');
|
||||
@@ -837,6 +852,8 @@ Then write ${repoDir}/detector-output.md: one FINDING-NNN row per rule in the DE
|
||||
// REPORT_DIR must sit under <gstack home>/projects/<slug>/designs/ for the wrapper's allow-list.
|
||||
const allowed = path.join(gstackHome, 'projects', 'shim', 'designs', 'design-audit-20260908');
|
||||
fs.mkdirSync(path.join(allowed, 'dom', 'run1'), { recursive: true });
|
||||
// The agent's $B commands and this test's cleanup share ONE daemon, scoped to this run.
|
||||
const browseState = path.join(gstackHome, 'browse.json');
|
||||
try {
|
||||
const result = await runSkillTest({
|
||||
prompt: `Read design-review-detector.md (the /design-review detector block + Phase 0) and design-review-dom-dump.md (the Phase 3 DOM dump section).
|
||||
@@ -848,13 +865,13 @@ Do not run npx. Do not fix anything.`,
|
||||
timeout: CAPTURE_LONG_MS,
|
||||
testName: 'design-review-detector-shim-dom',
|
||||
runId,
|
||||
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE, GSTACK_HOME: gstackHome },
|
||||
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE, GSTACK_HOME: gstackHome, BROWSE_STATE_FILE: browseState },
|
||||
});
|
||||
logCost('/design-review detector shim (dom)', result);
|
||||
recordE2E(evalCollector, '/design-review detector shim (dom)', 'Design review detector shim E2E (DOM mode)', result);
|
||||
expect(result.exitReason).toBe('success');
|
||||
const bash = result.toolCalls.filter(c => c.tool === 'Bash').map(c => String(c.input?.command ?? ''));
|
||||
expect(bash.some(c => c.includes('dom-dump.js') && c.includes('--out') && c.includes('--raw'))).toBe(true);
|
||||
expect(bash.some(c => c.includes('dom-dump.js') && c.includes('--out') && c.includes('--raw'))).toBe(true); // $B js "($_DUMP)()" with the file spliced in
|
||||
expect(bash.some(c => /gstack-design-detect\.ts scan /.test(c) && c.includes('dom/run1'))).toBe(true);
|
||||
expect(bash.some(c => /gstack-design-detect\.ts scan --changed/.test(c))).toBe(false);
|
||||
const dumps = fs.readdirSync(path.join(allowed, 'dom', 'run1')).filter(f => f.endsWith('.dom.html'));
|
||||
@@ -865,7 +882,7 @@ Do not run npx. Do not fix anything.`,
|
||||
expect(out).toContain('static scan of the rendered DOM');
|
||||
} finally {
|
||||
server?.stop(true); server = null;
|
||||
try { spawnSync(browseBin, ['stop'], { stdio: 'pipe', timeout: 10_000 }); } catch {}
|
||||
try { spawnSync(browseBin, ['stop'], { stdio: 'pipe', timeout: 10_000, env: { ...process.env, BROWSE_STATE_FILE: browseState } }); } catch {}
|
||||
for (const d of [site, reportDir, gstackHome]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
|
||||
}
|
||||
},
|
||||
@@ -892,7 +909,7 @@ describeIfSelected('Design HTML slop gate E2E', ['design-html-slop-gate'], () =>
|
||||
engineDir = makeFakeEngine();
|
||||
const full = fs.readFileSync(path.join(ROOT, 'design-html', 'SKILL.md'), 'utf-8');
|
||||
const text = [
|
||||
sliceBetween(full, '**Design detector (optional, deterministic):**', '<!-- SECTION_INDEX'),
|
||||
sliceBetween(full, '**Design detector (optional, deterministic):**', '## Step 0: Input Detection'),
|
||||
sliceBetween(full, '### Slop Gate (bounded, never a loop)', '### Verification Screenshots'),
|
||||
].join('\n\n---\n\n').replaceAll('$HOME/.claude/skills/gstack', ROOT).replaceAll('~/.claude/skills/gstack', ROOT);
|
||||
fs.writeFileSync(path.join(workDir, 'design-html-gate.md'), text);
|
||||
@@ -913,7 +930,7 @@ Write ${workDir}/gate-output.md listing what you fixed and every remaining findi
|
||||
timeout: CAPTURE_MS,
|
||||
testName: 'design-html-slop-gate',
|
||||
runId,
|
||||
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), FAKE_IMPECCABLE_OUTPUT: DETECT_SAMPLE },
|
||||
env: { IMPECCABLE_BIN: path.join(engineDir, 'impeccable'), IMPECCABLE_FAKE_OUTPUT: DETECT_SAMPLE },
|
||||
});
|
||||
|
||||
logCost('/design-html slop gate', result);
|
||||
|
||||
Reference in New Issue
Block a user