mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 10:36:27 +03:00
Merge remote-tracking branch 'upstream/main' into fix/detect-system-chrome-gpu-window
# Conflicts: # scripts/test-suites.mjs
This commit is contained in:
@@ -386,3 +386,83 @@ describe('bundled skill scripts are self-contained', () => {
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('degraded-mode fallback reference generation', () => {
|
||||
const ROOT = process.cwd();
|
||||
const DEGRADED_TEST_DIR = path.join(ROOT, 'test-tmp-degraded');
|
||||
const DIST = path.join(DEGRADED_TEST_DIR, 'dist');
|
||||
|
||||
const readDegraded = (provider, configDir, role) =>
|
||||
fs.readFileSync(
|
||||
path.join(DIST, provider, configDir, 'skills', 'impeccable', 'reference', 'degraded', `${role}.md`),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(DEGRADED_TEST_DIR)) fs.rmSync(DEGRADED_TEST_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(DEGRADED_TEST_DIR, { recursive: true });
|
||||
const { skills } = utils.readSourceFiles(ROOT);
|
||||
transformers.transformClaudeCode(skills, DIST);
|
||||
transformers.transformCodex(skills, DIST);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(DEGRADED_TEST_DIR)) fs.rmSync(DEGRADED_TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a build emits reference/degraded/<role>.md for every agent, prefix-stripped', () => {
|
||||
const dir = path.join(DIST, 'codex', '.codex', 'skills', 'impeccable', 'reference', 'degraded');
|
||||
const files = fs.readdirSync(dir).sort();
|
||||
expect(files).toEqual([
|
||||
'asset-producer.md',
|
||||
'documenter.md',
|
||||
'finish-reviewer.md',
|
||||
'manual-edit-applier.md',
|
||||
]);
|
||||
});
|
||||
|
||||
test('finish-reviewer fallback opens with the preamble and carries a distinctive body phrase', () => {
|
||||
const content = readDegraded('codex', '.codex', 'finish-reviewer');
|
||||
expect(content.startsWith('<!-- Generated from skill/agents/ at build time. Do not edit; edit the agent definition. -->'))
|
||||
.toBe(true);
|
||||
expect(content).toContain('This harness has no subagent capability, so you are running this role inline.');
|
||||
// Distinctive phrase from the agent body proves the source body was inlined.
|
||||
expect(content).toContain('material_fixes');
|
||||
});
|
||||
|
||||
test('generated fallbacks pass through provider-block compilation (codex keeps its block, others strip it)', () => {
|
||||
// Standalone provider blocks are the shape compileProviderBlocks compiles.
|
||||
// A synthetic agent proves the degraded path runs the same compilation as
|
||||
// ordinary reference files, with the right provider tags per target.
|
||||
const synthetic = {
|
||||
name: 'impeccable',
|
||||
description: 'synthetic',
|
||||
body: 'Synthetic skill body.',
|
||||
agents: [
|
||||
{
|
||||
name: 'impeccable-synthetic',
|
||||
body: 'Shared body line.\n\n<codex>\nCODEX_ONLY_MARKER for the codex target.\n</codex>\n\nMore shared body.',
|
||||
},
|
||||
],
|
||||
};
|
||||
const synthDist = path.join(DEGRADED_TEST_DIR, 'synth');
|
||||
transformers.transformCodex([synthetic], synthDist);
|
||||
transformers.transformClaudeCode([synthetic], synthDist);
|
||||
const read = (provider, configDir) =>
|
||||
fs.readFileSync(
|
||||
path.join(synthDist, provider, configDir, 'skills', 'impeccable', 'reference', 'degraded', 'synthetic.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const codex = read('codex', '.codex');
|
||||
const claude = read('claude-code', '.claude');
|
||||
expect(codex).toContain('CODEX_ONLY_MARKER');
|
||||
expect(claude).not.toContain('CODEX_ONLY_MARKER');
|
||||
// Both still carry the preamble and the shared body.
|
||||
expect(codex.startsWith('<!-- Generated from skill/agents/')).toBe(true);
|
||||
expect(claude).toContain('More shared body.');
|
||||
});
|
||||
|
||||
test('the source repo contains no hand-authored degraded/ reference files (generation-only)', () => {
|
||||
expect(fs.existsSync(path.join(ROOT, 'skill', 'reference', 'degraded'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,6 +135,56 @@ describe('gatherSignals', () => {
|
||||
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // README.md filtered out
|
||||
});
|
||||
|
||||
it('filters harness-dir files out of git-changes scan targets (#303)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
write('src/Hero.tsx', 'export const Hero = () => 2;\n'); // dirty app code
|
||||
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // dirty vendored skill
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.scan.via, 'git-changes');
|
||||
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // harness path filtered out
|
||||
});
|
||||
|
||||
it('keeps hidden-source-dir files (VitePress/Storybook) in scan targets', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('.vitepress/theme/Layout.vue', '<template><div/></template>\n');
|
||||
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
write('.vitepress/theme/Layout.vue', '<template><span/></template>\n'); // real UI source
|
||||
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // vendored
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.scan.via, 'git-changes');
|
||||
assert.deepEqual(s.scan.targets, ['.vitepress/theme/Layout.vue']);
|
||||
});
|
||||
|
||||
it('falls through to source dirs when only harness files changed (#303)', async () => {
|
||||
const { execFileSync } = await import('node:child_process');
|
||||
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
||||
git('init', '-q');
|
||||
git('config', 'user.email', 't@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
||||
write('.cursor/skills/impeccable/example.css', 'a{}\n');
|
||||
git('add', '.');
|
||||
git('commit', '-qm', 'init');
|
||||
write('.cursor/skills/impeccable/example.css', 'a{color:red}\n'); // only harness dirty
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.equal(s.scan.via, 'source-dir');
|
||||
assert.deepEqual(s.scan.targets, ['src']);
|
||||
});
|
||||
|
||||
it('has empty scan.targets only when there is no code at all', async () => {
|
||||
const s = await gatherSignals(scratch);
|
||||
assert.deepEqual(s.scan.targets, []);
|
||||
|
||||
@@ -112,6 +112,15 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shadowed form.id: a <form> with <input name="id"> does not crash the scan (issue #407)', async () => {
|
||||
// HTMLFormElement named-property shadowing makes form.id / form.className
|
||||
// return the child input element, whose .startsWith throws. Every Shopify
|
||||
// product form ships <input name="id">, so this crashed the URL scan. The
|
||||
// scan must complete and return an array of findings instead of throwing.
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/shadowed-form-id.html`);
|
||||
assert.ok(Array.isArray(f), 'detectUrl must return findings without throwing on a shadowed form.id');
|
||||
});
|
||||
|
||||
it('line-length: flag column triggers, pass column adds none', async () => {
|
||||
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
|
||||
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
|
||||
|
||||
@@ -12,8 +12,10 @@ import { fileURLToPath } from 'url';
|
||||
import {
|
||||
detectHtml,
|
||||
detectText,
|
||||
formatFindings,
|
||||
normalizeDesignSystem,
|
||||
} from '../cli/engine/detect-antipatterns.mjs';
|
||||
import { checkEmDashOveruse } from '../cli/engine/rules/checks.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
|
||||
@@ -84,6 +86,74 @@ describe('detectText - Astro structural CSS fixtures', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectText — pseudo-element stripe fixtures (issue #394)', () => {
|
||||
// The side-tab silhouette drawn as an absolutely-positioned ::before/::after
|
||||
// bar instead of a border. The scanner already ran on full HTML pages via
|
||||
// checkHtmlPatterns; these pin the standalone-stylesheet and component
|
||||
// style-block paths, which used to pass this construction clean.
|
||||
const SHOULD_FLAG = [
|
||||
'Inset Shorthand Left Edge',
|
||||
'Longhand Left Edge',
|
||||
'Bottom Edge',
|
||||
'Full Height Right Edge',
|
||||
];
|
||||
const SHOULD_PASS = [
|
||||
'Neutral Divider',
|
||||
'Wide Panel',
|
||||
'Static Underline',
|
||||
'Hairline Divider',
|
||||
'Hover Underline',
|
||||
'Floating Badge',
|
||||
// Commented-out CSS is not a live rule.
|
||||
'Commented Out Stripe',
|
||||
];
|
||||
|
||||
// The 1-based line a case's selector sits on in a fixture file, so the
|
||||
// reported finding line can be checked against the actual source.
|
||||
const selectorLine = (source, caseName) => {
|
||||
const idx = source.split('\n').findIndex(l => l.includes(`data-case="${caseName}"`));
|
||||
assert.notEqual(idx, -1, `fixture is missing case "${caseName}"`);
|
||||
return idx + 1;
|
||||
};
|
||||
|
||||
it('standalone .css files flag chromatic pseudo-element stripes only', () => {
|
||||
const filePath = path.join(FIXTURES, 'pseudo-stripe.css');
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab');
|
||||
const snippets = findings.map(r => r.snippet || '').join(' | ');
|
||||
for (const heading of SHOULD_FLAG) {
|
||||
assert.match(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `expected "${heading}" to flag`);
|
||||
}
|
||||
for (const heading of SHOULD_PASS) {
|
||||
assert.doesNotMatch(snippets, new RegExp(`data-case=${JSON.stringify(heading)}`), `"${heading}" should pass`);
|
||||
}
|
||||
// Every finding must carry the selector's real source line, so
|
||||
// line-scoped inline ignores (impeccable-disable-line and
|
||||
// impeccable-disable-next-line) can match it.
|
||||
for (const f of findings) {
|
||||
const caseName = (f.snippet.match(/data-case="([^"]+)"/) || [])[1];
|
||||
assert.equal(
|
||||
f.line, selectorLine(source, caseName),
|
||||
`finding for "${caseName}" reports line ${f.line}, selector sits on line ${selectorLine(source, caseName)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('component style blocks flag pseudo-element stripes at their source line', () => {
|
||||
const filePath = path.join(FIXTURES, 'pseudo-stripe.vue');
|
||||
const source = fs.readFileSync(filePath, 'utf8');
|
||||
const findings = detectText(source, filePath).filter(r => r.antipattern === 'side-tab');
|
||||
const snippets = findings.map(r => r.snippet || '').join(' | ');
|
||||
assert.match(snippets, /data-case="Component Left Edge"/, 'expected the component stripe to flag');
|
||||
assert.doesNotMatch(snippets, /data-case="Component Neutral Divider"/, 'neutral divider should pass');
|
||||
const stripe = findings.find(r => /data-case="Component Left Edge"/.test(r.snippet || ''));
|
||||
assert.equal(
|
||||
stripe.line, selectorLine(source, 'Component Left Edge'),
|
||||
'style-block finding must map back to the whole-file line, not the block-local one',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
it('should-flag: catches border anti-patterns', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
|
||||
@@ -279,6 +349,43 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('color: gradient-clipped text is not contrast-checked against its own fill (issue #409 Case A)', async () => {
|
||||
// background-clip: text with a transparent fill paints the glyphs with the
|
||||
// gradient; the inherited `color` is never painted, so measuring it against
|
||||
// the element's own gradient stops (#6d8cff / #a78bfa) is a false positive.
|
||||
// The gradient-text pattern flag still fires; the backdrop-contrast rules
|
||||
// (low-contrast / gray-on-color) must stay silent for the clipped element.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
const clippedContrastFP = f.filter(r =>
|
||||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||||
/#6d8cff|#a78bfa/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(
|
||||
clippedContrastFP.length, 0,
|
||||
`gradient-clipped text must not be contrast-checked against its own fill, got: ${clippedContrastFP.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
|
||||
);
|
||||
// The pattern itself must still be surfaced.
|
||||
assert.ok(
|
||||
f.some(r => r.antipattern === 'gradient-text'),
|
||||
'gradient-text pattern flag must still fire'
|
||||
);
|
||||
});
|
||||
|
||||
it('color: alpha gradient-glow stops composite against the surface beneath (issue #409 Case B)', async () => {
|
||||
// A 9%-alpha teal glow stop (rgba(52,192,168,0.09)) over a dark section
|
||||
// composites to ~near-black, not the full-opacity #34c0a8. Text on it is
|
||||
// high-contrast; treating the stop as opaque flagged every text child.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'color.html'));
|
||||
const glowFP = f.filter(r =>
|
||||
(r.antipattern === 'low-contrast' || r.antipattern === 'gray-on-color') &&
|
||||
/#34c0a8/i.test(r.snippet || '')
|
||||
);
|
||||
assert.equal(
|
||||
glowFP.length, 0,
|
||||
`alpha glow stops must composite against the underlying surface, got: ${glowFP.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('legitimate-borders: zero findings', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
|
||||
assert.equal(f.length, 0, `expected no findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`);
|
||||
@@ -327,6 +434,42 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('named-color-borders: named-color side-tabs are flagged, neutral names pass', async () => {
|
||||
// Regression for issue #359: the static cascade's shorthand color
|
||||
// extraction recognized only 9 named colors, so `border-left: 4px solid
|
||||
// purple` (or any of the other named colors parseAnyColor understands)
|
||||
// lost its color during expansion, defaulted to neutral black, and never
|
||||
// fired side-tab — while the same declaration in a .css file was flagged
|
||||
// by the regex engine. The extraction list is now derived from the same
|
||||
// CSS_NAMED_COLORS table the parser uses, so the two can't drift apart.
|
||||
const f = await detectHtml(path.join(FIXTURES, 'named-color-borders.html'));
|
||||
const sideTabs = f.filter(r => r.antipattern === 'side-tab').map(r => r.snippet).sort();
|
||||
// Six FLAG cases, each with a unique width/radius signature so every
|
||||
// finding attributes to exactly one case (an offsetting miss + false
|
||||
// positive can't cancel out in an aggregate count):
|
||||
// purple 4px + radius 8 (the issue reproducer), rebeccapurple 5px +
|
||||
// radius 4 (contains "purple" as a substring — whole-token matching),
|
||||
// crimson 4px top stripe, bare 3px teal, var() resolving to a named
|
||||
// color at 6px + radius 4, and a 7px inline style attribute.
|
||||
// The PASS column (neutral named colors at 3-4px, 1px thin, uniform)
|
||||
// must contribute nothing — dimgray/gainsboro/black have to parse AND
|
||||
// read as neutral rather than being dropped as unknown colors, and none
|
||||
// of its shapes can produce any of the signatures below.
|
||||
assert.deepEqual(sideTabs, [
|
||||
'border-left: 3px',
|
||||
'border-left: 4px + border-radius: 8px',
|
||||
'border-left: 5px + border-radius: 4px',
|
||||
'border-left: 6px + border-radius: 4px',
|
||||
'border-left: 7px',
|
||||
'border-top: 4px',
|
||||
]);
|
||||
const borderAccent = f.filter(r => r.antipattern === 'border-accent-on-rounded');
|
||||
assert.equal(
|
||||
borderAccent.length, 0,
|
||||
`expected 0 border-accent-on-rounded, got ${borderAccent.length}: ${borderAccent.map(r => r.snippet).join('; ')}`
|
||||
);
|
||||
});
|
||||
|
||||
it('modern-color-borders: regex fallback skips neutral 1px oklch dividers', () => {
|
||||
const css = `
|
||||
.flag-side-tab {
|
||||
@@ -553,6 +696,115 @@ describe('detectHtml — icon-tile-stack', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — radial-spotlight-glow', () => {
|
||||
// Two-column fixture convention: left col = should-flag, right col = should-pass.
|
||||
// The rule's snippet embeds the element's data-name in quotes, e.g.
|
||||
// radial-gradient spotlight glow "Hero Spotlight Blue" (#506fff a0.26 → transparent).
|
||||
const SHOULD_FLAG = [
|
||||
'Hero Spotlight Blue',
|
||||
'Section Glow Violet',
|
||||
'Overlay Glow Cyan',
|
||||
'Two Stop Soft Glow',
|
||||
'Hex Alpha Glow',
|
||||
];
|
||||
const SHOULD_PASS = [
|
||||
'Opaque Radial Background',
|
||||
'Small Accent Badge',
|
||||
'Avatar Glow Light',
|
||||
'Neutral Vignette',
|
||||
'White Vignette',
|
||||
'Rich Radial Composition',
|
||||
'Rich Transparent Composition',
|
||||
'Opaque Center Glow',
|
||||
'Linear Gradient Wash',
|
||||
];
|
||||
|
||||
it('radial-spotlight-glow: flags only the should-flag column', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'radial-spotlight-glow.html'));
|
||||
const flagged = new Set();
|
||||
for (const r of f) {
|
||||
if (r.antipattern !== 'radial-spotlight-glow') continue;
|
||||
const m = (r.snippet || '').match(/"([^"]+)"/);
|
||||
if (m) flagged.add(m[1]);
|
||||
}
|
||||
|
||||
for (const text of SHOULD_FLAG) {
|
||||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as radial-spotlight-glow`);
|
||||
}
|
||||
for (const text of SHOULD_PASS) {
|
||||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as radial-spotlight-glow`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — undersized-ui-text', () => {
|
||||
// Two-column fixture: left col = should-flag, right col = should-pass.
|
||||
// The rule's snippet embeds the element's direct text in quotes, e.g.
|
||||
// `8px functional text "Flag Nav Link" (below 11px floor)`.
|
||||
// The test extracts those quoted texts and matches them against the lists.
|
||||
const SHOULD_FLAG = [
|
||||
'Flag Nav Link', // interactive nav link at 8px
|
||||
'Flag Category', // non-interactive furniture label at 8px
|
||||
'Flag Meta Row', // meta row at 9px
|
||||
'Flag Button', // interactive button at 10px
|
||||
'Flag Table Cell', // structural table cell at 9px
|
||||
'Flag Caps Label', // uppercase letterspaced micro-label — NOT exempt
|
||||
'Flag Footer Link', // interactive text in footer stays on the 11px floor
|
||||
];
|
||||
const SHOULD_PASS = [
|
||||
'Pass Legal Fine Print', // non-interactive footer smallprint at 10px (floor 10)
|
||||
'Pass Sr Only', // visually-hidden text
|
||||
'Pass Sup Marker', // sup tag exempt
|
||||
'Pass Sub Marker', // sub tag exempt
|
||||
'Pass Em Sized', // 0.6em of a 20px parent = 12px, above the floor
|
||||
'Pass Terminal Line', // code/terminal mock, legitimately small
|
||||
'Pass Normal Link', // functional text at the 12px floor
|
||||
];
|
||||
|
||||
it('undersized-ui-text: flags only the should-flag column', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'undersized-ui-text.html'));
|
||||
const flagged = new Set();
|
||||
for (const r of f) {
|
||||
if (r.antipattern !== 'undersized-ui-text') continue;
|
||||
const m = (r.snippet || '').match(/"([^"]+)"/);
|
||||
if (m) flagged.add(m[1]);
|
||||
}
|
||||
|
||||
for (const text of SHOULD_FLAG) {
|
||||
assert.ok(flagged.has(text), `expected "${text}" to be flagged as undersized-ui-text`);
|
||||
}
|
||||
for (const text of SHOULD_PASS) {
|
||||
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged as undersized-ui-text`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — non-rendered text (issue #408)', () => {
|
||||
// On sites that set `html { font-size: 62.5% }` the root computes to 10px, so
|
||||
// <script>/<style>/<title>/<noscript> and display:none / visibility:hidden
|
||||
// blocks — whose JS/CSS/JSON-LD text clears the hasDirectText gate — report a
|
||||
// 10px size and used to produce dozens of phantom "10px body text" findings.
|
||||
// Both text-size floors (tiny-text and undersized-ui-text) must skip them and
|
||||
// measure only genuinely rendered text.
|
||||
it('tiny-text / undersized-ui-text: non-rendered elements produce no findings, rendered text still flags', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'nonrendered-text.html'));
|
||||
const tiny = f.filter(r => r.antipattern === 'tiny-text');
|
||||
const undersized = f.filter(r => r.antipattern === 'undersized-ui-text');
|
||||
|
||||
// Exactly the two genuinely rendered elements flag: the 10px body <p>
|
||||
// (tiny-text) and the 9px interactive nav link (undersized-ui-text).
|
||||
assert.equal(
|
||||
tiny.length, 1,
|
||||
`expected exactly 1 tiny-text finding (rendered body copy), got ${tiny.length}: ${tiny.map(r => r.snippet).join('; ')}`
|
||||
);
|
||||
assert.equal(
|
||||
undersized.length, 1,
|
||||
`expected exactly 1 undersized-ui-text finding (rendered nav link), got ${undersized.length}: ${undersized.map(r => r.snippet).join('; ')}`
|
||||
);
|
||||
assert.match(undersized[0].snippet || '', /Rendered Nav Link/, 'the one undersized finding must be the rendered nav link');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — quality (static-compatible rules)', () => {
|
||||
// Six of the eight quality rules can run in static HTML/CSS because they only need
|
||||
// computed CSS values (tight-leading, tiny-text, justified-text,
|
||||
@@ -927,52 +1179,68 @@ describe('em-dash overuse — HTML entity escapes', () => {
|
||||
`<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>t</title></head>` +
|
||||
`<body><main><h1>A real page heading of ordinary length</h1><p>${body}</p></main></body></html>`;
|
||||
|
||||
// Six dashes clears the 5+ threshold. Sentence fragments keep the surrounding
|
||||
// prose realistic so nothing else in the pipeline objects.
|
||||
const sixNamed = 'fast — cheap — honest — simple — quiet — kind — done';
|
||||
const sixNumeric = 'fast — cheap — honest — simple — quiet — kind — done';
|
||||
const sixHex = 'fast — cheap — honest — simple — quiet — kind — done';
|
||||
const sixHexUpper = 'fast — cheap — honest — simple — quiet — kind — done';
|
||||
const sixNumericPadded = 'fast — cheap — honest — simple — quiet — kind — done';
|
||||
// Three literal glyphs + three named entities render identically; the count
|
||||
// must see all six.
|
||||
const mixed = 'fast — cheap — honest — simple — quiet — kind — done';
|
||||
// Eight dashes clears the raised advisory floor (EM_DASH_FLOOR = 8, up from
|
||||
// the old flat 5). Packed into one short paragraph they also clear the density
|
||||
// gate. Sentence fragments keep the surrounding prose realistic so nothing
|
||||
// else in the pipeline objects.
|
||||
const eightNamed = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||||
const eightNumeric = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||||
const eightHex = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||||
const eightHexUpper = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||||
const eightNumericPadded = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||||
// Four literal glyphs + four named entities render identically; the count
|
||||
// must see all eight.
|
||||
const mixed = 'fast — cheap — honest — simple — quiet — kind — bright — calm — done';
|
||||
|
||||
const SHOULD_FLAG = {
|
||||
'named —': sixNamed,
|
||||
'numeric —': sixNumeric,
|
||||
'hex —': sixHex,
|
||||
'uppercase-hex —': sixHexUpper,
|
||||
'zero-padded decimal —': sixNumericPadded,
|
||||
'named —': eightNamed,
|
||||
'numeric —': eightNumeric,
|
||||
'hex —': eightHex,
|
||||
'uppercase-hex —': eightHexUpper,
|
||||
'zero-padded decimal —': eightNumericPadded,
|
||||
'mixed literal + entity': mixed,
|
||||
};
|
||||
|
||||
// A long paragraph carrying exactly eight dashes across several thousand
|
||||
// characters of prose. Above the absolute floor, but the density gate
|
||||
// (one per ~500 chars) keeps ordinary long-form writing from flagging.
|
||||
const longLowDensityFiller = 'This paragraph is written in ordinary human prose that runs on for quite a while. '.repeat(60);
|
||||
const longLowDensity = `a — b — c — d — e — f — g — h — end. ${longLowDensityFiller}`;
|
||||
|
||||
// False-positive shapes: none of these should trip the em-dash counter.
|
||||
const SHOULD_PASS = {
|
||||
// Below the 5+ threshold: occasional em-dash entity use is legitimate prose.
|
||||
// Below the floor: seven dashes on a short page is under the raised floor of 8.
|
||||
'seven dashes below floor': 'a — b — c — d — e — f — g — done, otherwise plain sentences fill the paragraph body',
|
||||
// Below the floor: occasional em-dash entity use is legitimate prose.
|
||||
'two entities below threshold': 'fast — cheap — done, otherwise plain sentences fill the paragraph body',
|
||||
// Above the floor but below the density gate: a long human article.
|
||||
'eight dashes across a long article': longLowDensity,
|
||||
// En-dashes are a different character and a different job (ranges); the em-dash
|
||||
// rule must not decode or count them.
|
||||
'en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2',
|
||||
'numeric en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2',
|
||||
'en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2 and 3–4 and 5–6 and 7–8',
|
||||
'numeric en-dash entities': 'pages 10–20 and 30–40 and 50–60 and 70–80 and 90–100 and 1–2 and 3–4 and 5–6',
|
||||
// Double-escaped: the visible text is the literal string "—", not a dash.
|
||||
'double-escaped ampersand': 'write &mdash; and &mdash; and &mdash; and &mdash; and &mdash; and &mdash; literally',
|
||||
'double-escaped ampersand': 'write &mdash; and &mdash; and &mdash; and &mdash; and &mdash; and &mdash; and &mdash; and &mdash; literally',
|
||||
// Unrelated entities must never be miscounted as dashes.
|
||||
'non-dash entities': 'a b © c … d & e ™ f ® g ° h § i ¶',
|
||||
// Ordinary hyphenated compounds are single hyphens, not the double-hyphen tell.
|
||||
'hyphenated compounds': 'state-of-the-art, well-being, high-quality, self-service, end-to-end, at-a-glance copy',
|
||||
'hyphenated compounds': 'state-of-the-art, well-being, high-quality, self-service, end-to-end, at-a-glance, day-to-day, off-the-shelf copy',
|
||||
};
|
||||
|
||||
const emDashCount = (findings) =>
|
||||
findings.filter((r) => r.antipattern === 'em-dash-overuse').length;
|
||||
const emDashFindings = (findings) =>
|
||||
findings.filter((r) => r.antipattern === 'em-dash-overuse');
|
||||
|
||||
for (const [label, body] of Object.entries(SHOULD_FLAG)) {
|
||||
it(`flags em-dash overuse spelled as ${label}`, () => {
|
||||
const findings = detectText(page(body), 'em-dash.html');
|
||||
const hits = emDashFindings(findings);
|
||||
assert.equal(
|
||||
emDashCount(findings), 1,
|
||||
hits.length, 1,
|
||||
`expected em-dash-overuse for "${label}", got: ${findings.map((r) => r.antipattern).join(', ') || 'none'}`,
|
||||
);
|
||||
// The rule is advisory: the finding must carry the flag so the CLI, JSON,
|
||||
// and hook can partition it out of the failure set.
|
||||
assert.equal(hits[0].advisory, true, `"${label}" finding should be marked advisory`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -980,7 +1248,7 @@ describe('em-dash overuse — HTML entity escapes', () => {
|
||||
it(`does not flag ${label}`, () => {
|
||||
const findings = detectText(page(body), 'em-dash.html');
|
||||
assert.equal(
|
||||
emDashCount(findings), 0,
|
||||
emDashFindings(findings).length, 0,
|
||||
`"${label}" should not flag em-dash overuse`,
|
||||
);
|
||||
});
|
||||
@@ -988,9 +1256,65 @@ describe('em-dash overuse — HTML entity escapes', () => {
|
||||
|
||||
it('static-HTML path decodes entity em-dashes too (fixture file)', async () => {
|
||||
const findings = await detectHtml(path.join(FIXTURES, 'em-dash-entities.html'));
|
||||
const hits = findings.filter((r) => r.antipattern === 'em-dash-overuse');
|
||||
assert.equal(
|
||||
findings.filter((r) => r.antipattern === 'em-dash-overuse').length, 1,
|
||||
hits.length, 1,
|
||||
'em-dash-entities.html should flag em-dash overuse via the static-HTML path',
|
||||
);
|
||||
assert.equal(hits[0].advisory, true, 'static-HTML em-dash finding should be advisory');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFindings — advisory partitioning', () => {
|
||||
const primary = { antipattern: 'side-tab', name: 'Side-tab', description: 'A primary finding.', file: 'a.css', line: 1, snippet: 'x' };
|
||||
const advisory = { antipattern: 'em-dash-overuse', name: 'Em-dash', description: 'An advisory finding.', file: 'a.html', line: 0, snippet: '8 em-dashes', advisory: true };
|
||||
|
||||
it('lists advisory findings in a separate section and excludes them from the failure count', () => {
|
||||
const text = formatFindings([primary, advisory], false);
|
||||
assert.match(text, /1 anti-pattern found\./); // primary count only
|
||||
assert.match(text, /Advisory \(not counted as failures\)/);
|
||||
assert.match(text, /em-dash-overuse/);
|
||||
assert.match(text, /1 advisory note/);
|
||||
});
|
||||
|
||||
it('reports zero failures for an advisory-only set but still shows the advisory section', () => {
|
||||
const text = formatFindings([advisory], false);
|
||||
assert.match(text, /0 anti-patterns found\./);
|
||||
assert.match(text, /em-dash-overuse/);
|
||||
});
|
||||
|
||||
it('keeps every finding (advisory flagged) in JSON output', () => {
|
||||
const json = JSON.parse(formatFindings([primary, advisory], true));
|
||||
assert.equal(json.length, 2);
|
||||
assert.equal(json.find((f) => f.antipattern === 'em-dash-overuse').advisory, true);
|
||||
assert.equal(json.find((f) => f.antipattern === 'side-tab').advisory, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('em-dash overuse — browser adapter parity (checkEmDashOveruse)', () => {
|
||||
// The browser DOM check operates on already-rendered text, so it exercises
|
||||
// the same two-gate logic without entity decoding. checkEmDashOveruse is the
|
||||
// pure core the DOM wrapper calls.
|
||||
const id = (findings) => findings.map((f) => f.id).join(',');
|
||||
|
||||
it('flags eight dense em-dashes', () => {
|
||||
const findings = checkEmDashOveruse('a — b — c — d — e — f — g — h — done');
|
||||
assert.equal(id(findings), 'em-dash-overuse');
|
||||
});
|
||||
|
||||
it('does not flag seven em-dashes (below the floor)', () => {
|
||||
const findings = checkEmDashOveruse('a — b — c — d — e — f — g — done');
|
||||
assert.equal(findings.length, 0);
|
||||
});
|
||||
|
||||
it('does not flag eight em-dashes spread across long prose (density gate)', () => {
|
||||
const filler = 'This is ordinary human prose that continues at length. '.repeat(80);
|
||||
const findings = checkEmDashOveruse(`a — b — c — d — e — f — g — h — end. ${filler}`);
|
||||
assert.equal(findings.length, 0);
|
||||
});
|
||||
|
||||
it('counts the double-hyphen em-dash substitute', () => {
|
||||
const findings = checkEmDashOveruse('a--b c--d e--f g--h i--j k--l m--n o--p done');
|
||||
assert.equal(id(findings), 'em-dash-overuse');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1828,6 +1828,55 @@ describe('walkDir', () => {
|
||||
test('returns empty for nonexistent dir', () => {
|
||||
expect(walkDir('/nonexistent/path/12345')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Issue #303: when impeccable (or any agent tool) is installed into a
|
||||
// project's .claude/.cursor/etc. tree, a root scan descended into the
|
||||
// vendored skill code and reported the detector's own example strings as
|
||||
// findings. Hidden directories are never app source — skip them all.
|
||||
test('skips hidden dirs (AI-harness installs) during recursion', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-'));
|
||||
try {
|
||||
const write = (rel) => {
|
||||
const abs = path.join(tmp, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, '/* fixture */');
|
||||
};
|
||||
write('src/app.css');
|
||||
write('.claude/skills/impeccable/scripts/detector.js');
|
||||
write('.cursor/skills/impeccable/example.css');
|
||||
write('.impeccable/live/preview.html');
|
||||
write('node_modules/pkg/index.js');
|
||||
// Hidden dirs that conventionally hold real UI source are the
|
||||
// exception: VitePress themes and Storybook preview files must keep
|
||||
// being scanned (they were before the hidden-dir rule existed).
|
||||
write('.vitepress/theme/Layout.vue');
|
||||
write('.vuepress/theme/Layout.vue');
|
||||
write('.storybook/preview.css');
|
||||
const files = walkDir(tmp).sort();
|
||||
expect(files).toEqual([
|
||||
path.join(tmp, '.storybook', 'preview.css'),
|
||||
path.join(tmp, '.vitepress', 'theme', 'Layout.vue'),
|
||||
path.join(tmp, '.vuepress', 'theme', 'Layout.vue'),
|
||||
path.join(tmp, 'src', 'app.css'),
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('still scans a hidden dir passed as the explicit target', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-walk-'));
|
||||
try {
|
||||
const abs = path.join(tmp, '.claude', 'page.html');
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, '<html></html>');
|
||||
// Only child entries are name-checked; naming the hidden dir directly
|
||||
// is an explicit user intent and must keep working.
|
||||
expect(walkDir(path.join(tmp, '.claude'))).toEqual([abs]);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Regression: `impeccable detect <file-or-dir>` must resolve DESIGN.md from
|
||||
* EACH scan target's own project root, not from process.cwd().
|
||||
*
|
||||
* The bug (found during eval work): running detect from repo A against a file
|
||||
* that lives in repo B applied A's DESIGN.md to B — cross-project contamination.
|
||||
* These tests spawn the real CLI so the fix is exercised end to end.
|
||||
*
|
||||
* Run with: node --test tests/detect-cli-design-contamination.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLI = path.resolve(__dirname, '../cli/bin/cli.js');
|
||||
|
||||
// Verdana is a plain web-safe font: it is not in OVERUSED_FONTS and trips no
|
||||
// standalone rule, so the only way it becomes a `design-system-font` finding is
|
||||
// if a DESIGN.md that forbids it gets applied.
|
||||
const PAGE_HTML =
|
||||
'<!doctype html><html><head><style>.card { font-family: Verdana, sans-serif; }</style></head>' +
|
||||
'<body><div class="card">Hi</div></body></html>';
|
||||
|
||||
// A DESIGN.md whose typography allows only Palatino — Verdana violates it.
|
||||
const DESIGN_MD = `---
|
||||
typography:
|
||||
body:
|
||||
fontFamily: "Palatino, Georgia, serif"
|
||||
---
|
||||
# Project A Design System
|
||||
`;
|
||||
|
||||
const tempRoots = [];
|
||||
|
||||
function mkProject({ withDesign, withMarker = true }) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-contam-'));
|
||||
tempRoots.push(dir);
|
||||
if (withMarker) fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"fixture"}');
|
||||
if (withDesign) fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD);
|
||||
const page = path.join(dir, 'page.html');
|
||||
fs.writeFileSync(page, PAGE_HTML);
|
||||
return { dir, page };
|
||||
}
|
||||
|
||||
// Run the CLI from `cwd`; force the node binary so the HTML/jsdom path never
|
||||
// runs under bun (which is unusably slow).
|
||||
function runDetect(cwd, targets) {
|
||||
const result = spawnSync(process.execPath, [CLI, 'detect', '--json', ...targets], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
let findings = [];
|
||||
try {
|
||||
findings = JSON.parse(result.stdout || '[]');
|
||||
} catch {
|
||||
throw new Error(`Non-JSON CLI output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function fontFindingsFor(findings, file) {
|
||||
return findings.filter(
|
||||
(f) => f.antipattern === 'design-system-font' && (!file || f.file === file),
|
||||
);
|
||||
}
|
||||
|
||||
let projA;
|
||||
let projB;
|
||||
|
||||
before(() => {
|
||||
projA = mkProject({ withDesign: true }); // DESIGN.md forbids Verdana
|
||||
projB = mkProject({ withDesign: false }); // its own project, no DESIGN.md
|
||||
});
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempRoots) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
describe('detect CLI DESIGN.md resolution', () => {
|
||||
it('does NOT apply cwd project A\'s DESIGN.md to project B\'s file (the contamination bug)', () => {
|
||||
const findings = runDetect(projA.dir, [projB.page]);
|
||||
assert.deepEqual(
|
||||
fontFindingsFor(findings, projB.page).map((f) => f.ignoreValue),
|
||||
[],
|
||||
'project B\'s Verdana must not be flagged by project A\'s DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('still applies a project\'s own DESIGN.md to its own file (positive control)', () => {
|
||||
const findings = runDetect(projA.dir, [projA.page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, projA.page).some((f) => f.ignoreValue === 'verdana'),
|
||||
'project A\'s own DESIGN.md must flag Verdana in project A\'s file',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves per target when one scan spans two projects', () => {
|
||||
const findings = runDetect(projA.dir, [projA.page, projB.page]);
|
||||
assert.ok(
|
||||
fontFindingsFor(findings, projA.page).length > 0,
|
||||
'A\'s file should be judged against A\'s DESIGN.md',
|
||||
);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, projB.page).length,
|
||||
0,
|
||||
'B\'s file should NOT be judged against A\'s DESIGN.md',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to no design system for a bare file with no project markers above it', () => {
|
||||
// A lone file whose directory has neither .git, package.json, nor .impeccable.
|
||||
const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-bare-'));
|
||||
tempRoots.push(bareDir);
|
||||
const barePage = path.join(bareDir, 'page.html');
|
||||
fs.writeFileSync(barePage, PAGE_HTML);
|
||||
|
||||
const findings = runDetect(projA.dir, [barePage]);
|
||||
assert.equal(
|
||||
fontFindingsFor(findings, barePage).length,
|
||||
0,
|
||||
'a project-less file must fall back to no design system, not cwd\'s',
|
||||
);
|
||||
});
|
||||
});
|
||||
+103
-1
@@ -39,7 +39,11 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(scratch, { recursive: true, force: true });
|
||||
// These tests run real git subprocesses in the scratch dir; on Node 22 a
|
||||
// recursive delete can race git's object writes and fail the whole test
|
||||
// with ENOTEMPTY (seen in CI). maxRetries/retryDelay make rmSync retry
|
||||
// exactly those transient errors.
|
||||
fs.rmSync(scratch, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
function write(rel, body) {
|
||||
@@ -272,6 +276,104 @@ describe('checkHookInstallation', () => {
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
// The manifest `impeccable hooks on` actually writes, verbatim: a
|
||||
// `${CLAUDE_PROJECT_DIR}`-relative command. Claude Code expands the variable
|
||||
// to the project dir at hook time; the doctor must expand it the same way
|
||||
// (issue #402) instead of existsSync-ing the literal `${CLAUDE_PROJECT_DIR}/...`.
|
||||
const claudeManifest = () => ({
|
||||
hooks: {
|
||||
PostToolUse: [{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' }],
|
||||
}],
|
||||
Stop: [{ hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' }] }],
|
||||
},
|
||||
});
|
||||
|
||||
it('stays quiet for a ${CLAUDE_PROJECT_DIR} manifest when the script exists at root', () => {
|
||||
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
||||
write('.claude/settings.json', JSON.stringify(claudeManifest()));
|
||||
assert.deepEqual(
|
||||
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('still flags a genuinely missing script behind ${CLAUDE_PROJECT_DIR}', () => {
|
||||
// Placeholder expands to a real path that does not exist: the check must
|
||||
// stay real, not neutered into always-quiet.
|
||||
write('.claude/settings.json', JSON.stringify(claudeManifest()));
|
||||
const findings = checkHookInstallation({
|
||||
projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code',
|
||||
});
|
||||
assert.deepEqual(ids(findings), ['hook-script-missing']);
|
||||
});
|
||||
|
||||
it('handles the #399 guarded project-relative form', () => {
|
||||
const p = '"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"';
|
||||
const guarded = `[ ! -f ${p} ] || node ${p}`;
|
||||
write('.claude/settings.json', JSON.stringify({
|
||||
hooks: { Stop: [{ hooks: [{ command: guarded }] }] },
|
||||
}));
|
||||
// missing → flagged
|
||||
assert.deepEqual(
|
||||
ids(checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' })),
|
||||
['hook-script-missing'],
|
||||
);
|
||||
// present → quiet
|
||||
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
||||
assert.deepEqual(
|
||||
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('handles the #399 guarded absolute form (user-level installs)', () => {
|
||||
const abs = path.join(scratch, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
|
||||
const p = JSON.stringify(abs);
|
||||
const guarded = `[ ! -f ${p} ] || node ${p}`;
|
||||
write('.claude/settings.json', JSON.stringify({
|
||||
hooks: { Stop: [{ hooks: [{ command: guarded }] }] },
|
||||
}));
|
||||
// absolute path missing → flagged
|
||||
assert.deepEqual(
|
||||
ids(checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' })),
|
||||
['hook-script-missing'],
|
||||
);
|
||||
// present → quiet
|
||||
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
||||
assert.deepEqual(
|
||||
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('never reports missing for the GitHub $(git rev-parse) form', () => {
|
||||
// Command substitution is not statically resolvable; a doctor must not
|
||||
// assert a negative it cannot verify.
|
||||
write('.github/hooks/impeccable.json', JSON.stringify({
|
||||
hooks: { postToolUse: [{ bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"' }] },
|
||||
}));
|
||||
assert.deepEqual(
|
||||
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'github' }),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('never reports missing for plugin-root placeholders the doctor cannot map', () => {
|
||||
for (const token of ['${CLAUDE_PLUGIN_ROOT}', '${PLUGIN_ROOT}', '${GROK_PLUGIN_ROOT}']) {
|
||||
fs.rmSync(path.join(scratch, '.claude'), { recursive: true, force: true });
|
||||
write('.claude/settings.json', JSON.stringify({
|
||||
hooks: { Stop: [{ hooks: [{ command: `node "${token}/skills/impeccable/scripts/hook.mjs"` }] }] },
|
||||
}));
|
||||
assert.deepEqual(
|
||||
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
||||
[],
|
||||
`expected no finding for ${token}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── retired live-mode state ───────────────────────────────────────────────
|
||||
|
||||
+31
@@ -24,6 +24,18 @@
|
||||
.panel-reset { background: rgb(28, 30, 38); color: rgb(230, 232, 237); padding: 12px; }
|
||||
.panel-reset code { background: rgb(246, 242, 244); border-radius: 3px; padding: 1px 4px; }
|
||||
.panel-reset pre code { background: none; }
|
||||
/* issue #409 Case A: gradient-clipped text. The gradient IS the glyph fill
|
||||
(text-fill-color: transparent), not a backdrop. The inherited `color`
|
||||
(#e8e6e3) is never painted, so measuring it against the element's own
|
||||
gradient stops (#6d8cff / #a78bfa) is a false positive. */
|
||||
.ox-grad-text { background: linear-gradient(135deg, #6d8cff 0%, #a78bfa 50%, #6d8cff 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: #e8e6e3; font-size: 40px; font-weight: 800; }
|
||||
/* issue #409 Case B: a 9%-alpha accent glow stop over a dark surface. The
|
||||
stop composites to ~#121f1f, not the full-opacity #34c0a8, so text stays
|
||||
high-contrast. The dark wrapper supplies the surface beneath the glow. */
|
||||
.ox-dark-wrap { background: #0f0f11; padding: 16px; }
|
||||
.ox-glow { background: linear-gradient(160deg, rgba(52,192,168,0.09) 0%, #141419 65%); padding: 20px; }
|
||||
.ox-glow p { color: #e8e6e3; font-size: 18px; }
|
||||
.ox-glow .muted { color: #8e8c89; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -181,6 +193,25 @@
|
||||
<pre><code data-test="code-reset">light text over the dark panel, not the light code surface</code></pre>
|
||||
</div>
|
||||
|
||||
<h3>Gradient-clipped text (issue #409 Case A — must not flag contrast)</h3>
|
||||
<!-- background-clip: text with a transparent fill: the gradient paints the
|
||||
glyphs, not a surface behind them. The inherited light `color` is
|
||||
never painted. The gradient-text pattern still flags; the backdrop
|
||||
contrast rules (low-contrast / gray-on-color) must not. -->
|
||||
<h1 class="ox-grad-text" data-test="ox-grad-text">Gradient Clipped Heading Text</h1>
|
||||
|
||||
<h3>Faint accent-glow gradient (issue #409 Case B — must not flag contrast)</h3>
|
||||
<!-- A 9%-alpha teal glow over a dark section. Composited against the dark
|
||||
surface the stop is near-black, so the light and gray text on it are
|
||||
high-contrast. Treating the stop as opaque #34c0a8 was the false
|
||||
positive that flagged every text child. -->
|
||||
<div class="ox-dark-wrap">
|
||||
<div class="ox-glow" data-test="ox-glow">
|
||||
<p>Light body copy on a faint accent glow that composites to near-black</p>
|
||||
<p class="muted">Muted secondary line on the same faint glow area here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Emoji on light backgrounds</h3>
|
||||
<!-- Emojis render as multicolor glyphs regardless of CSS color, so the
|
||||
CSS color is irrelevant for contrast. These should NOT be flagged. -->
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
<p>
|
||||
The product is fast — it is also cheap — and it is honest
|
||||
— which matters — more than speed — or price
|
||||
— in the long run.
|
||||
— in the short term — and the long run — always.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Side-Tab with CSS Named Colors (purple/rebeccapurple/crimson/teal)</title>
|
||||
<style>
|
||||
/* Two-column fixture: left col = should-flag, right col = should-pass.
|
||||
Regression for issue #359: `border-left: 4px solid purple` in a .html
|
||||
file never fired side-tab because the static cascade's shorthand color
|
||||
extraction only recognized 9 named colors — every other spec name
|
||||
(purple, rebeccapurple, crimson, teal, ...) was dropped from the
|
||||
shorthand, leaving the side at the default black, which reads as
|
||||
neutral and silently skips the check. The same declaration in a .css
|
||||
file was flagged by the regex engine, so the two engines disagreed. */
|
||||
|
||||
:root {
|
||||
--accent: orange; /* named color behind a var() — must resolve and flag */
|
||||
}
|
||||
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; background: #fafafa; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1120px; margin: 0 auto; }
|
||||
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
|
||||
.case { padding: 12px 16px; margin-bottom: 16px; }
|
||||
.case h3 { font-size: 14px; margin: 0 0 4px; }
|
||||
.case p { font-size: 13px; margin: 0; color: #64748b; }
|
||||
|
||||
/* ── FLAG cases: colored side-tab borders using CSS named colors ── */
|
||||
|
||||
/* 1: the exact reproducer from issue #359 — named purple in a <style>
|
||||
rule with background + padding + radius */
|
||||
#flag-named-purple {
|
||||
width: 400px;
|
||||
background: #f6f6f6;
|
||||
padding: 16px;
|
||||
border-left: 4px solid purple;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 2: rebeccapurple — a longer name that contains another color name
|
||||
("purple") as a substring; must match whole-token. Width 5px so the
|
||||
finding snippet is unique to this case. */
|
||||
#flag-named-rebecca {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border-left: 5px solid rebeccapurple;
|
||||
}
|
||||
|
||||
/* 3: horizontal stripe variant — named crimson riding the top edge */
|
||||
#flag-named-crimson-top {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-top: 4px solid crimson;
|
||||
}
|
||||
|
||||
/* 4: named teal, 3px, no radius — the bare w >= 3 arm */
|
||||
#flag-named-teal {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-left: 3px solid teal;
|
||||
}
|
||||
|
||||
/* 5: named color behind a var() in the shorthand. Width 6px so the
|
||||
finding snippet is unique to this case. */
|
||||
#flag-named-var {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border-left: 6px solid var(--accent);
|
||||
}
|
||||
|
||||
/* ── PASS cases: neutral named colors and non-side-tab shapes ── */
|
||||
|
||||
/* 1: dimgray — chromatic-looking name, neutral value; must NOT fire */
|
||||
#pass-named-dimgray {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid dimgray;
|
||||
}
|
||||
|
||||
/* 2: gainsboro — light neutral named color */
|
||||
#pass-named-gainsboro {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid gainsboro;
|
||||
}
|
||||
|
||||
/* 3: named black side border — neutral, not a colored stripe */
|
||||
#pass-named-black {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid black;
|
||||
}
|
||||
|
||||
/* 4: 1px named purple — too thin to qualify */
|
||||
#pass-named-thin {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
border-left: 1px solid purple;
|
||||
}
|
||||
|
||||
/* 5: uniform named purple border on all four sides — not a side-tab */
|
||||
#pass-named-allsides {
|
||||
width: 400px;
|
||||
background: #ffffff;
|
||||
border: 3px solid purple;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
<div class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
<div class="case" id="flag-named-purple"><h3>named purple</h3><p>border-left 4px solid purple + radius</p></div>
|
||||
<div class="case" id="flag-named-rebecca"><h3>rebeccapurple</h3><p>border-left 4px + radius</p></div>
|
||||
<div class="case" id="flag-named-crimson-top"><h3>crimson top stripe</h3><p>border-top 4px, horizontal variant</p></div>
|
||||
<div class="case" id="flag-named-teal"><h3>named teal</h3><p>border-left 3px, no radius</p></div>
|
||||
<div class="case" id="flag-named-var"><h3>var() to named</h3><p>border-left 4px solid var(--accent)</p></div>
|
||||
<div class="case" style="width: 400px; background: #ffffff; border-left: 7px solid purple">
|
||||
<h3>inline named purple</h3><p>style attribute, issue #359 case (a); width 7px keeps the snippet unique</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" data-col="pass">
|
||||
<h2>Should pass</h2>
|
||||
<div class="case" id="pass-named-dimgray"><h3>dimgray</h3><p>neutral named color</p></div>
|
||||
<div class="case" id="pass-named-gainsboro"><h3>gainsboro</h3><p>light neutral named color</p></div>
|
||||
<div class="case" id="pass-named-black"><h3>named black</h3><p>neutral side border</p></div>
|
||||
<div class="case" id="pass-named-thin"><h3>1px purple</h3><p>too thin to qualify</p></div>
|
||||
<div class="case" id="pass-named-allsides"><h3>uniform purple</h3><p>all four sides, not a stripe</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Regression fixture for issue #408: tiny-text / undersized-ui-text must only
|
||||
measure rendered text. On sites that set `html { font-size: 62.5% }` the root
|
||||
computes to 10px, so every element that inherits the root and carries text
|
||||
content — including <script>, <style>, <title>, <noscript>, and display:none /
|
||||
visibility:hidden blocks — reports a 10px computed size. Their JS / CSS /
|
||||
JSON-LD text satisfies hasDirectText, so before the fix they produced dozens
|
||||
of phantom "10px body text" findings on every Shopify page.
|
||||
|
||||
Explicit pixel sizes throughout because jsdom does no layout.
|
||||
-->
|
||||
<html style="font-size: 10px">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- SHOULD PASS: a long <title> inherits the 10px root, but nothing renders it. -->
|
||||
<title>This is a fairly long document title that easily exceeds twenty characters</title>
|
||||
<!-- SHOULD PASS: stylesheet text is never painted. -->
|
||||
<style>
|
||||
/* This CSS comment is deliberately longer than twenty characters so the style
|
||||
element's text node clears the hasDirectText / textLen > 20 gate. */
|
||||
body { font-size: 10px; font-family: system-ui, sans-serif; }
|
||||
.rendered-body { font-size: 10px; }
|
||||
.rendered-nav-link { font-size: 9px; }
|
||||
.none-block { display: none; font-size: 10px; }
|
||||
.hidden-block { visibility: hidden; font-size: 10px; }
|
||||
</style>
|
||||
<!-- SHOULD PASS: script payload text is never painted. -->
|
||||
<script>window.__ANALYTICS__ = { id: 1, ts: 0 }; console.log("an analytics payload string that is clearly longer than twenty characters");</script>
|
||||
<!-- SHOULD PASS: JSON-LD schema block, a Shopify staple. -->
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Product","name":"A product name long enough to exceed twenty characters"}</script>
|
||||
<!-- SHOULD PASS: noscript fallback prose is only shown without JS; still non-rendered here. -->
|
||||
<noscript>Please enable JavaScript in your browser to view this page content correctly.</noscript>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- SHOULD PASS: display:none block, its text is never painted. -->
|
||||
<div class="none-block">Hidden display-none block of body text long enough to exceed twenty characters.</div>
|
||||
|
||||
<!-- SHOULD PASS: visibility:hidden paragraph, its text is never painted. -->
|
||||
<p class="hidden-block">Invisible visibility-hidden paragraph copy that is longer than the twenty char gate.</p>
|
||||
|
||||
<!-- SHOULD FLAG (tiny-text): genuinely rendered body copy at 10px. -->
|
||||
<p class="rendered-body">This is real rendered body copy at 10px that is definitely long enough to flag.</p>
|
||||
|
||||
<!-- SHOULD FLAG (undersized-ui-text): genuinely rendered interactive text at 9px. -->
|
||||
<nav aria-label="primary"><a href="/docs" class="rendered-nav-link">Rendered Nav Link</a></nav>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Pseudo-element stripe fixture for the regex engine (issue #394).
|
||||
*
|
||||
* The side-tab silhouette built as an absolutely-positioned ::before/::after
|
||||
* bar instead of a border. scanCssTextForPseudoStripe already caught these on
|
||||
* full HTML pages; this fixture pins the standalone-stylesheet path. The
|
||||
* data-case attribute in each selector lands in the finding snippet, so the
|
||||
* test can attribute every flag/pass case individually.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--stripe-fixture-neutral: #e5e7eb;
|
||||
}
|
||||
|
||||
/* ── FLAG: the issue reproducer — inset shorthand pin + 4px chromatic bar ── */
|
||||
.card[data-case="Inset Shorthand Left Edge"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── FLAG: longhand edge pins; unresolvable var() errs toward detection ── */
|
||||
.card[data-case="Longhand Left Edge"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* ── FLAG: horizontal variant riding the bottom edge ── */
|
||||
.card[data-case="Bottom Edge"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 4px;
|
||||
background: #f43f5e;
|
||||
}
|
||||
|
||||
/* ── FLAG: height:100% full-height stripe on the right edge ── */
|
||||
.card[data-case="Full Height Right Edge"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
background: oklch(60% 0.2 300);
|
||||
}
|
||||
|
||||
/* ── PASS: neutral gray divider is not an accent stripe ── */
|
||||
.card[data-case="Neutral Divider"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: var(--stripe-fixture-neutral);
|
||||
}
|
||||
|
||||
/* ── PASS: 24px is a panel, not a stripe ── */
|
||||
.card[data-case="Wide Panel"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 24px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── PASS: no position: absolute — not an overlay stripe ── */
|
||||
.card[data-case="Static Underline"]::before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── PASS: 1px hairline is a divider ── */
|
||||
.card[data-case="Hairline Divider"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 1px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── PASS: hover-conditional underline is an affordance, not decoration ── */
|
||||
.link-row[data-case="Hover Underline"]:hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 4px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── PASS: pinned to one edge but not full-height — a badge, not a stripe ── */
|
||||
.card[data-case="Floating Badge"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── PASS: commented-out CSS is not a live rule ──
|
||||
.card[data-case="Commented Out Stripe"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
*/
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="body">Component with a pseudo-element stripe in its style block</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const label = 'pseudo-stripe fixture';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* FLAG: same silhouette as a border side-tab, drawn as a ::before bar */
|
||||
.card[data-case="Component Left Edge"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 4px;
|
||||
background: #7c3aed;
|
||||
}
|
||||
|
||||
/* PASS: neutral hairline divider */
|
||||
.card[data-case="Component Neutral Divider"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 1px;
|
||||
background: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>radial-spotlight-glow fixture</title>
|
||||
<style>
|
||||
/* jsdom / static-html engine does no layout: every case declares explicit
|
||||
pixel width + height so the size gate can read them. */
|
||||
body { margin: 0; background: #0b0d13; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 24px; padding: 24px; }
|
||||
.case { color: #e6e6e6; }
|
||||
|
||||
/* ───────── SHOULD FLAG: decorative low-opacity chromatic spotlights ───────── */
|
||||
|
||||
/* The exact declaration that evaded the detector: soft blue spotlight fading
|
||||
to transparent, painted on a large mobile hero. */
|
||||
.flag-hero-blue {
|
||||
width: 360px; height: 560px;
|
||||
background: radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26), transparent 44%);
|
||||
}
|
||||
/* Violet accent wash on a full section. */
|
||||
.flag-section-violet {
|
||||
width: 900px; height: 600px;
|
||||
background-image: radial-gradient(circle, rgba(139,92,246,0.30), transparent 60%);
|
||||
}
|
||||
/* Cyan overlay-style glow (the pseudo-element overlay div reflex). */
|
||||
.flag-overlay-cyan {
|
||||
width: 760px; height: 480px;
|
||||
background: radial-gradient(ellipse at top, rgba(34,211,238,0.22), transparent 70%);
|
||||
}
|
||||
/* Single glow drawn with two low-alpha stops of one hue fading out. */
|
||||
.flag-two-stop-rose {
|
||||
width: 640px; height: 400px;
|
||||
background: radial-gradient(circle, rgba(255,90,120,0.28) 0%, rgba(255,90,120,0.12) 45%, transparent 75%);
|
||||
}
|
||||
/* Same tell written as an 8-digit hex (#506fff at alpha 0x3d ≈ 0.24). */
|
||||
.flag-hex-alpha {
|
||||
width: 700px; height: 460px;
|
||||
background: radial-gradient(circle, #506fff3d, transparent 50%);
|
||||
}
|
||||
|
||||
/* ───────── SHOULD PASS: real backgrounds, small elements, neutrals, rich art ───────── */
|
||||
|
||||
/* Opaque radial background — a real surface, not a glow (no transparent end). */
|
||||
.pass-opaque-bg {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, #1e3a8a, #0f172a);
|
||||
}
|
||||
/* Small badge: chromatic low-alpha radial glow, but tiny. */
|
||||
.pass-badge {
|
||||
width: 40px; height: 40px;
|
||||
background: radial-gradient(circle, rgba(80,111,255,0.30), transparent);
|
||||
}
|
||||
/* Avatar-scale light: the subject IS a small light, exempt by size. */
|
||||
.pass-avatar-light {
|
||||
width: 72px; height: 72px;
|
||||
background: radial-gradient(circle, rgba(255,196,64,0.35), transparent 70%);
|
||||
}
|
||||
/* Neutral near-black vignette — grayscale, not the accent-glow tell. */
|
||||
.pass-neutral-vignette {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, rgba(0,0,0,0.40), transparent 80%);
|
||||
}
|
||||
/* Neutral near-white vignette — grayscale, exempt. */
|
||||
.pass-white-vignette {
|
||||
width: 820px; height: 540px;
|
||||
background: radial-gradient(ellipse, rgba(255,255,255,0.35), transparent 70%);
|
||||
}
|
||||
/* Rich three-color radial composition — real color structure, opaque, no fade. */
|
||||
.pass-rich-composition {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, #ff0080 0%, #7928ca 45%, #0070f3 100%);
|
||||
}
|
||||
/* Rich composition that does fade out, but its color stops are opaque. */
|
||||
.pass-rich-transparent {
|
||||
width: 780px; height: 500px;
|
||||
background: radial-gradient(circle, rgba(255,0,128,0.90) 0%, rgba(121,40,202,0.85) 50%, transparent);
|
||||
}
|
||||
/* Opaque center glow (the radial-halo case, alpha ≥ 0.45) — not this rule. */
|
||||
.pass-opaque-center {
|
||||
width: 800px; height: 520px;
|
||||
background: radial-gradient(circle, rgba(80,111,255,0.85), transparent);
|
||||
}
|
||||
/* Low-alpha chromatic wash, but LINEAR, not radial. */
|
||||
.pass-linear-wash {
|
||||
width: 800px; height: 520px;
|
||||
background: linear-gradient(120deg, rgba(80,111,255,0.28), transparent 60%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="grid">
|
||||
<!-- should-flag column -->
|
||||
<div class="case flag-hero-blue" data-name="Hero Spotlight Blue"></div>
|
||||
<div class="case flag-section-violet" data-name="Section Glow Violet"></div>
|
||||
<div class="case flag-overlay-cyan" data-name="Overlay Glow Cyan"></div>
|
||||
<div class="case flag-two-stop-rose" data-name="Two Stop Soft Glow"></div>
|
||||
<div class="case flag-hex-alpha" data-name="Hex Alpha Glow"></div>
|
||||
|
||||
<!-- should-pass column -->
|
||||
<div class="case pass-opaque-bg" data-name="Opaque Radial Background"></div>
|
||||
<div class="case pass-badge" data-name="Small Accent Badge"></div>
|
||||
<div class="case pass-avatar-light" data-name="Avatar Glow Light"></div>
|
||||
<div class="case pass-neutral-vignette" data-name="Neutral Vignette"></div>
|
||||
<div class="case pass-white-vignette" data-name="White Vignette"></div>
|
||||
<div class="case pass-rich-composition" data-name="Rich Radial Composition"></div>
|
||||
<div class="case pass-rich-transparent" data-name="Rich Transparent Composition"></div>
|
||||
<div class="case pass-opaque-center" data-name="Opaque Center Glow"></div>
|
||||
<div class="case pass-linear-wash" data-name="Linear Gradient Wash"></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Regression fixture for issue #407: DOM named-property shadowing.
|
||||
|
||||
HTMLFormElement is [LegacyOverrideBuiltIns], so a named control shadows even
|
||||
builtin getters: a <form> containing <input name="id"> makes `form.id` return
|
||||
the INPUT ELEMENT, not the id string. Reading `.startsWith` on it throws
|
||||
"elId.startsWith is not a function". Every Shopify product form ships an
|
||||
<input name="id"> (the variant id), so this crashed the URL scan of essentially
|
||||
every Shopify page. `<input name="className">` shadows `form.className` the same
|
||||
way. The detector must read these via getAttribute (immune to shadowing).
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Shadowed form.id regression fixture</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #ffffff; color: #1a1a1a; margin: 0; padding: 24px; }
|
||||
.product { max-width: 640px; margin: 0 auto; }
|
||||
h1 { font-size: 28px; margin: 0 0 12px; }
|
||||
.price { font-size: 20px; font-weight: 600; }
|
||||
form { margin-top: 16px; }
|
||||
.add-to-cart { background: #1a1a1a; color: #ffffff; border: 0; padding: 12px 24px; border-radius: 6px; font-size: 16px; cursor: pointer; }
|
||||
label { display: block; margin: 8px 0 4px; font-size: 14px; }
|
||||
select, input[type="number"] { padding: 8px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="product">
|
||||
<h1>Impeccable Test Product</h1>
|
||||
<p class="price">$49.00</p>
|
||||
|
||||
<!-- Shopify-style product form: the <input name="id"> shadows form.id. -->
|
||||
<form method="post" action="/cart/add" id="product-form">
|
||||
<input type="hidden" name="id" value="4001">
|
||||
<input type="hidden" name="className" value="variant-default">
|
||||
<label for="qty">Quantity</label>
|
||||
<input type="number" id="qty" name="quantity" value="1" min="1">
|
||||
<label for="variant">Variant</label>
|
||||
<select id="variant" name="options[Size]">
|
||||
<option value="s">Small</option>
|
||||
<option value="m">Medium</option>
|
||||
<option value="l">Large</option>
|
||||
</select>
|
||||
<button type="submit" class="add-to-cart">Add to cart</button>
|
||||
</form>
|
||||
</main>
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>undersized-ui-text fixture</title>
|
||||
<style>
|
||||
/* Explicit pixel dimensions because jsdom does no layout. */
|
||||
body { font-family: system-ui, sans-serif; font-size: 16px; }
|
||||
.col { display: inline-block; width: 480px; vertical-align: top; }
|
||||
|
||||
/* --- should-flag styles --- */
|
||||
.nav-link { font-size: 8px; }
|
||||
.category { font-size: 8px; }
|
||||
.meta { font-size: 9px; }
|
||||
.btn-small { font-size: 10px; width: 120px; height: 24px; }
|
||||
.cell-small { font-size: 9px; }
|
||||
.caps-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; }
|
||||
.footer-link { font-size: 8px; }
|
||||
|
||||
/* --- should-pass styles --- */
|
||||
.legal { font-size: 10px; line-height: 1.5; } /* non-interactive footer smallprint at 10px */
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); font-size: 4px; }
|
||||
.sup-marker { font-size: 8px; }
|
||||
.sub-marker { font-size: 8px; }
|
||||
.em-parent { font-size: 20px; }
|
||||
.em-child { font-size: 0.6em; } /* resolves to 12px, above the floor */
|
||||
.mock-terminal { width: 400px; }
|
||||
.term-line { font-size: 9px; } /* legitimately small code/terminal text */
|
||||
.normal-link { font-size: 12px; } /* at the floor, allowed */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ================= SHOULD FLAG (functional/UI text below the 11px floor) ================= -->
|
||||
<div class="col" id="should-flag">
|
||||
<h2>Should flag</h2>
|
||||
|
||||
<!-- interactive: nav link at 8px -->
|
||||
<nav aria-label="primary">
|
||||
<a href="/docs" class="nav-link">Flag Nav Link</a>
|
||||
</nav>
|
||||
|
||||
<!-- non-interactive furniture: category label at 8px -->
|
||||
<span class="category">Flag Category</span>
|
||||
|
||||
<!-- non-interactive furniture: meta row at 9px -->
|
||||
<span class="meta">Flag Meta Row</span>
|
||||
|
||||
<!-- interactive: button label at 10px -->
|
||||
<button type="button" class="btn-small">Flag Button</button>
|
||||
|
||||
<!-- structural furniture: table cell at 9px -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><td class="cell-small">Flag Table Cell</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- decorative letterspaced uppercase micro-label at 10px: STILL functional, not exempt -->
|
||||
<span class="caps-label">Flag Caps Label</span>
|
||||
|
||||
<!-- interactive text in a footer stays on the 11px floor: footer link at 8px -->
|
||||
<footer>
|
||||
<a href="/privacy" class="footer-link">Flag Footer Link</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- ================= SHOULD PASS ================= -->
|
||||
<div class="col" id="should-pass">
|
||||
<h2>Should pass</h2>
|
||||
|
||||
<!-- non-interactive legal fine print in a footer at 10px: floor drops to 10px for smallprint -->
|
||||
<footer>
|
||||
<p class="legal">Pass Legal Fine Print copyright 2026 all rights reserved across this jurisdiction</p>
|
||||
</footer>
|
||||
|
||||
<!-- visually-hidden / screen-reader text: never rendered, exempt -->
|
||||
<span class="sr-only">Pass Sr Only</span>
|
||||
|
||||
<!-- sup / sub markers are exempt by tag -->
|
||||
<p>Reference<sup class="sup-marker">Pass Sup Marker</sup> and water<sub class="sub-marker">Pass Sub Marker</sub></p>
|
||||
|
||||
<!-- em-sized text relative to a large parent computes to 12px, above the floor -->
|
||||
<div class="em-parent"><span class="em-child">Pass Em Sized</span></div>
|
||||
|
||||
<!-- code / terminal mock: legitimately small, exempt -->
|
||||
<div class="mock-terminal">
|
||||
<span class="term-line">Pass Terminal Line</span>
|
||||
</div>
|
||||
|
||||
<!-- functional text exactly at the 12px floor is allowed -->
|
||||
<a href="/home" class="normal-link">Pass Normal Link</a>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -157,6 +157,19 @@ for (const name of listFixtures()) {
|
||||
assert.match(body, /localhost:9999\/live\.js/);
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'tanstack-start') {
|
||||
const adapterResult = result.results[0];
|
||||
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
|
||||
const component = readFileSync(join(tmp, adapterResult.componentFile), 'utf-8');
|
||||
assert.equal(adapterResult.inserted, true, 'TanStack Start root document was patched');
|
||||
assert.match(rootDoc, /impeccable-live-tanstack-start/, 'root document got the adapter marker');
|
||||
assert.match(rootDoc, /<ImpeccableLiveRoot \/>/, 'root document renders the mount component');
|
||||
assert.doesNotMatch(rootDoc, /impeccable-live-start/, 'root document must not get the raw script block');
|
||||
assert.doesNotMatch(rootDoc, /localhost:9999\/live\.js/, 'root document must not own live.js directly');
|
||||
assert.match(component, /localhost:9999\/live\.js/, 'mount component loads live.js');
|
||||
assert.match(component, /useEffect/, 'mount component appends the script on mount');
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
@@ -189,6 +202,14 @@ for (const name of listFixtures()) {
|
||||
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
|
||||
return;
|
||||
}
|
||||
if (result.adapter === 'tanstack-start') {
|
||||
const adapterResult = result.results[0];
|
||||
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
|
||||
assert.doesNotMatch(rootDoc, /ImpeccableLiveRoot/);
|
||||
assert.doesNotMatch(rootDoc, /impeccable-live-tanstack-start/);
|
||||
assert.equal(existsSync(join(tmp, adapterResult.componentFile)), false, 'TanStack mount component was removed');
|
||||
return;
|
||||
}
|
||||
for (const r of result.results) {
|
||||
const body = readFileSync(join(tmp, r.file), 'utf-8');
|
||||
assert.doesNotMatch(body, /impeccable-live-start/);
|
||||
|
||||
@@ -113,6 +113,8 @@ When `preActions` is omitted, steer smoke inherits `runtime.preActions` to revea
|
||||
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
|
||||
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
|
||||
| `nuxt-vite7/` | Nuxt 4 `app/` structure + Vue 3 SFC. Live loads through a generated dev-only client plugin. |
|
||||
| `tanstack-router-vite/` | Vite + TanStack Router (code-based SPA). Tracked `index.html` shell inject (the baseline Vite path, no adapter). |
|
||||
| `tanstack-start/` | Vite + TanStack Start (SSR). No static `index.html`; Live patches the `__root.tsx` document to mount a generated dev-only React component that loads the bundle. |
|
||||
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
|
||||
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
|
||||
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"astro": "^6.0.0"
|
||||
"astro": "^7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Astro 6 + Vite 7",
|
||||
"name": "Astro 7 + Vite 7",
|
||||
"config": {
|
||||
"files": ["src/layouts/Layout.astro"],
|
||||
"insertBefore": "</body>",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite 8 + TanStack Router Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "tanstack-router-vite-fixture",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
RouterProvider,
|
||||
Outlet,
|
||||
Link,
|
||||
} from '@tanstack/react-router';
|
||||
import Home from './routes/Home.jsx';
|
||||
import About from './routes/About.jsx';
|
||||
import './styles.css';
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<>
|
||||
<nav className="nav">
|
||||
<Link to="/">Home</Link>
|
||||
<Link to="/about" data-testid="nav-about">About</Link>
|
||||
</nav>
|
||||
<Outlet />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Home });
|
||||
const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: '/about', component: About });
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]);
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function About() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1 className="hero-title">About Page Hero</h1>
|
||||
<p className="hero-hook">Lives on the /about route — only mounts after navigation.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h2>Home</h2>
|
||||
<p>Welcome. The hero we'll edit lives on the About page.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
body { margin: 0; font-family: system-ui, sans-serif; }
|
||||
.nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #eee; }
|
||||
.nav a { color: #111; text-decoration: none; }
|
||||
.page { padding: 2rem; }
|
||||
.hero-title { font-size: 2rem; margin: 0 0 0.5rem; }
|
||||
.hero-hook { color: #555; }
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: '127.0.0.1', strictPort: false },
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Vite 8 + TanStack Router (code-based SPA)",
|
||||
"config": {
|
||||
"files": ["index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": [
|
||||
"index.html",
|
||||
"src/main.jsx",
|
||||
"src/routes/Home.jsx",
|
||||
"src/routes/About.jsx",
|
||||
"src/styles.css",
|
||||
"vite.config.js"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps About hero in routes/About.jsx",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "src/routes/About.jsx"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 120000,
|
||||
"preActions": [
|
||||
{ "type": "click", "selector": "[data-testid='nav-about']" },
|
||||
{ "type": "wait", "selector": "h1.hero-title" }
|
||||
],
|
||||
"reloadProbe": {
|
||||
"preActions": [
|
||||
{ "type": "click", "selector": "[data-testid='nav-about']" },
|
||||
{ "type": "wait", "selector": "h1.hero-title" }
|
||||
],
|
||||
"expectSelector": "h1.hero-title"
|
||||
},
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
},
|
||||
"steer": {
|
||||
"sourceFile": "src/routes/About.jsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
package-lock.json
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "tanstack-start-fixture",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 127.0.0.1",
|
||||
"build": "vite build",
|
||||
"start": "node .output/server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
|
||||
import { routeTree } from './routeTree.gen';
|
||||
|
||||
export function getRouter() {
|
||||
return createTanStackRouter({ routeTree, scrollRestoration: true });
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: ReturnType<typeof getRouter>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ title: 'TanStack Start Fixture' },
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
});
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1 className="hero-title">Start Home Hero</h1>
|
||||
<p className="hero-hook">Server-rendered by TanStack Start.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
|
||||
import viteReact from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
server: { host: '127.0.0.1' },
|
||||
plugins: [tanstackStart(), viteReact()],
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "Vite 8 + TanStack Start (SSR, root-document adapter)",
|
||||
"config": {
|
||||
"files": ["src/routes/__root.tsx"],
|
||||
"insertBefore": "<Scripts",
|
||||
"commentSyntax": "jsx"
|
||||
},
|
||||
"sourceFiles": [
|
||||
"src/routes/__root.tsx",
|
||||
"src/routes/index.tsx",
|
||||
"src/router.tsx",
|
||||
"vite.config.js"
|
||||
],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps index hero in routes/index.tsx",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "src/routes/index.tsx"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"styling": "plain-css",
|
||||
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
|
||||
"devCommand": ["npm", "run", "dev"],
|
||||
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
|
||||
"readyTimeoutMs": 180000,
|
||||
"pickSelector": "h1.hero-title",
|
||||
"reloadProbe": {
|
||||
"expectSelector": "h1.hero-title"
|
||||
},
|
||||
"probe": {
|
||||
"expectLiveInit": true,
|
||||
"expectConsoleClean": true
|
||||
},
|
||||
"steer": {
|
||||
"sourceFile": "src/routes/index.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.nitro/
|
||||
.tanstack/
|
||||
.output/
|
||||
src/routeTree.gen.ts
|
||||
package-lock.json
|
||||
+114
-5
@@ -13,6 +13,7 @@ import {
|
||||
buildClaudeSettingsManifest,
|
||||
buildClaudePluginHooksManifest,
|
||||
buildCodexHooksManifest,
|
||||
buildCodexPluginHooksManifest,
|
||||
buildCursorHooksManifest,
|
||||
buildGitHubHooksManifest,
|
||||
buildGrokHooksManifest,
|
||||
@@ -25,13 +26,46 @@ function readJson(rel) {
|
||||
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'));
|
||||
}
|
||||
|
||||
// The runtime probe every hook command must carry (issue #410): a node below
|
||||
// the engines floor exits the command at 0 instead of dying on ESM parse. The
|
||||
// expected floor comes from package.json engines, so probe and contract cannot
|
||||
// drift apart.
|
||||
const ENGINES_NODE_MAJOR = parseInt(
|
||||
JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).engines.node.replace(/[^\d.]/g, ''),
|
||||
10,
|
||||
);
|
||||
const NODE_PROBE = `process.exit(parseInt(process.versions.node,10)>=${ENGINES_NODE_MAJOR}?0:1)`;
|
||||
|
||||
function expectCommand(command, expectedPath) {
|
||||
assert.equal(typeof command, 'string');
|
||||
assert.match(command, /^node "/);
|
||||
// node-command providers carry the missing-file guard (issue #399: exits 0
|
||||
// when absent, preserves node's exit code when present) plus the runtime
|
||||
// probe. GitHub's portable `$(git rev-parse)` form is guarded too, so it
|
||||
// lands in the same branch.
|
||||
if (command.startsWith('[ ! -f "')) {
|
||||
assert.match(command, /\|\| node "/);
|
||||
assert.ok(command.includes(NODE_PROBE), `missing runtime probe in ${command}`);
|
||||
} else {
|
||||
assert.match(command, /^node "|^bash -c|\$\(git rev-parse/);
|
||||
}
|
||||
assert.ok(command.includes(expectedPath), `missing ${expectedPath} in ${command}`);
|
||||
assert.ok(!command.includes('hook-probe.mjs'), `probe hook still referenced in ${command}`);
|
||||
}
|
||||
|
||||
function manifestCommands(manifest) {
|
||||
const commands = [];
|
||||
const walk = (value) => {
|
||||
if (Array.isArray(value)) { value.forEach(walk); return; }
|
||||
if (value && typeof value === 'object') {
|
||||
if (typeof value.command === 'string') commands.push(value.command);
|
||||
if (typeof value.bash === 'string') commands.push(value.bash);
|
||||
Object.values(value).forEach(walk);
|
||||
}
|
||||
};
|
||||
walk(manifest.hooks);
|
||||
return commands;
|
||||
}
|
||||
|
||||
describe('hook manifest builders', () => {
|
||||
it('builds Claude project settings for the real detector hook', () => {
|
||||
const manifest = buildClaudeSettingsManifest();
|
||||
@@ -56,6 +90,9 @@ describe('hook manifest builders', () => {
|
||||
});
|
||||
|
||||
it('builds Codex project-local hooks for the real detector hook', () => {
|
||||
// Default install dir is `.codex`: a `.codex`-directory install keeps the
|
||||
// skill payload at `.codex/skills/...`, so the hook must point there (not at
|
||||
// a hardcoded `.agents`, which no-ops on such installs).
|
||||
const manifest = buildCodexHooksManifest();
|
||||
assert.equal(manifest.description, undefined);
|
||||
const group = manifest.hooks.PostToolUse[0];
|
||||
@@ -65,7 +102,7 @@ describe('hook manifest builders', () => {
|
||||
assert.equal(handler.type, 'command');
|
||||
assert.equal(handler.timeout, 5);
|
||||
assert.equal(handler.statusMessage, 'Checking UI changes');
|
||||
expectCommand(handler.command, '.agents/skills/impeccable/scripts/hook.mjs');
|
||||
expectCommand(handler.command, '.codex/skills/impeccable/scripts/hook.mjs');
|
||||
assert.ok(!handler.command.includes('git rev-parse --show-toplevel'));
|
||||
assert.ok(!handler.command.includes('${PLUGIN_ROOT}'));
|
||||
assert.equal(manifest.hooks.SessionStart, undefined);
|
||||
@@ -74,7 +111,31 @@ describe('hook manifest builders', () => {
|
||||
// pass too.
|
||||
const stop = manifest.hooks.Stop[0].hooks[0];
|
||||
assert.equal(stop.timeout, 30);
|
||||
expectCommand(stop.command, '.agents/skills/impeccable/scripts/hook.mjs');
|
||||
expectCommand(stop.command, '.codex/skills/impeccable/scripts/hook.mjs');
|
||||
});
|
||||
|
||||
it('derives the Codex hook payload path from the install dir', () => {
|
||||
// Each install dir gets a manifest pointing at its own skills payload: a
|
||||
// `.codex`-directory install at `.codex/skills`, a `.agents` (Codex repo
|
||||
// skills) install at `.agents/skills`.
|
||||
const codexDir = buildCodexHooksManifest('.codex');
|
||||
expectCommand(codexDir.hooks.PostToolUse[0].hooks[0].command, '.codex/skills/impeccable/scripts/hook.mjs');
|
||||
expectCommand(codexDir.hooks.Stop[0].hooks[0].command, '.codex/skills/impeccable/scripts/hook.mjs');
|
||||
|
||||
const agentsDir = buildCodexHooksManifest('.agents');
|
||||
expectCommand(agentsDir.hooks.PostToolUse[0].hooks[0].command, '.agents/skills/impeccable/scripts/hook.mjs');
|
||||
expectCommand(agentsDir.hooks.Stop[0].hooks[0].command, '.agents/skills/impeccable/scripts/hook.mjs');
|
||||
assert.ok(!agentsDir.hooks.PostToolUse[0].hooks[0].command.includes('.codex/skills'));
|
||||
|
||||
// hooksJsonFor threads the provider's configDir through to the builder.
|
||||
expectCommand(
|
||||
hooksJsonFor('codex', { configDir: '.agents' }).hooks.PostToolUse[0].hooks[0].command,
|
||||
'.agents/skills/impeccable/scripts/hook.mjs',
|
||||
);
|
||||
expectCommand(
|
||||
hooksJsonFor('codex').hooks.PostToolUse[0].hooks[0].command,
|
||||
'.codex/skills/impeccable/scripts/hook.mjs',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds one Cursor pre-write blocking hook', () => {
|
||||
@@ -131,6 +192,40 @@ describe('hook manifest builders', () => {
|
||||
expectCommand(stop.command, '.grok/skills/impeccable/scripts/hook.mjs');
|
||||
});
|
||||
|
||||
it('probes the node runtime everywhere, and notices only where a channel exists', () => {
|
||||
// Claude Code and Codex render a `systemMessage` from hook stdout, so their
|
||||
// manifests carry the one-time unsupported-runtime notice. Cursor (output is
|
||||
// permission-shaped; a message would block the edit), Grok (stdout ignored),
|
||||
// and Copilot (contract unconfirmed) get the silent probe only.
|
||||
const withNotice = [
|
||||
buildClaudeSettingsManifest(),
|
||||
buildClaudePluginHooksManifest(),
|
||||
buildCodexHooksManifest(),
|
||||
buildCodexPluginHooksManifest(),
|
||||
];
|
||||
const probeOnly = [
|
||||
buildCursorHooksManifest(),
|
||||
buildGitHubHooksManifest(),
|
||||
buildGrokHooksManifest(),
|
||||
];
|
||||
for (const manifest of [...withNotice, ...probeOnly]) {
|
||||
for (const command of manifestCommands(manifest)) {
|
||||
assert.ok(command.includes(NODE_PROBE), `missing runtime probe in ${command}`);
|
||||
}
|
||||
}
|
||||
for (const manifest of withNotice) {
|
||||
for (const command of manifestCommands(manifest)) {
|
||||
assert.ok(command.includes('systemMessage'), `missing notice in ${command}`);
|
||||
assert.ok(command.includes('node-unsupported'), `missing once-only marker in ${command}`);
|
||||
}
|
||||
}
|
||||
for (const manifest of probeOnly) {
|
||||
for (const command of manifestCommands(manifest)) {
|
||||
assert.ok(!command.includes('systemMessage'), `unexpected notice in ${command}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('routes supported hook builders and leaves other providers alone', () => {
|
||||
assert.ok(hooksJsonFor('claude'));
|
||||
assert.ok(hooksJsonFor('codex'));
|
||||
@@ -185,11 +280,25 @@ describe('generated hook artifacts in repo', () => {
|
||||
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.cursor/skills/impeccable/scripts/detector/detect-antipatterns.mjs')));
|
||||
});
|
||||
|
||||
it('Codex project hooks reference hook.mjs in the .agents skill payload', () => {
|
||||
it('Codex project hooks reference hook.mjs in the .codex skill payload', () => {
|
||||
// The committed `.codex/hooks.json` is the distribution artifact for a
|
||||
// `.codex`-directory install, whose skill payload lives at `.codex/skills/`
|
||||
// (issue: it previously hardcoded `.agents/skills`, so the guarded hook
|
||||
// no-opped on `.codex` installs). CLI installs that lay the skill down at
|
||||
// `.agents/skills` rewrite the command to that path at install time.
|
||||
const manifest = readJson('.codex/hooks.json');
|
||||
const handler = manifest.hooks.PostToolUse[0].hooks[0];
|
||||
|
||||
expectCommand(handler.command, '.agents/skills/impeccable/scripts/hook.mjs');
|
||||
expectCommand(handler.command, '.codex/skills/impeccable/scripts/hook.mjs');
|
||||
assert.ok(!handler.command.includes('.agents/skills'));
|
||||
|
||||
// The self-consistent Codex bundle at `dist/codex/.codex/skills/` is a build
|
||||
// artifact, not a tracked repo file; `bun run build` emits it and
|
||||
// build.test.js verifies it there. This suite runs before the build (CI's
|
||||
// `test:core` precedes the Build step), so it asserts only tracked outputs.
|
||||
|
||||
// The repo ships the Codex skill payload at `.agents/skills` (the
|
||||
// layout CLI installs use, and where the rewritten command resolves).
|
||||
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook.mjs')));
|
||||
assert.ok(fs.existsSync(path.join(REPO_ROOT, '.agents/skills/impeccable/scripts/hook-lib.mjs')));
|
||||
|
||||
+143
-17
@@ -53,6 +53,8 @@ import {
|
||||
IMMEDIATE_TIER_RULES,
|
||||
splitFindingsByTier,
|
||||
perEditTieringActive,
|
||||
ADVISORY_RULES,
|
||||
isAdvisoryFinding,
|
||||
payload,
|
||||
extractFindingIgnoreValue,
|
||||
resolveProjectPlatform,
|
||||
@@ -416,6 +418,39 @@ describe('filterFindings()', () => {
|
||||
assert.deepEqual(filtered.map((f) => f.antipattern), ['gradient-text', 'overused-font']);
|
||||
});
|
||||
|
||||
it('drops advisory-rule findings by default', () => {
|
||||
const findings = [
|
||||
finding('side-tab', 1),
|
||||
finding('em-dash-overuse', 2),
|
||||
finding('gradient-text', 3),
|
||||
];
|
||||
const filtered = filterFindings(findings, '', '.html', {
|
||||
ignoreRules: [],
|
||||
limits: DEFAULT_CONFIG.limits,
|
||||
});
|
||||
assert.deepEqual(filtered.map((f) => f.antipattern), ['side-tab', 'gradient-text']);
|
||||
});
|
||||
|
||||
it('keeps advisory-rule findings when advisoryRules is "include"', () => {
|
||||
const findings = [
|
||||
finding('side-tab', 1),
|
||||
finding('em-dash-overuse', 2),
|
||||
];
|
||||
const filtered = filterFindings(findings, '', '.html', {
|
||||
ignoreRules: [],
|
||||
advisoryRules: 'include',
|
||||
limits: DEFAULT_CONFIG.limits,
|
||||
});
|
||||
assert.deepEqual(filtered.map((f) => f.antipattern), ['side-tab', 'em-dash-overuse']);
|
||||
});
|
||||
|
||||
it('recognizes advisory findings by rule id or explicit flag', () => {
|
||||
assert.ok(ADVISORY_RULES.has('em-dash-overuse'));
|
||||
assert.equal(isAdvisoryFinding(finding('em-dash-overuse', 1)), true);
|
||||
assert.equal(isAdvisoryFinding({ antipattern: 'anything', advisory: true }), true);
|
||||
assert.equal(isAdvisoryFinding(finding('side-tab', 1)), false);
|
||||
});
|
||||
|
||||
it('does not treat source comments as hook suppression', () => {
|
||||
const content = [
|
||||
'/* impeccable: ignore * */',
|
||||
@@ -3147,12 +3182,12 @@ describe('runHook() — per-edit tiering', () => {
|
||||
it('splitFindingsByTier partitions on IMMEDIATE_TIER_RULES', () => {
|
||||
const { immediate, deferred } = splitFindingsByTier([
|
||||
finding('dark-glow', 1),
|
||||
finding('em-dash-overuse', 2),
|
||||
finding('marketing-buzzword', 2),
|
||||
finding('low-contrast', 3),
|
||||
finding('side-tab', 4),
|
||||
]);
|
||||
assert.deepEqual(immediate.map((f) => f.antipattern), ['dark-glow', 'low-contrast']);
|
||||
assert.deepEqual(deferred.map((f) => f.antipattern), ['em-dash-overuse', 'side-tab']);
|
||||
assert.deepEqual(deferred.map((f) => f.antipattern), ['marketing-buzzword', 'side-tab']);
|
||||
for (const f of immediate) assert.ok(IMMEDIATE_TIER_RULES.has(f.antipattern));
|
||||
});
|
||||
|
||||
@@ -3167,13 +3202,13 @@ describe('runHook() — per-edit tiering', () => {
|
||||
it('surfaces immediate-tier findings per edit and defers copy-tier ones', async () => {
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([
|
||||
finding('em-dash-overuse', 3),
|
||||
finding('marketing-buzzword', 3),
|
||||
finding('dark-glow', 5),
|
||||
]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file)), env: {}, cwd, detector: det });
|
||||
assert.match(r.stdout, /Design hook findings requiring review/);
|
||||
assert.match(r.stdout, /dark-glow/);
|
||||
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
|
||||
assert.doesNotMatch(r.stdout, /marketing-buzzword/);
|
||||
assert.equal(r.audit.deferred, 1);
|
||||
|
||||
const cache = readCache(cwd);
|
||||
@@ -3182,10 +3217,10 @@ describe('runHook() — per-edit tiering', () => {
|
||||
|
||||
it('emits a clean ack when all findings are deferred, and still marks the file touched', async () => {
|
||||
const file = write('src/Copy.tsx', 'noop');
|
||||
const det = fakeDetector([finding('em-dash-overuse', 2)]);
|
||||
const det = fakeDetector([finding('marketing-buzzword', 2)]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-deferred-only')), env: {}, cwd, detector: det });
|
||||
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
||||
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
|
||||
assert.doesNotMatch(r.stdout, /marketing-buzzword/);
|
||||
assert.equal(r.audit.deferred, 1);
|
||||
|
||||
// The touched-file entry is what lets the Stop deep pass find this file.
|
||||
@@ -3198,10 +3233,10 @@ describe('runHook() — per-edit tiering', () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({ hook: { perEditRules: 'all' } }));
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('em-dash-overuse', 2)]);
|
||||
const det = fakeDetector([finding('marketing-buzzword', 2)]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'tier-all')), env: {}, cwd, detector: det });
|
||||
assert.match(r.stdout, /Design hook findings requiring review/);
|
||||
assert.match(r.stdout, /em-dash-overuse/);
|
||||
assert.match(r.stdout, /marketing-buzzword/);
|
||||
assert.equal(r.audit.deferred, undefined);
|
||||
});
|
||||
|
||||
@@ -3213,11 +3248,33 @@ describe('runHook() — per-edit tiering', () => {
|
||||
toolName: 'edit',
|
||||
toolArgs: JSON.stringify({ path: file }),
|
||||
};
|
||||
const det = fakeDetector([finding('em-dash-overuse', 2)]);
|
||||
const det = fakeDetector([finding('marketing-buzzword', 2)]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(githubEvent), env: {}, cwd, detector: det });
|
||||
assert.equal(r.audit.harness, 'github');
|
||||
const out = JSON.parse(r.stdout);
|
||||
assert.match(out.additionalContext, /em-dash-overuse/);
|
||||
assert.match(out.additionalContext, /marketing-buzzword/);
|
||||
});
|
||||
|
||||
it('skips advisory findings per edit by default and never nags about them', async () => {
|
||||
const file = write('src/Copy.tsx', 'noop');
|
||||
const det = fakeDetector([finding('em-dash-overuse', 3)]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'adv-skip')), env: {}, cwd, detector: det });
|
||||
// The only finding is advisory, so the file scans clean.
|
||||
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
||||
assert.doesNotMatch(r.stdout, /em-dash-overuse/);
|
||||
});
|
||||
|
||||
it('includes advisory findings per edit when detector.advisoryRules is "include"', async () => {
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
||||
hook: { perEditRules: 'all' },
|
||||
detector: { advisoryRules: 'include' },
|
||||
}));
|
||||
const file = write('src/Copy.tsx', 'noop');
|
||||
const det = fakeDetector([finding('em-dash-overuse', 3)]);
|
||||
const r = await runHook({ stdinJson: JSON.stringify(eventFor(file, 'adv-include')), env: {}, cwd, detector: det });
|
||||
assert.match(r.stdout, /Design hook findings requiring review/);
|
||||
assert.match(r.stdout, /em-dash-overuse/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3257,14 +3314,14 @@ describe('runStopHook()', () => {
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([
|
||||
finding('dark-glow', 5),
|
||||
finding('em-dash-overuse', 3),
|
||||
finding('marketing-buzzword', 3),
|
||||
finding('side-tab', 7),
|
||||
]);
|
||||
|
||||
// Per-edit pass: surfaces dark-glow, defers the other two.
|
||||
const edit = await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
|
||||
assert.match(edit.stdout, /dark-glow/);
|
||||
assert.doesNotMatch(edit.stdout, /em-dash-overuse/);
|
||||
assert.doesNotMatch(edit.stdout, /marketing-buzzword/);
|
||||
|
||||
// Stop deep pass: surfaces exactly the deferred remainder.
|
||||
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
|
||||
@@ -3272,7 +3329,7 @@ describe('runStopHook()', () => {
|
||||
assert.equal(stop.audit.emitted, true);
|
||||
const out = JSON.parse(stop.stdout);
|
||||
assert.equal(out.hookSpecificOutput.hookEventName, 'Stop');
|
||||
assert.match(out.hookSpecificOutput.additionalContext, /em-dash-overuse/);
|
||||
assert.match(out.hookSpecificOutput.additionalContext, /marketing-buzzword/);
|
||||
assert.match(out.hookSpecificOutput.additionalContext, /side-tab/);
|
||||
assert.doesNotMatch(out.hookSpecificOutput.additionalContext, /dark-glow/);
|
||||
assert.equal(stop.emission.kind, 'stop-deep-pass');
|
||||
@@ -3288,11 +3345,11 @@ describe('runStopHook()', () => {
|
||||
it('a second Stop fire is silent: deep-pass findings are remembered', async () => {
|
||||
const sid = 'stop-twice';
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('em-dash-overuse', 3)]);
|
||||
const det = fakeDetector([finding('marketing-buzzword', 3)]);
|
||||
|
||||
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
|
||||
const first = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
|
||||
assert.match(first.stdout, /em-dash-overuse/);
|
||||
assert.match(first.stdout, /marketing-buzzword/);
|
||||
|
||||
const second = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
|
||||
assert.equal(second.stdout, '');
|
||||
@@ -3303,15 +3360,84 @@ describe('runStopHook()', () => {
|
||||
const sid = 'stop-ignored';
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
||||
detector: { ignoreRules: ['em-dash-overuse'] },
|
||||
detector: { ignoreRules: ['marketing-buzzword'] },
|
||||
}));
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('marketing-buzzword', 3)]);
|
||||
|
||||
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
|
||||
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
|
||||
assert.equal(stop.stdout, '');
|
||||
assert.equal(stop.audit.skipped, 'stop-clean');
|
||||
});
|
||||
|
||||
it('skips advisory findings in the deep pass by default', async () => {
|
||||
const sid = 'stop-advisory';
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('em-dash-overuse', 3)]);
|
||||
|
||||
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
|
||||
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
|
||||
// Silent either way: the advisory finding is dropped at the per-edit pass, so
|
||||
// the file is never recorded as touched, and the deep pass has nothing to say.
|
||||
assert.equal(stop.stdout, '');
|
||||
assert.ok(['stop-clean', 'no-touched-files'].includes(stop.audit.skipped));
|
||||
});
|
||||
|
||||
it('surfaces advisory findings in the deep pass when advisoryRules is "include"', async () => {
|
||||
const sid = 'stop-advisory-include';
|
||||
fs.mkdirSync(path.join(cwd, '.impeccable'), { recursive: true });
|
||||
fs.writeFileSync(getConfigPath(cwd), JSON.stringify({
|
||||
detector: { advisoryRules: 'include' },
|
||||
}));
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('em-dash-overuse', 3)]);
|
||||
|
||||
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
|
||||
const stop = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
|
||||
assert.equal(stop.audit.emitted, true);
|
||||
const out = JSON.parse(stop.stdout);
|
||||
assert.match(out.hookSpecificOutput.additionalContext, /em-dash-overuse/);
|
||||
});
|
||||
|
||||
it('re-invoked with stop_hook_active:true exits 0 and silent even with pending findings (issue #400)', async () => {
|
||||
const sid = 'stop-active';
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('marketing-buzzword', 3)]);
|
||||
|
||||
// Prime a real touched-file + finding so a plain Stop pass would fire.
|
||||
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
|
||||
|
||||
// Re-invocation after the previous fire kept the turn alive: the contract
|
||||
// says exit clean, no re-block, before scanning.
|
||||
const active = { ...stopEvent(sid), stop_hook_active: true };
|
||||
const stop = await runStopHook({ stdinJson: JSON.stringify(active), env: {}, cwd, detector: det });
|
||||
assert.equal(stop.exitCode, 0);
|
||||
assert.equal(stop.stdout, '');
|
||||
assert.equal(stop.audit.skipped, 'stop-clean');
|
||||
assert.equal(stop.audit.emitted, undefined);
|
||||
assert.equal(stop.audit.skipped, 'stop-hook-active');
|
||||
});
|
||||
|
||||
it('stop_hook_active:false or absent still runs the deep pass as before', async () => {
|
||||
const sid = 'stop-inactive';
|
||||
const file = write('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('marketing-buzzword', 3)]);
|
||||
await runHook({ stdinJson: JSON.stringify(editEvent(file, sid)), env: {}, cwd, detector: det });
|
||||
|
||||
// Explicit false (the stopEvent default).
|
||||
const explicitFalse = await runStopHook({ stdinJson: JSON.stringify(stopEvent(sid)), env: {}, cwd, detector: det });
|
||||
assert.equal(explicitFalse.audit.emitted, true);
|
||||
assert.match(explicitFalse.stdout, /marketing-buzzword/);
|
||||
|
||||
// Field absent entirely (legacy / non-Claude-Code payloads): same behavior.
|
||||
const sid2 = 'stop-absent';
|
||||
const file2 = write('src/Card2.tsx', 'noop');
|
||||
await runHook({ stdinJson: JSON.stringify(editEvent(file2, sid2)), env: {}, cwd, detector: det });
|
||||
const ev = stopEvent(sid2);
|
||||
delete ev.stop_hook_active;
|
||||
const absent = await runStopHook({ stdinJson: JSON.stringify(ev), env: {}, cwd, detector: det });
|
||||
assert.equal(absent.audit.emitted, true);
|
||||
assert.match(absent.stdout, /marketing-buzzword/);
|
||||
});
|
||||
|
||||
it('honors kill switches and the re-entrancy guard', async () => {
|
||||
|
||||
@@ -273,6 +273,73 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('SSE error reply clears the durable session checkpoint like discarded', () => {
|
||||
// Issue #362: `live-poll.mjs --reply <id> error "..."` is the documented
|
||||
// abort flow in reference/live.md, and an agent error reply is terminal
|
||||
// for the session it names. The 'error' case used to reset only the UI
|
||||
// (hideBar + PICKING) while the localStorage checkpoint written for the
|
||||
// GENERATING phase survived — so every reload resurrected a dead session
|
||||
// the server no longer knew about, until the user hand-cleared the
|
||||
// impeccable-live* keys in the console. The error path must tear down the
|
||||
// named session exactly like 'discarded' does (markSessionHandled +
|
||||
// cleanup, which includes clearSession), and drop a stored-but-not-
|
||||
// current checkpoint that matches the errored id.
|
||||
const errorCase = SOURCE.match(/case 'error':[\s\S]{0,2500}?setLiveState\('PICKING'\);\s*break;/);
|
||||
assert.ok(errorCase, 'expected an SSE case \'error\' handler in live-browser.js');
|
||||
assert.match(
|
||||
errorCase[0],
|
||||
/if \(msg\.id && msg\.id === currentSessionId\) \{[\s\S]{0,160}?markSessionHandled\(\);[\s\S]{0,80}?cleanup\(\);[\s\S]{0,80}?break;/,
|
||||
'an error reply naming the current session must run the same markSessionHandled + cleanup teardown as \'discarded\' so the durable checkpoint is cleared',
|
||||
);
|
||||
assert.match(
|
||||
errorCase[0],
|
||||
/if \(msg\.id && loadSession\(\)\?\.id === msg\.id\) clearSession\(\);/,
|
||||
'an error reply naming a stored-but-not-current session must drop that checkpoint so a reload cannot resurrect it',
|
||||
);
|
||||
});
|
||||
|
||||
it('a late accept failure is recognized after the optimistic teardown (#384)', () => {
|
||||
// Accept is optimistic: POST /events acknowledging the intent schedules
|
||||
// cleanupAcceptedSession(), which nulls pendingAcceptedSession before
|
||||
// live-accept.mjs has actually run. When the accept later fails (missing
|
||||
// markers, preview error, receipt conflict, source_locked), the SSE
|
||||
// 'error' guard keyed on pendingAcceptedSession could no longer match,
|
||||
// so the user got only the generic error toast with no hint that their
|
||||
// variant was never written. An awaitingAcceptResult id must be set on
|
||||
// the optimistic success path, survive cleanupAcceptedSession, be
|
||||
// matched in the 'error' case with an explicit not-saved message, and
|
||||
// be released when the real accept result arrives.
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/awaitingAcceptResult = \{ id: acceptedSessionId \};[\s\S]{0,400}?scheduleAcceptCleanup\(pending\);/,
|
||||
'the optimistic POST-success path must record awaitingAcceptResult before scheduling the teardown',
|
||||
);
|
||||
const errorCase = SOURCE.match(/case 'error':[\s\S]{0,2600}?setLiveState\('PICKING'\);\s*break;/);
|
||||
assert.ok(errorCase, 'expected an SSE case \'error\' handler in live-browser.js');
|
||||
assert.match(
|
||||
errorCase[0],
|
||||
/if \(awaitingAcceptResult\?\.id && msg\.id === awaitingAcceptResult\.id\) \{[\s\S]{0,700}?awaitingAcceptResult = null;[\s\S]{0,700}?may not have been saved[\s\S]{0,300}?break;/,
|
||||
'an error naming the awaited accept must clear the marker and warn that the variant may not have been saved (hedged: a carbonize-phase failure fires this after the source WAS promoted)',
|
||||
);
|
||||
// Accept unlocks at the first variant, so a late generation agent_done
|
||||
// for the same session id can arrive after Accept; only a carbonize
|
||||
// agent_done is provably accept-side and may close the window.
|
||||
const agentDoneCase = SOURCE.match(/case 'agent_done':[\s\S]{0,1200}?break;/);
|
||||
assert.ok(agentDoneCase, 'expected an SSE case \'agent_done\' handler in live-browser.js');
|
||||
assert.match(
|
||||
agentDoneCase[0],
|
||||
/msg\.data\?\.carbonize === true && awaitingAcceptResult\?\.id && msg\.id === awaitingAcceptResult\.id/,
|
||||
'agent_done must only release the awaited accept marker for carbonize completions, or a late generation agent_done reopens the #384 hole',
|
||||
);
|
||||
const cleanupFn = SOURCE.match(/function cleanupAcceptedSession\(\) \{[\s\S]{0,1200}?\n \}/);
|
||||
assert.ok(cleanupFn, 'expected cleanupAcceptedSession in live-browser.js');
|
||||
assert.doesNotMatch(
|
||||
cleanupFn[0],
|
||||
/awaitingAcceptResult\s*=/,
|
||||
'cleanupAcceptedSession must not clear awaitingAcceptResult - surviving the teardown is the point',
|
||||
);
|
||||
});
|
||||
|
||||
it('handleServerLost preserves the current recoverable phase', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
@@ -286,6 +353,41 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('server-lost toast frames the disconnect as resumable, not ended', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/Live server disconnected\. Session ended\./,
|
||||
'the "Session ended" copy made agents rationalize bailing to direct edits; the session is resumable',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/Live server connection lost\. Your session is saved;[^']*restart live-poll\.mjs to continue\./,
|
||||
'server-lost toast should tell the user the session is saved and how to continue',
|
||||
);
|
||||
});
|
||||
|
||||
it('the agent-phase progress bar advances monotonically', () => {
|
||||
// A behind/resumed checkpoint must not move the visible bar backward.
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
/generationPhase = msg\.phase \|\| generationPhase;/,
|
||||
'raw phase assignment lets a behind checkpoint regress the visible bar to an earlier phase',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'agent_phase':[\s\S]{0,400}?if \(shouldAdvancePhase\(generationPhase, msg\.phase\)\) generationPhase = msg\.phase;/,
|
||||
'agent_phase should only advance the phase when it moves forward',
|
||||
);
|
||||
// The rank table must order the lifecycle so scaffolding/source_ready sit
|
||||
// below generating and the reviewable phases.
|
||||
assert.match(SOURCE, /function shouldAdvancePhase\(current, next\)/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/scaffolding: 2,[\s\S]{0,120}?source_ready: 4,[\s\S]{0,120}?(generation_ready|generating): 5,/,
|
||||
'scaffolding and source_ready must rank below generating',
|
||||
);
|
||||
});
|
||||
|
||||
it('source reinjection preserves the visible variant after cycling', () => {
|
||||
assert.doesNotMatch(
|
||||
SOURCE,
|
||||
@@ -920,10 +1022,13 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
assert.match(SOURCE, /tune\.disabled = true/, 'pending Tune must be visibly loading but non-interactive');
|
||||
assert.match(SOURCE, /Tune controls are ready\./, 'parameter arrival needs a clear ready indication');
|
||||
// Source-mode DOM injection is gated to the `done` branch (it races
|
||||
// framework ownership mid-generation), but a params-only publication must
|
||||
// still flip the Tune controls into their loading state on the checkpoint.
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/msg\.publicationKind !== 'params' && arrivedVariants >= targetArrived/,
|
||||
'a params-only publication must refresh even though the variant count is unchanged',
|
||||
/case 'variant_progress':[\s\S]{0,120}?if \(msg\.publicationKind === 'params'\) parameterGenerationState = 'loading';/,
|
||||
'a params-only publication must mark Tune controls loading even though the variant count is unchanged',
|
||||
);
|
||||
assert.match(SOURCE, /revisionDomain: 'browser'/, 'browser checkpoints must use their own revision domain');
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('live-browser source contracts', () => {
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'complete':\s*case 'accept':\s*if \(maybeCompleteAcceptedSession\(msg\)\) break;/,
|
||||
/case 'complete':\s*case 'accept':[\s\S]{0,400}?if \(maybeCompleteAcceptedSession\(msg\)\) break;/,
|
||||
'final accepted DOM cleanup should be driven by explicit complete or harness accept replies',
|
||||
);
|
||||
assert.match(
|
||||
@@ -340,8 +340,8 @@ describe('live-browser source contracts', () => {
|
||||
assert.match(agentDoneSource, /maybeCompleteAcceptedSession\(msg\)/);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,80}?currentSessionId = id8\(\);/,
|
||||
'starting a new generation should clear any stale accepted-session sentinel first',
|
||||
/function handleGo\(\)[\s\S]{0,900}?pendingAcceptedSession = null;[\s\S]{0,400}?awaitingAcceptResult = null;[\s\S]{0,120}?currentSessionId = id8\(\);/,
|
||||
'starting a new generation should clear any stale accepted-session sentinel (and the awaited accept-result marker, #384) first',
|
||||
);
|
||||
const handleAcceptStart = SOURCE.indexOf('function handleAccept()');
|
||||
const maybeCompleteStart = SOURCE.indexOf('function maybeCompleteAcceptedSession', handleAcceptStart);
|
||||
@@ -421,11 +421,30 @@ describe('live-browser source contracts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('loads progressive source checkpoints through the no-HMR fallback', () => {
|
||||
it('does not source-inject per variant_progress checkpoint (HMR owns mid-generation reconciliation)', () => {
|
||||
// Isolate the variant_progress handler body.
|
||||
const progressCase = SOURCE.match(/case 'variant_progress':[\s\S]*?break;/);
|
||||
assert.ok(progressCase, 'variant_progress case should exist');
|
||||
assert.doesNotMatch(
|
||||
progressCase[0],
|
||||
/injectVariantsFromSource\(/,
|
||||
'source-mode progress must not source-inject per checkpoint; it races React/Vue ownership and triggers removeChild errors',
|
||||
);
|
||||
// The svelte-component progressive path stays.
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/case 'variant_progress':[\s\S]{0,1400}?msg\.previewMode === 'source'[\s\S]{0,1000}?arrivedVariants >= targetArrived[\s\S]{0,260}?injectVariantsFromSource\(msg\.previewFile \|\| msg\.file, msg\.id\)/,
|
||||
'source-mode progress should let framework HMR settle before using the no-HMR fallback',
|
||||
progressCase[0],
|
||||
/injectSvelteComponentsFromManifest\(msg\.previewFile, msg\.id\)/,
|
||||
'component-preview progressive delivery must still stream per checkpoint',
|
||||
);
|
||||
});
|
||||
|
||||
it('source-injects only on the final done branch, keeping the 750ms settle', () => {
|
||||
const doneCase = SOURCE.match(/case 'done':[\s\S]*?break;\n {8}case /);
|
||||
assert.ok(doneCase, 'done case should exist');
|
||||
assert.match(
|
||||
doneCase[0],
|
||||
/setTimeout\([\s\S]{0,260}?injectVariantsFromSource\(msg\.file, msg\.id, \{ generationCompleted: true \}\)[\s\S]{0,40}?\}, 750\)/,
|
||||
'done should source-inject via the 750ms fallback for harnesses without HMR',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+61
-14
@@ -1292,14 +1292,12 @@ function renderVariantsBlock({ sessionId, indent, output, commentSyntax, file, s
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the wrapped file, find the "insert below this line" marker, splice in
|
||||
* the rendered variants block, write back.
|
||||
* Splice the rendered variants block into an array of wrapper lines at the
|
||||
* "insert below this line" marker. Pure: returns the new lines array. Used
|
||||
* both against a whole file (wrapper already in source) and against a
|
||||
* standalone wrapper block (deferred source write, agent writes it now).
|
||||
*/
|
||||
async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
|
||||
const filePath = path.join(tmp, wrapInfo.file);
|
||||
const src = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = src.split('\n');
|
||||
|
||||
function spliceVariantsIntoLines(lines, { sessionId, output, commentSyntax, file, styleMode }) {
|
||||
// Find the "Variants: insert below this line" comment line — definitive
|
||||
// marker, robust to any indentation off-by-one. Matches in any comment
|
||||
// style (HTML / JSX / Astro).
|
||||
@@ -1307,7 +1305,7 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
|
||||
l.includes('Variants: insert below this line'),
|
||||
);
|
||||
if (markerIdx === -1) {
|
||||
throw new Error('insert marker not found in ' + wrapInfo.file);
|
||||
throw new Error('insert marker not found in ' + file);
|
||||
}
|
||||
|
||||
const indent = (lines[markerIdx].match(/^\s*/) || [''])[0];
|
||||
@@ -1320,26 +1318,73 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
|
||||
sessionId,
|
||||
indent: wrapperIndent,
|
||||
output,
|
||||
commentSyntax: wrapInfo.commentSyntax,
|
||||
file: wrapInfo.file,
|
||||
styleMode: wrapInfo.styleMode,
|
||||
commentSyntax,
|
||||
file,
|
||||
styleMode,
|
||||
});
|
||||
|
||||
const endMarkerIdx = lines.findIndex((line, index) =>
|
||||
index > markerIdx && line.includes('impeccable-variants-end ' + sessionId),
|
||||
);
|
||||
if (endMarkerIdx === -1) {
|
||||
throw new Error('end marker not found in ' + wrapInfo.file);
|
||||
throw new Error('end marker not found in ' + file);
|
||||
}
|
||||
const tailIdx = wrapInfo.commentSyntax.open === '{/*'
|
||||
const tailIdx = commentSyntax.open === '{/*'
|
||||
? endMarkerIdx
|
||||
: endMarkerIdx - 1;
|
||||
|
||||
const next = [
|
||||
return [
|
||||
...lines.slice(0, markerIdx + 1),
|
||||
block,
|
||||
...lines.slice(tailIdx),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the wrapped file, find the "insert below this line" marker, splice in
|
||||
* the rendered variants block, write back. Used when the wrapper is already
|
||||
* present in source (agent's own wrap fallback, no preflight).
|
||||
*/
|
||||
async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
|
||||
const filePath = path.join(tmp, wrapInfo.file);
|
||||
const src = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = src.split('\n');
|
||||
const next = spliceVariantsIntoLines(lines, {
|
||||
sessionId,
|
||||
output,
|
||||
commentSyntax: wrapInfo.commentSyntax,
|
||||
file: wrapInfo.file,
|
||||
styleMode: wrapInfo.styleMode,
|
||||
});
|
||||
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deferred source write (preflight computed the scaffold but left source
|
||||
* untouched). Splice the variants into the scaffold's `wrapperBlock`, then
|
||||
* replace the picked element's source range with the result in ONE write —
|
||||
* the 3.5 atomic single-edit semantics. `replaceEndLine < replaceStartLine`
|
||||
* expresses a pure insertion (insert mode).
|
||||
*/
|
||||
async function writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId, output }) {
|
||||
const filePath = path.join(tmp, wrapInfo.file);
|
||||
const src = await fs.readFile(filePath, 'utf-8');
|
||||
const lines = src.split('\n');
|
||||
const wrapperLines = String(wrapInfo.wrapperBlock).split('\n');
|
||||
const splicedWrapper = spliceVariantsIntoLines(wrapperLines, {
|
||||
sessionId,
|
||||
output,
|
||||
commentSyntax: wrapInfo.commentSyntax,
|
||||
file: wrapInfo.file,
|
||||
styleMode: wrapInfo.styleMode,
|
||||
});
|
||||
const startIdx = wrapInfo.replaceStartLine - 1;
|
||||
const endIdx = wrapInfo.replaceEndLine - 1; // may be startIdx-1 for insertion
|
||||
const next = [
|
||||
...lines.slice(0, startIdx),
|
||||
...splicedWrapper,
|
||||
...lines.slice(endIdx + 1),
|
||||
];
|
||||
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
@@ -1661,6 +1706,8 @@ export async function runAgentLoop({
|
||||
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
|
||||
if (wrapInfo.previewMode === 'svelte-component') {
|
||||
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
} else if (wrapInfo.sourceWritten === false) {
|
||||
await writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId: event.id, output });
|
||||
} else {
|
||||
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
|
||||
}
|
||||
|
||||
@@ -117,10 +117,14 @@ export function stopLiveServer(tmp) {
|
||||
} catch { /* already gone */ }
|
||||
}
|
||||
|
||||
export function runInject(tmp, port) {
|
||||
export function runInject(tmp, port, token) {
|
||||
const out = execFileSync(
|
||||
process.execPath,
|
||||
[join(SCRIPTS_DIR, 'live-inject.mjs'), '--port', String(port)],
|
||||
[
|
||||
join(SCRIPTS_DIR, 'live-inject.mjs'),
|
||||
'--port', String(port),
|
||||
...(token ? ['--token', String(token)] : []),
|
||||
],
|
||||
{
|
||||
cwd: tmp,
|
||||
encoding: 'utf-8',
|
||||
@@ -296,7 +300,7 @@ export async function bootFixtureSession({
|
||||
const injectStartedAt = Date.now();
|
||||
trace('setup.inject.start', { fixture: name });
|
||||
log(`live-inject --port ${live.port}`);
|
||||
const injectResult = runInject(tmp, live.port);
|
||||
const injectResult = runInject(tmp, live.port, live.token);
|
||||
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
|
||||
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
|
||||
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
||||
import {
|
||||
buildGenerationPreflight,
|
||||
runGenerationPreflight,
|
||||
clearSourceResolutionCache,
|
||||
} from '../skill/scripts/live/generation-preflight.mjs';
|
||||
|
||||
const SCRIPTS_DIR = path.resolve('skill/scripts');
|
||||
@@ -26,6 +27,7 @@ test('builds a replace preflight from the picker locator', () => {
|
||||
assert.equal(command.mode, 'replace');
|
||||
assert.deepEqual(command.args.slice(1), [
|
||||
'--id', 'session-1', '--count', '3',
|
||||
'--defer-source-write',
|
||||
'--element-id', 'hero',
|
||||
'--classes', 'hero hero--dark',
|
||||
'--tag', 'SECTION',
|
||||
@@ -48,11 +50,22 @@ test('builds an insert preflight from the anchor locator', () => {
|
||||
|
||||
assert.equal(command.mode, 'insert');
|
||||
assert.deepEqual(command.args.slice(1), [
|
||||
'--id', 'session-2', '--count', '2', '--position', 'before',
|
||||
'--id', 'session-2', '--count', '2',
|
||||
'--defer-source-write', '--position', 'before',
|
||||
'--classes', 'card', '--tag', 'ARTICLE', '--text', 'Plan',
|
||||
]);
|
||||
});
|
||||
|
||||
test('replace preflight always requests a deferred source write', () => {
|
||||
const command = buildGenerationPreflight({
|
||||
type: 'generate',
|
||||
id: 'session-defer',
|
||||
count: 3,
|
||||
element: { classes: ['hero'] },
|
||||
}, SCRIPTS_DIR);
|
||||
assert.ok(command.args.includes('--defer-source-write'));
|
||||
});
|
||||
|
||||
test('returns scaffold metadata without exposing child-process details', async () => {
|
||||
const calls = [];
|
||||
const result = await runGenerationPreflight({
|
||||
@@ -108,6 +121,93 @@ test('yields to the event loop instead of blocking on the child process', async
|
||||
assert.equal(tickedDuringPreflight, true, 'the event loop must stay responsive during preflight');
|
||||
});
|
||||
|
||||
test('caches the resolved source file and reuses it via --file on the next generate', async () => {
|
||||
clearSourceResolutionCache();
|
||||
const cache = new Map();
|
||||
const event = {
|
||||
type: 'generate',
|
||||
id: 'sess-a',
|
||||
count: 3,
|
||||
pageUrl: '/pricing',
|
||||
element: { classes: ['hero'], tagName: 'SECTION' },
|
||||
};
|
||||
const firstArgs = [];
|
||||
const first = await runGenerationPreflight(event, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
cache,
|
||||
async execFileImpl(_file, args) {
|
||||
firstArgs.push(...args);
|
||||
return { stdout: '{"file":"src/Pricing.jsx","sourceWritten":false}\n', stderr: '' };
|
||||
},
|
||||
});
|
||||
assert.equal(first.ok, true);
|
||||
assert.ok(!firstArgs.includes('--file'), 'first pass does the tree search, no --file');
|
||||
|
||||
// Second generate on the SAME target (new session id) should point --file at
|
||||
// the cached resolution and skip the search.
|
||||
const secondArgs = [];
|
||||
const second = await runGenerationPreflight({ ...event, id: 'sess-b' }, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
cache,
|
||||
async execFileImpl(_file, args) {
|
||||
secondArgs.push(...args);
|
||||
return { stdout: '{"file":"src/Pricing.jsx","sourceWritten":false}\n', stderr: '' };
|
||||
},
|
||||
});
|
||||
assert.equal(second.ok, true);
|
||||
const fileIdx = secondArgs.indexOf('--file');
|
||||
assert.notEqual(fileIdx, -1, 'cached resolution injects --file');
|
||||
assert.equal(secondArgs[fileIdx + 1], 'src/Pricing.jsx');
|
||||
});
|
||||
|
||||
test('evicts the cached resolution when the preflight fails', async () => {
|
||||
const cache = new Map();
|
||||
const event = {
|
||||
type: 'generate',
|
||||
id: 'sess-c',
|
||||
count: 3,
|
||||
pageUrl: '/pricing',
|
||||
element: { classes: ['hero'] },
|
||||
};
|
||||
await runGenerationPreflight(event, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
cache,
|
||||
async execFileImpl() { return { stdout: '{"file":"src/Pricing.jsx"}\n', stderr: '' }; },
|
||||
});
|
||||
assert.equal(cache.size, 1);
|
||||
|
||||
const error = new Error('spawn failed');
|
||||
error.stderr = 'live-wrap.mjs: element not found\n';
|
||||
await runGenerationPreflight(event, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
cache,
|
||||
execFileImpl: () => Promise.reject(error),
|
||||
});
|
||||
assert.equal(cache.size, 0, 'a failed resolution is evicted so the next run re-searches');
|
||||
});
|
||||
|
||||
test('caches the route source file, not the svelte-component manifest', async () => {
|
||||
const cache = new Map();
|
||||
const event = {
|
||||
type: 'generate',
|
||||
id: 'sess-svelte',
|
||||
count: 3,
|
||||
pageUrl: '/',
|
||||
element: { classes: ['hero'] },
|
||||
};
|
||||
await runGenerationPreflight(event, {
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
cache,
|
||||
async execFileImpl() {
|
||||
return {
|
||||
stdout: '{"file":"node_modules/.impeccable-live/x/manifest.json","sourceFile":"src/routes/+page.svelte","previewMode":"svelte-component"}\n',
|
||||
stderr: '',
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.deepEqual([...cache.values()], ['src/routes/+page.svelte']);
|
||||
});
|
||||
|
||||
test('reports a child-process failure without leaking internals or throwing', async () => {
|
||||
const error = new Error('spawn failed');
|
||||
error.stderr = 'live-wrap.mjs: element not found\n';
|
||||
|
||||
+103
-3
@@ -5,7 +5,7 @@
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync, execSync, spawn } from 'node:child_process';
|
||||
@@ -167,7 +167,7 @@ describe('live-server integration', () => {
|
||||
// rather than an inline copy, so the server must serialize the canonical
|
||||
// vocabulary into /live.js (next to the token/port).
|
||||
const { LIVE_COMMANDS } = await import('../skill/scripts/live/vocabulary.mjs');
|
||||
const body = await (await fetch(`http://localhost:${server.port}/live.js`)).text();
|
||||
const body = await (await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`)).text();
|
||||
assert.match(body, /window\.__IMPECCABLE_VOCAB__\s*=/);
|
||||
const injected = JSON.parse(body.match(/window\.__IMPECCABLE_VOCAB__\s*=\s*(\[.*?\]);/s)[1]);
|
||||
assert.deepEqual(injected, LIVE_COMMANDS);
|
||||
@@ -259,7 +259,7 @@ describe('live-server integration', () => {
|
||||
});
|
||||
|
||||
it('/live.js serves script with token injected', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/live.js`);
|
||||
const res = await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('content-type'), 'application/javascript');
|
||||
const text = await res.text();
|
||||
@@ -302,6 +302,64 @@ describe('live-server integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('/live.js returns 401 without the token and 200 with it', async () => {
|
||||
const noToken = await fetch(`http://localhost:${server.port}/live.js`);
|
||||
assert.equal(noToken.status, 401);
|
||||
|
||||
const wrongToken = await fetch(`http://localhost:${server.port}/live.js?token=not-the-token`);
|
||||
assert.equal(wrongToken.status, 401);
|
||||
|
||||
const ok = await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`);
|
||||
assert.equal(ok.status, 200);
|
||||
const body = await ok.text();
|
||||
assert.ok(body.includes('__IMPECCABLE_LIVE_INIT__'), 'authorized /live.js returns the assembled bundle');
|
||||
});
|
||||
|
||||
it('CORS: a remote origin gets no Access-Control-Allow-Origin on any route', async () => {
|
||||
const evil = 'https://evil.example';
|
||||
for (const path of ['/health', `/live.js?token=${server.token}`, `/status?token=${server.token}`]) {
|
||||
const res = await fetch(`http://localhost:${server.port}${path}`, { headers: { Origin: evil } });
|
||||
assert.equal(
|
||||
res.headers.get('access-control-allow-origin'),
|
||||
null,
|
||||
`remote origin must not be reflected on ${path}`,
|
||||
);
|
||||
}
|
||||
// Preflight from a remote origin is likewise unauthorized to read.
|
||||
const preflight = await fetch(`http://localhost:${server.port}/poll`, {
|
||||
method: 'OPTIONS',
|
||||
headers: { Origin: evil, 'Access-Control-Request-Method': 'POST' },
|
||||
});
|
||||
assert.equal(preflight.headers.get('access-control-allow-origin'), null);
|
||||
});
|
||||
|
||||
it('CORS: a loopback origin is reflected with Vary: Origin', async () => {
|
||||
for (const origin of [
|
||||
`http://localhost:${server.port}`,
|
||||
'http://127.0.0.1:5173',
|
||||
'http://[::1]:5173',
|
||||
]) {
|
||||
const res = await fetch(`http://localhost:${server.port}/health`, { headers: { Origin: origin } });
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), origin, `reflect ${origin}`);
|
||||
const vary = res.headers.get('vary') || '';
|
||||
assert.ok(/\bOrigin\b/i.test(vary), `Vary: Origin present for ${origin}, got "${vary}"`);
|
||||
}
|
||||
// A hostname that merely extends "localhost" must not pass the loopback test.
|
||||
const spoof = await fetch(`http://localhost:${server.port}/health`, {
|
||||
headers: { Origin: 'http://localhost.evil.com' },
|
||||
});
|
||||
assert.equal(spoof.headers.get('access-control-allow-origin'), null, 'localhost.evil.com must not be reflected');
|
||||
});
|
||||
|
||||
it('token-guarded routes still work with a loopback Origin header', async () => {
|
||||
const origin = `http://localhost:${server.port}`;
|
||||
const res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`, {
|
||||
headers: { Origin: origin },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), origin);
|
||||
});
|
||||
|
||||
it('/design-system.json reads DESIGN.md plus .impeccable/design.json', async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-design-system-'));
|
||||
let designServer;
|
||||
@@ -3080,6 +3138,48 @@ colors: {}
|
||||
}
|
||||
});
|
||||
|
||||
it('/source rejects an absolute path to a sibling directory sharing the root prefix', async () => {
|
||||
// Sibling dir whose name extends the project dir name (projeto -> projeto-evil):
|
||||
// a plain string prefix check on the resolved path lets it escape the root.
|
||||
// Build it off the server's real cwd (macOS symlinks /var -> /private/var,
|
||||
// and the server guards against its own process.cwd(), i.e. the realpath).
|
||||
const siblingDir = realpathSync(serverCwd) + '-evil';
|
||||
mkdirSync(siblingDir, { recursive: true });
|
||||
const secretPath = join(siblingDir, 'secret.txt');
|
||||
writeFileSync(secretPath, 'TOP SECRET SIBLING');
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=${encodeURIComponent(secretPath)}`);
|
||||
// Drain the body so the socket doesn't hang regardless of status.
|
||||
await res.text().catch(() => {});
|
||||
assert.equal(res.status, 403);
|
||||
} finally {
|
||||
rmSync(siblingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/source rejects the project root itself (directory, not a file)', async () => {
|
||||
// `.` resolves exactly to cwd; the route only serves files, so an empty
|
||||
// relative path is not a legitimate request and must be forbidden.
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=${encodeURIComponent('.')}`);
|
||||
await res.text().catch(() => {});
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('/source still serves a legitimate nested in-root file', async () => {
|
||||
const nestedDir = join(serverCwd, 'nested');
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
const nestedPath = join(nestedDir, 'page.html');
|
||||
writeFileSync(nestedPath, '<h1>in root</h1>\n');
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=${encodeURIComponent('nested/page.html')}`);
|
||||
assert.equal(res.status, 200);
|
||||
const text = await res.text();
|
||||
assert.ok(text.includes('in root'));
|
||||
} finally {
|
||||
rmSync(nestedDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('/modern-screenshot.js serves the vendored UMD build', async () => {
|
||||
const res = await fetch(`http://localhost:${server.port}/modern-screenshot.js`);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Unit tests for the TanStack Start live-mode adapter.
|
||||
* Run with: node --test tests/live-tanstack-adapter.test.mjs
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
detectTanStackStartProject,
|
||||
applyTanStackLiveAdapter,
|
||||
removeTanStackLiveAdapter,
|
||||
patchTanStackRoot,
|
||||
unpatchTanStackRoot,
|
||||
buildTanStackLiveRootComponent,
|
||||
} from '../skill/scripts/live/tanstack-adapter.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ROOT_TSX = `import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
shellComponent: RootDocument,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
function scaffold(tmp, { ext = 'tsx', rootBody = ROOT_TSX, startPackage = '@tanstack/react-start' } = {}) {
|
||||
mkdirSync(join(tmp, 'src', 'routes'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'package.json'), JSON.stringify({
|
||||
name: 'app',
|
||||
dependencies: { '@tanstack/react-router': '^1', [startPackage]: '^1' },
|
||||
}));
|
||||
writeFileSync(join(tmp, 'src', 'routes', `__root.${ext}`), rootBody);
|
||||
}
|
||||
|
||||
describe('tanstack-adapter — detection', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
it('detects a TanStack Start project from package + root route', () => {
|
||||
scaffold(tmp);
|
||||
const project = detectTanStackStartProject(tmp);
|
||||
assert.equal(project.rootRoute, 'src/routes/__root.tsx');
|
||||
assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.tsx');
|
||||
assert.equal(project.componentImport, '../impeccable/ImpeccableLiveRoot');
|
||||
});
|
||||
|
||||
it('mirrors the root-route extension for the mount component (jsx)', () => {
|
||||
scaffold(tmp, { ext: 'jsx' });
|
||||
const project = detectTanStackStartProject(tmp);
|
||||
assert.equal(project.rootRoute, 'src/routes/__root.jsx');
|
||||
assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.jsx');
|
||||
});
|
||||
|
||||
it('detects @tanstack/solid-start and @tanstack/start too', () => {
|
||||
scaffold(tmp, { startPackage: '@tanstack/solid-start' });
|
||||
assert.ok(detectTanStackStartProject(tmp));
|
||||
});
|
||||
|
||||
it('returns null without the Start package (plain TanStack Router SPA)', () => {
|
||||
mkdirSync(join(tmp, 'src', 'routes'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'package.json'), JSON.stringify({
|
||||
dependencies: { '@tanstack/react-router': '^1' },
|
||||
}));
|
||||
writeFileSync(join(tmp, 'src', 'routes', '__root.tsx'), ROOT_TSX);
|
||||
assert.equal(detectTanStackStartProject(tmp), null);
|
||||
});
|
||||
|
||||
it('returns null without a root route file', () => {
|
||||
writeFileSync(join(tmp, 'package.json'), JSON.stringify({
|
||||
dependencies: { '@tanstack/react-start': '^1' },
|
||||
}));
|
||||
assert.equal(detectTanStackStartProject(tmp), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tanstack-adapter — patch/unpatch round-trip', () => {
|
||||
it('inserts the import + mount component before <Scripts />', () => {
|
||||
const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot');
|
||||
assert.match(patched, /import ImpeccableLiveRoot from '\.\.\/impeccable\/ImpeccableLiveRoot';/);
|
||||
assert.match(patched, /\{\/\* impeccable-live-tanstack-start \*\/\}/);
|
||||
assert.match(patched, /<ImpeccableLiveRoot \/>/);
|
||||
// component renders before <Scripts />
|
||||
assert.ok(patched.indexOf('<ImpeccableLiveRoot />') < patched.indexOf('<Scripts />'));
|
||||
});
|
||||
|
||||
it('round-trips byte-for-byte (patch then unpatch)', () => {
|
||||
const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot');
|
||||
assert.notEqual(patched, ROOT_TSX);
|
||||
assert.equal(unpatchTanStackRoot(patched), ROOT_TSX);
|
||||
});
|
||||
|
||||
it('is idempotent (double patch adds one import + one mount)', () => {
|
||||
const once = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot');
|
||||
const twice = patchTanStackRoot(once, '../impeccable/ImpeccableLiveRoot');
|
||||
assert.equal(twice, once);
|
||||
assert.equal((twice.match(/<ImpeccableLiveRoot \/>/g) || []).length, 1);
|
||||
assert.equal((twice.match(/^import ImpeccableLiveRoot/gm) || []).length, 1);
|
||||
});
|
||||
|
||||
it('falls back to </body> when <Scripts /> is absent', () => {
|
||||
const noScripts = ROOT_TSX.replace(/\s*<Scripts \/>/, '');
|
||||
const patched = patchTanStackRoot(noScripts, '../impeccable/ImpeccableLiveRoot');
|
||||
assert.match(patched, /<ImpeccableLiveRoot \/>/);
|
||||
assert.ok(patched.indexOf('<ImpeccableLiveRoot />') < patched.indexOf('</body>'));
|
||||
assert.equal(unpatchTanStackRoot(patched), noScripts);
|
||||
});
|
||||
|
||||
it('builds a client-only mount component carrying the token', () => {
|
||||
const body = buildTanStackLiveRootComponent(8123, 'tok-xyz');
|
||||
assert.match(body, /http:\/\/localhost:8123\/live\.js\?token=tok-xyz/);
|
||||
assert.match(body, /useEffect/);
|
||||
assert.match(body, /typeof document === 'undefined'/);
|
||||
assert.match(body, /data-impeccable-live-tanstack/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tanstack-adapter — apply/remove on disk', () => {
|
||||
let tmp;
|
||||
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); });
|
||||
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
|
||||
|
||||
it('apply writes the component + patches root, remove restores byte-for-byte', () => {
|
||||
scaffold(tmp);
|
||||
const original = readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8');
|
||||
|
||||
const applied = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' });
|
||||
assert.equal(applied.adapter, 'tanstack-start');
|
||||
assert.equal(applied.inserted, true);
|
||||
assert.ok(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx')));
|
||||
assert.match(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), /ImpeccableLiveRoot/);
|
||||
assert.match(
|
||||
readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'),
|
||||
/localhost:9100\/live\.js\?token=T1/,
|
||||
);
|
||||
|
||||
const removed = removeTanStackLiveAdapter({ cwd: tmp });
|
||||
assert.equal(removed.removed, true);
|
||||
assert.equal(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx')), false);
|
||||
assert.equal(existsSync(join(tmp, 'src/impeccable')), false, 'empty managed dir pruned');
|
||||
assert.equal(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), original);
|
||||
});
|
||||
|
||||
it('refuses to clobber an unmanaged file at the component path', () => {
|
||||
scaffold(tmp);
|
||||
mkdirSync(join(tmp, 'src/impeccable'), { recursive: true });
|
||||
writeFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'export const mine = 1;\n');
|
||||
const result = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' });
|
||||
assert.equal(result.error, 'tanstack_component_conflict');
|
||||
// unmanaged file untouched
|
||||
assert.equal(
|
||||
readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'),
|
||||
'export const mine = 1;\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -254,6 +254,43 @@ describe('wrapCli integration', () => {
|
||||
});
|
||||
|
||||
|
||||
it('--defer-source-write leaves source untouched and returns the wrapper block', () => {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div class="hero-section">
|
||||
<h1>Hello World</h1>
|
||||
<p>Welcome to our site.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
const file = join(tmp, 'index.html');
|
||||
writeFileSync(file, html);
|
||||
|
||||
const result = JSON.parse(execSync(
|
||||
`node skill/scripts/live-wrap.mjs --id defer1 --count 3 --classes "hero-section" --defer-source-write --file "${file}"`,
|
||||
{ cwd: process.cwd(), encoding: 'utf-8' }
|
||||
));
|
||||
|
||||
// Source is NOT written by the preflight (no reload storm).
|
||||
assert.equal(readFileSync(file, 'utf-8'), html);
|
||||
|
||||
// Deferred contract fields present for the agent's atomic edit.
|
||||
assert.equal(result.sourceWritten, false);
|
||||
assert.ok(typeof result.wrapperBlock === 'string' && result.wrapperBlock.length > 0);
|
||||
assert.ok(result.wrapperBlock.includes('data-impeccable-variants="defer1"'));
|
||||
assert.ok(result.wrapperBlock.includes('Variants: insert below this line'));
|
||||
assert.ok(result.wrapperBlock.includes('impeccable-variants-end defer1'));
|
||||
assert.equal(typeof result.replaceStartLine, 'number');
|
||||
assert.equal(typeof result.replaceEndLine, 'number');
|
||||
|
||||
// The replace range points at the picked <div class="hero-section"> block
|
||||
// (1-indexed lines 4..7 of the source above).
|
||||
const lines = html.split('\n');
|
||||
assert.ok(lines[result.replaceStartLine - 1].includes('class="hero-section"'));
|
||||
assert.ok(lines[result.replaceEndLine - 1].includes('</div>'));
|
||||
});
|
||||
|
||||
it('wraps a JSX element and uses JSX comment syntax', () => {
|
||||
const jsx = `export default function App() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Deterministic smoke tests for the new-work interactive flow.
|
||||
*
|
||||
* Covers the parts a user actually touches: the serve-question decision page
|
||||
* (pick, re-roll + steer + re-deal, canon, tab close) driven through a real
|
||||
* browser by the scripted user bot, plus the offline fake image generator.
|
||||
* No LLM calls; a real Chromium via Playwright supplies full page fidelity
|
||||
* (heartbeats, re-roll reload, tab close). Kept OUT of `bun run test` like
|
||||
* live-e2e; run it with `bun run test:new-work-e2e`.
|
||||
*
|
||||
* The concept-seed direction roll (challengers, ASSIGNED INDEX, the no
|
||||
* PRODUCT.md gate) is already covered by tests/concept-seed.test.mjs and is
|
||||
* not repeated here.
|
||||
*
|
||||
* One-time setup: npx playwright install chromium
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { runUserBot } from './new-work-e2e/user-bot.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SERVE = path.join(ROOT, 'skill', 'scripts', 'serve-question.mjs');
|
||||
const GENERATE = path.join(ROOT, 'skill', 'scripts', 'generate-image.mjs');
|
||||
const CATALOG_DIR = path.join(ROOT, 'tests', 'fixtures', 'concept-catalog');
|
||||
|
||||
let playwright;
|
||||
let browser;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
playwright = await import('playwright');
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Playwright is required for new-work-e2e tests (${err.message}). Run: npx playwright install chromium`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
browser = await playwright.chromium.launch({ headless: true });
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to launch Chromium (${err.message}). Run: npx playwright install chromium`);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Workspace + serve-question helpers
|
||||
// --------------------------------------------------------------------------
|
||||
function makeWorkspace() {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'new-work-e2e-'));
|
||||
writeFileSync(
|
||||
path.join(dir, 'PRODUCT.md'),
|
||||
'# Product\n\n## Register\n\nbrand\n\n## Platform\n\nweb\n',
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// serve-question writes its state under cwd; run everything from the workspace.
|
||||
function run(args, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [SERVE, ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, IMPECCABLE_QUESTION_FORCE: '1', IMPECCABLE_CATALOG_DIR: CATALOG_DIR },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout.on('data', (c) => { out += c; });
|
||||
child.stderr.on('data', (c) => { err += c; });
|
||||
child.on('exit', (code) => resolve({ code, out, err }));
|
||||
});
|
||||
}
|
||||
|
||||
async function startDaemon(cwd, payload, key) {
|
||||
const payloadPath = path.join(cwd, `${key}.payload.json`);
|
||||
writeFileSync(payloadPath, JSON.stringify(payload));
|
||||
const started = await run(['--start', '--payload', payloadPath, '--no-open', '--key', key], cwd);
|
||||
assert.equal(started.code, 0, `--start failed: ${started.out} ${started.err}`);
|
||||
const url = started.out.match(/QUESTION URL: (\S+)/)?.[1];
|
||||
assert.ok(url, `no URL from --start: ${started.out}`);
|
||||
return { url, payloadPath };
|
||||
}
|
||||
|
||||
// Poll --wait until it settles on a terminal exit code (0 answered, 2 gone,
|
||||
// 4 page closed); loop while it reports WAITING (3).
|
||||
async function waitLoop(cwd, key, { poll = 30, max = 20 } = {}) {
|
||||
for (let i = 0; i < max; i++) {
|
||||
const res = await run(['--wait', '--key', key, '--poll', String(poll)], cwd);
|
||||
if (res.code !== 3) return res;
|
||||
}
|
||||
throw new Error('waitLoop exceeded max iterations');
|
||||
}
|
||||
|
||||
async function stopDaemon(cwd, key) {
|
||||
await run(['--stop', '--key', key], cwd).catch(() => {});
|
||||
}
|
||||
|
||||
function makeFakeImage(cwd, prompt, outName) {
|
||||
const out = path.join(cwd, outName);
|
||||
const res = spawnSyncGen(prompt, out);
|
||||
assert.equal(res.status, 0, `generate-image fake failed: ${res.stderr}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function spawnSyncGen(prompt, out, size = null) {
|
||||
const args = [GENERATE, '--prompt', prompt, '--out', out];
|
||||
if (size) args.push('--size', size);
|
||||
return spawnSync(process.execPath, args, {
|
||||
env: { ...process.env, IMPECCABLE_IMAGE_GEN_FAKE: '1' },
|
||||
encoding: 'buffer',
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// serve-question interactive cycles
|
||||
// --------------------------------------------------------------------------
|
||||
describe('new-work-e2e: serve-question decision page', () => {
|
||||
it('(a) pick assigned returns the option, hero/board fields, and the CHOSEN CARD directive', async () => {
|
||||
const cwd = makeWorkspace();
|
||||
const key = 'pick';
|
||||
const hero = makeFakeImage(cwd, 'Fillmore handbill hero', 'hero.png');
|
||||
const board = makeFakeImage(cwd, 'Fillmore handbill board', 'board.png');
|
||||
const payload = {
|
||||
title: 'Choose the visual world',
|
||||
question: 'The roll assigned Fillmore Handbill.',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', hero, board },
|
||||
{ id: 'challenger-teletext', label: 'Teletext Service', body: 'block-mosaic pages' },
|
||||
],
|
||||
reroll: true,
|
||||
canon: true,
|
||||
steer: true,
|
||||
};
|
||||
await startDaemon(cwd, payload, key);
|
||||
try {
|
||||
const bot = await runUserBot({
|
||||
workspaceDir: cwd, key, browser,
|
||||
policy: [{ pick: 'assigned', steer: 'warmer palette' }],
|
||||
});
|
||||
assert.equal(bot.results[0].action, 'pick');
|
||||
const collected = await waitLoop(cwd, key);
|
||||
assert.equal(collected.code, 0, collected.out);
|
||||
assert.match(collected.out, /ANSWER: /);
|
||||
const answer = JSON.parse(collected.out.match(/ANSWER: (\{.*\})/)[1]);
|
||||
assert.equal(answer.optionId, 'assigned');
|
||||
assert.equal(answer.steer, 'warmer palette');
|
||||
assert.ok(answer.hero, 'answer carries the chosen hero path');
|
||||
assert.ok(answer.board, 'answer carries the chosen board path');
|
||||
assert.match(collected.out, /CHOSEN CARD:/);
|
||||
} finally {
|
||||
await stopDaemon(cwd, key);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('(b) re-roll with steer keeps the server alive; --update re-deals; the next pick is terminal', async () => {
|
||||
const cwd = makeWorkspace();
|
||||
const key = 'reroll';
|
||||
const payload1 = {
|
||||
title: 'Choose the visual world',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'First Hand', kicker: 'THE ROLL' },
|
||||
{ id: 'challenger-a', label: 'Alt One' },
|
||||
],
|
||||
reroll: true, steer: true, canon: true,
|
||||
};
|
||||
const payload2 = {
|
||||
title: 'Choose the visual world',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Second Hand', kicker: 'THE ROLL' },
|
||||
{ id: 'challenger-b', label: 'Alt Two' },
|
||||
],
|
||||
reroll: true, steer: true,
|
||||
};
|
||||
await startDaemon(cwd, payload1, key);
|
||||
try {
|
||||
// Bot drives the whole page: re-roll (with steer) then, after the page
|
||||
// reloads into the next hand, pick the assigned card.
|
||||
const botPromise = runUserBot({
|
||||
workspaceDir: cwd, key, browser,
|
||||
policy: [{ reroll: true, steer: 'colder, more restraint' }, { pick: 'assigned' }],
|
||||
});
|
||||
|
||||
// First answer: the re-roll. Server must stay alive afterwards.
|
||||
const first = await waitLoop(cwd, key);
|
||||
assert.equal(first.code, 0, first.out);
|
||||
assert.match(first.out, /"optionId":"reroll"/);
|
||||
assert.match(first.out, /colder, more restraint/);
|
||||
assert.ok(existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
|
||||
'server state file survives a re-roll');
|
||||
|
||||
// Deliver the next hand; the live page reloads itself.
|
||||
const nextPayloadPath = path.join(cwd, 'next.json');
|
||||
writeFileSync(nextPayloadPath, JSON.stringify(payload2));
|
||||
const updated = await run(['--update', '--key', key, '--payload', nextPayloadPath], cwd);
|
||||
assert.equal(updated.code, 0, updated.out);
|
||||
|
||||
// Second answer: the terminal pick on the re-dealt hand.
|
||||
const second = await waitLoop(cwd, key);
|
||||
assert.equal(second.code, 0, second.out);
|
||||
assert.match(second.out, /"optionId":"assigned"/);
|
||||
|
||||
const bot = await botPromise;
|
||||
assert.equal(bot.results[0].action, 'reroll');
|
||||
assert.ok(bot.results[0].reloaded, 'page reloaded into the next hand');
|
||||
assert.equal(bot.results[1].action, 'pick');
|
||||
|
||||
// Terminal pick cleans the state file up.
|
||||
assert.ok(!existsSync(path.join(cwd, '.impeccable', 'questions', `${key}.state.json`)),
|
||||
'terminal pick removes the server state file');
|
||||
} finally {
|
||||
await stopDaemon(cwd, key);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('(c) canon click returns optionId canon and prints the CANON CHOSEN directive', async () => {
|
||||
const cwd = makeWorkspace();
|
||||
const key = 'canon';
|
||||
const payload = {
|
||||
title: 'Choose the visual world',
|
||||
options: [{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL' }],
|
||||
reroll: true, canon: true, steer: true,
|
||||
};
|
||||
await startDaemon(cwd, payload, key);
|
||||
try {
|
||||
await runUserBot({ workspaceDir: cwd, key, browser, policy: [{ canon: true }] });
|
||||
const collected = await waitLoop(cwd, key);
|
||||
assert.equal(collected.code, 0, collected.out);
|
||||
assert.match(collected.out, /"optionId":"canon"/);
|
||||
assert.match(collected.out, /CANON CHOSEN:/);
|
||||
} finally {
|
||||
await stopDaemon(cwd, key);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('(d) closing the tab makes --wait exit 4 PAGE CLOSED', async () => {
|
||||
const cwd = makeWorkspace();
|
||||
const key = 'close';
|
||||
const payload = {
|
||||
title: 'Choose the visual world',
|
||||
options: [{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL' }],
|
||||
reroll: true, steer: true,
|
||||
};
|
||||
await startDaemon(cwd, payload, key);
|
||||
try {
|
||||
const bot = await runUserBot({ workspaceDir: cwd, key, browser, policy: [{ close: true }] });
|
||||
assert.equal(bot.results[0].action, 'close');
|
||||
assert.ok(bot.results[0].beat > 0, 'a heartbeat landed before the tab closed');
|
||||
// --wait must observe the stale heartbeat and report the closed page.
|
||||
const res = await run(['--wait', '--key', key, '--poll', '30'], cwd);
|
||||
assert.equal(res.code, 4, `expected exit 4, got ${res.code}: ${res.out}`);
|
||||
assert.match(res.out, /PAGE CLOSED/);
|
||||
} finally {
|
||||
await stopDaemon(cwd, key);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('(e) an option with no hero renders a text-only card (no .media element)', async () => {
|
||||
const cwd = makeWorkspace();
|
||||
const key = 'textonly';
|
||||
const hero = makeFakeImage(cwd, 'has a hero', 'hero.png');
|
||||
const payload = {
|
||||
title: 'Choose the visual world',
|
||||
options: [
|
||||
{ id: 'assigned', label: 'Text Only Direction', body: 'a grounded direction, no comp' },
|
||||
{ id: 'challenger-hero', label: 'Has A Card', hero },
|
||||
],
|
||||
reroll: true, steer: true,
|
||||
};
|
||||
const { url } = await startDaemon(cwd, payload, key);
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'load' });
|
||||
await page.waitForSelector('button.choose');
|
||||
const textOnlyMedia = await page.$('.card[data-id="assigned"] .media');
|
||||
const heroMedia = await page.$('.card[data-id="challenger-hero"] .media');
|
||||
const textOnlyFace = await page.$('.card[data-id="assigned"] .face.text-only');
|
||||
await context.close();
|
||||
assert.equal(textOnlyMedia, null, 'text-only card has no .media region');
|
||||
assert.ok(textOnlyFace, 'text-only card carries the .text-only face class');
|
||||
assert.ok(heroMedia, 'the hero card still renders its .media region');
|
||||
} finally {
|
||||
await stopDaemon(cwd, key);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Fake image generation
|
||||
// --------------------------------------------------------------------------
|
||||
describe('new-work-e2e: fake image generation', () => {
|
||||
it('is deterministic per prompt and encodes the SYNTHETIC marker', () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
|
||||
try {
|
||||
const a = path.join(cwd, 'a.png');
|
||||
const b = path.join(cwd, 'b.png');
|
||||
const r1 = spawnSyncGen('Fillmore psychedelic handbill, warm ink', a);
|
||||
const r2 = spawnSyncGen('Fillmore psychedelic handbill, warm ink', b);
|
||||
assert.equal(r1.status, 0, r1.stderr?.toString());
|
||||
assert.equal(r2.status, 0, r2.stderr?.toString());
|
||||
assert.ok(existsSync(a) && existsSync(b), 'both files exist');
|
||||
assert.match(r1.stdout.toString(), /\$0\.00/, 'cost line reads $0.00');
|
||||
const bytesA = readFileSync(a);
|
||||
const bytesB = readFileSync(b);
|
||||
assert.ok(bytesA.equals(bytesB), 'same prompt yields identical bytes');
|
||||
// Valid PNG signature + the SYNTHETIC marker (in the tEXt chunk).
|
||||
assert.equal(bytesA.slice(0, 8).toString('hex'), '89504e470d0a1a0a');
|
||||
assert.ok(bytesA.includes(Buffer.from('SYNTHETIC')), 'PNG carries the SYNTHETIC marker');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a different palette for a different prompt', () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
|
||||
try {
|
||||
const a = path.join(cwd, 'a.png');
|
||||
const c = path.join(cwd, 'c.png');
|
||||
spawnSyncGen('Fillmore psychedelic handbill, warm ink', a);
|
||||
spawnSyncGen('Teletext broadcast mosaic, cold blue', c);
|
||||
const bytesA = readFileSync(a);
|
||||
const bytesC = readFileSync(c);
|
||||
assert.ok(!bytesA.equals(bytesC), 'different prompts produce different images');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('the SVG variant carries the readable prompt text and SYNTHETIC COMP label', () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'new-work-img-'));
|
||||
try {
|
||||
const svg = path.join(cwd, 'comp.svg');
|
||||
const res = spawnSyncGen('teletext broadcast mosaic', svg, '800x600');
|
||||
assert.equal(res.status, 0, res.stderr?.toString());
|
||||
const text = readFileSync(svg, 'utf8');
|
||||
assert.match(text, /^<\?xml/, 'is an SVG document');
|
||||
assert.match(text, /SYNTHETIC COMP/);
|
||||
assert.match(text, /teletext/i, 'the prompt text is rendered');
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
# new-work E2E
|
||||
|
||||
A cheap, deterministic smoke suite for the interactive parts of new-work: the
|
||||
serve-question decision page and the offline image generator. It is kept out of
|
||||
`bun run test` and runs on demand.
|
||||
|
||||
```bash
|
||||
bun run test:new-work-e2e
|
||||
```
|
||||
|
||||
One-time setup: `npx playwright install chromium` (the suite drives a real
|
||||
Chromium so the page runs its own JS, exactly as a user's tab would).
|
||||
|
||||
## What it covers
|
||||
|
||||
`tests/new-work-e2e.test.mjs` opens the served decision page with a real
|
||||
browser and drives it through the scripted user bot, then asserts on the
|
||||
serve-question protocol output:
|
||||
|
||||
- **pick assigned** returns the chosen `optionId`, the typed steer, the
|
||||
`hero`/`board` fields, and the `CHOSEN CARD` directive printed by `--wait`.
|
||||
- **re-roll with steer** keeps the daemon alive, `--update` re-deals the next
|
||||
hand, the page reloads itself, and the following pick is terminal (state file
|
||||
cleaned up).
|
||||
- **canon** returns `optionId: canon` and prints the `CANON CHOSEN` directive.
|
||||
- **tab close** stops the page heartbeats so `--wait` exits 4 `PAGE CLOSED`.
|
||||
- **text-only card** renders with no `.media` region when an option has no hero.
|
||||
- **fake image generation**: same prompt yields identical bytes, the file
|
||||
exists, the `SYNTHETIC` marker is present, and different prompts produce
|
||||
different palettes.
|
||||
|
||||
The concept-seed direction roll (challengers, `ASSIGNED INDEX`, the no
|
||||
PRODUCT.md gate) is already covered by `tests/concept-seed.test.mjs` and is not
|
||||
repeated here.
|
||||
|
||||
## Pieces
|
||||
|
||||
- `user-bot.mjs` is a module plus CLI. Given a workspace dir it resolves the
|
||||
running daemon from `.impeccable/questions/<key>.state.json`, opens the page,
|
||||
and runs a JSON policy of real clicks: `{"pick":"assigned"}`,
|
||||
`{"reroll":true,"steer":"warmer"}`, `{"pick":"challenger-*"}`,
|
||||
`{"canon":true}`, `{"close":true}`. The deterministic tier passes an
|
||||
already-launched browser in; the CLI launches its own Chromium.
|
||||
- `IMPECCABLE_IMAGE_GEN_FAKE=1` switches `skill/scripts/generate-image.mjs` to
|
||||
the offline stand-in: no OpenAI call, no key, a `$0.00` cost line, and a
|
||||
deterministic image (SVG for `.svg` out with the wrapped prompt text and a
|
||||
`SYNTHETIC COMP` label; a valid palette-stripe PNG otherwise, with the prompt
|
||||
and marker in a PNG `tEXt` chunk).
|
||||
|
||||
## Planned LLM tier (not built yet)
|
||||
|
||||
The same scaffolding supports an opt-in LLM tier later, mirroring the two-layer
|
||||
pattern in `tests/live-e2e`:
|
||||
|
||||
- A real model plays the user through the same scripted `user-bot.mjs` policy,
|
||||
choosing and steering instead of following canned actions.
|
||||
- `IMPECCABLE_IMAGE_GEN_FAKE` still stands in for image spend, so a full
|
||||
concept-to-card cycle runs without paying per render.
|
||||
- Assertions run against the tool-call trace via the skill-behavior harness,
|
||||
the same way `tests/skill-behavior` keys on the trace rather than free-form
|
||||
output.
|
||||
|
||||
Cost posture: the deterministic tier is free (no API calls, local Chromium).
|
||||
The LLM tier hits a provider and costs money, so it stays opt-in and out of CI,
|
||||
matching how `test:live-e2e` and `test:skill-behavior` are gated today.
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Scripted user bot for the new-work interactive smoke suite.
|
||||
*
|
||||
* Given a workspace directory, it discovers a running serve-question daemon
|
||||
* from `.impeccable/questions/<key>.state.json`, opens the served page in a
|
||||
* real browser, and drives it through a scripted policy: it clicks the real
|
||||
* `button.choose`, `#reroll`, and `#canon` controls, types into `#steer`, and
|
||||
* closes the tab for the exit-4 path. Because a real page runs the page's own
|
||||
* JS, heartbeats fire and re-roll reloads behave exactly as a user's would.
|
||||
*
|
||||
* The deterministic tier passes an already-launched Playwright browser in.
|
||||
* Run as a CLI (`--workspace DIR --policy '<json>'`) it launches its own
|
||||
* Chromium. The policy is an ordered list of actions:
|
||||
*
|
||||
* { "pick": "assigned" } click the assigned card
|
||||
* { "pick": "challenger-*" } click the first matching card
|
||||
* { "pickIndex": 1 } click the Nth choose button
|
||||
* { "reroll": true, "steer": "warmer" } type the steer, click Re-roll
|
||||
* { "canon": true } click Play it straight
|
||||
* { "close": true } close the tab (stops heartbeats)
|
||||
*
|
||||
* A `steer` on any action is typed into `#steer` first when the field exists.
|
||||
* After a re-roll the bot waits for the page to reload into the next hand
|
||||
* (delivered out of band by `serve-question --update`) before the next action.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function questionsDir(workspaceDir) {
|
||||
return path.join(workspaceDir, '.impeccable', 'questions');
|
||||
}
|
||||
|
||||
// Resolve the served URL from the daemon state file. When no key is given and
|
||||
// several exist, the newest wins.
|
||||
export function resolveQuestion(workspaceDir, key = null) {
|
||||
const dir = questionsDir(workspaceDir);
|
||||
if (!existsSync(dir)) throw new Error(`no questions dir at ${dir}`);
|
||||
const stateFiles = readdirSync(dir).filter((f) => f.endsWith('.state.json'));
|
||||
if (stateFiles.length === 0) throw new Error(`no *.state.json in ${dir}`);
|
||||
let file;
|
||||
if (key) {
|
||||
file = `${key}.state.json`;
|
||||
if (!stateFiles.includes(file)) throw new Error(`no state file for key ${key}`);
|
||||
} else {
|
||||
file = stateFiles
|
||||
.map((f) => ({ f, mtime: readFileSync(path.join(dir, f), 'utf8') && f }))
|
||||
.sort()
|
||||
.pop().f;
|
||||
}
|
||||
const resolvedKey = file.replace(/\.state\.json$/, '');
|
||||
const state = JSON.parse(readFileSync(path.join(dir, file), 'utf8'));
|
||||
return { key: resolvedKey, url: state.url, port: state.port, pid: state.pid };
|
||||
}
|
||||
|
||||
function stateLastBeat(workspaceDir, key) {
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(path.join(questionsDir(workspaceDir), `${key}.state.json`), 'utf8'));
|
||||
return state.lastBeat || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function typeSteer(page, action) {
|
||||
if (action.steer == null) return;
|
||||
const steer = await page.$('#steer');
|
||||
if (steer) await steer.fill(String(action.steer));
|
||||
}
|
||||
|
||||
function chooseSelector(pick) {
|
||||
if (pick.endsWith('*')) {
|
||||
const prefix = pick.slice(0, -1);
|
||||
return `button.choose[data-id^="${prefix}"]`;
|
||||
}
|
||||
return `button.choose[data-id="${pick}"]`;
|
||||
}
|
||||
|
||||
async function runAction(page, action, { workspaceDir, key }) {
|
||||
await typeSteer(page, action);
|
||||
|
||||
if (action.reroll) {
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'load', timeout: 60000 }).catch(() => {}),
|
||||
page.click('#reroll'),
|
||||
]);
|
||||
// Fresh hand loaded: wait for the interactive controls of the next round.
|
||||
await page.waitForSelector('button.choose', { timeout: 30000 });
|
||||
return { action: 'reroll', reloaded: true };
|
||||
}
|
||||
|
||||
if (action.canon) {
|
||||
await page.click('#canon');
|
||||
return { action: 'canon' };
|
||||
}
|
||||
|
||||
if (action.close) {
|
||||
// Make sure at least one heartbeat has been recorded so the --wait poll can
|
||||
// later see the beat go stale (the exit-4 PAGE CLOSED path).
|
||||
const deadline = Date.now() + 8000;
|
||||
while (Date.now() < deadline && !stateLastBeat(workspaceDir, key)) {
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
await page.close();
|
||||
return { action: 'close', beat: stateLastBeat(workspaceDir, key) };
|
||||
}
|
||||
|
||||
if (action.pickIndex != null) {
|
||||
const buttons = await page.$$('button.choose');
|
||||
const btn = buttons[action.pickIndex];
|
||||
if (!btn) throw new Error(`no choose button at index ${action.pickIndex}`);
|
||||
await btn.click();
|
||||
return { action: 'pick', index: action.pickIndex };
|
||||
}
|
||||
|
||||
if (action.pick) {
|
||||
await page.click(chooseSelector(action.pick));
|
||||
return { action: 'pick', id: action.pick };
|
||||
}
|
||||
|
||||
throw new Error(`unknown action: ${JSON.stringify(action)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the served question page through the policy. Pass a launched
|
||||
* Playwright `browser` (deterministic tier) or omit it to launch Chromium.
|
||||
*/
|
||||
export async function runUserBot({ workspaceDir, key = null, policy = [], browser = null }) {
|
||||
let ownBrowser = null;
|
||||
let pw = null;
|
||||
if (!browser) {
|
||||
pw = await import('playwright');
|
||||
ownBrowser = await pw.chromium.launch({ headless: true });
|
||||
browser = ownBrowser;
|
||||
}
|
||||
const question = resolveQuestion(workspaceDir, key);
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(question.url, { waitUntil: 'load' });
|
||||
await page.waitForSelector('button.choose', { timeout: 30000 });
|
||||
|
||||
const results = [];
|
||||
let closed = false;
|
||||
try {
|
||||
for (const action of policy) {
|
||||
const result = await runAction(page, action, { workspaceDir, key: question.key });
|
||||
results.push(result);
|
||||
if (result.action === 'close') { closed = true; break; }
|
||||
// Give the answer POST time to land before the process may exit.
|
||||
if (result.action === 'pick' || result.action === 'canon') {
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!closed) await context.close().catch(() => {});
|
||||
if (ownBrowser) await ownBrowser.close().catch(() => {});
|
||||
}
|
||||
return { key: question.key, url: question.url, results };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CLI
|
||||
// --------------------------------------------------------------------------
|
||||
function cliArg(name, fallback = null) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
if (i === -1) return fallback;
|
||||
const v = process.argv[i + 1];
|
||||
return v && !v.startsWith('--') ? v : fallback;
|
||||
}
|
||||
|
||||
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
const workspaceDir = cliArg('workspace');
|
||||
const key = cliArg('key');
|
||||
const policyRaw = cliArg('policy');
|
||||
if (!workspaceDir || !policyRaw) {
|
||||
console.error('user-bot: --workspace <dir> and --policy <json> are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
let policy;
|
||||
try {
|
||||
policy = JSON.parse(policyRaw);
|
||||
} catch (err) {
|
||||
console.error(`user-bot: --policy must be JSON (${err.message})`);
|
||||
process.exit(1);
|
||||
}
|
||||
runUserBot({ workspaceDir, key, policy })
|
||||
.then((out) => {
|
||||
console.log(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`user-bot: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+297
-1
@@ -98,8 +98,11 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
|
||||
}
|
||||
if (providers.includes('.agents')) {
|
||||
mkdirSync(join(bundleRoot, '.codex'), { recursive: true });
|
||||
// Mirror production: the Codex bundle's `.codex/hooks.json` targets its own
|
||||
// `.codex/skills` payload. The CLI installs the skill at `.agents/skills`, so
|
||||
// the installer must rewrite this command to `.agents/skills` (see below).
|
||||
writeFileSync(join(bundleRoot, '.codex', 'hooks.json'), JSON.stringify({
|
||||
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".agents/skills/impeccable/scripts/hook.mjs"' }] }] },
|
||||
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] },
|
||||
}, null, 2));
|
||||
}
|
||||
return bundleRoot;
|
||||
@@ -675,6 +678,12 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
expect(existsSync(join(tmp, '.claude', 'settings.local.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.cursor', 'hooks.json'))).toBe(true);
|
||||
expect(existsSync(join(tmp, '.codex', 'hooks.json'))).toBe(true);
|
||||
// The CLI puts Codex's skill at `.agents/skills`, so the project-scope hook
|
||||
// command must point there — not at the bundle's own `.codex/skills` path,
|
||||
// which would resolve to a nonexistent file and silently no-op the hook.
|
||||
const codexHooks = readFileSync(join(tmp, '.codex', 'hooks.json'), 'utf8');
|
||||
expect(codexHooks).toContain('.agents/skills/impeccable/scripts/hook.mjs');
|
||||
expect(codexHooks).not.toContain('.codex/skills/impeccable/scripts/hook.mjs');
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
@@ -875,6 +884,145 @@ describe('skills install/update: local universal bundle e2e', () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
// OpenCode reads global skills from its config directory, not ~/.opencode:
|
||||
// $OPENCODE_CONFIG_DIR/skills, else $XDG_CONFIG_HOME/opencode/skills, else
|
||||
// ~/.config/opencode/skills. Writing to ~/.opencode/skills produced an
|
||||
// install `opencode debug skill` never saw (#406).
|
||||
test('global install writes OpenCode skills to ~/.config/opencode/skills (#406)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-oc-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-oc-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
mkdirSync(join(home, '.opencode'), { recursive: true });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
delete env.OPENCODE_CONFIG_DIR;
|
||||
delete env.XDG_CONFIG_HOME;
|
||||
|
||||
const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env });
|
||||
|
||||
expect(output).toContain('Installed impeccable into: .opencode (global)');
|
||||
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('OpenCode global dir honors OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME (#406)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-env-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-env-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
||||
const baseEnv = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
delete baseEnv.OPENCODE_CONFIG_DIR;
|
||||
delete baseEnv.XDG_CONFIG_HOME;
|
||||
|
||||
run('skills install -y --providers=opencode --scope=global --no-hooks', {
|
||||
cwd: tmp,
|
||||
env: { ...baseEnv, OPENCODE_CONFIG_DIR: join(home, 'occfg') },
|
||||
});
|
||||
expect(existsSync(join(home, 'occfg', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
|
||||
run('skills install -y --providers=opencode --scope=global --no-hooks', {
|
||||
cwd: tmp,
|
||||
env: { ...baseEnv, XDG_CONFIG_HOME: join(home, 'xdg') },
|
||||
});
|
||||
expect(existsSync(join(home, 'xdg', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 20000);
|
||||
|
||||
test('global OpenCode install migrates a legacy ~/.opencode/skills copy, sparing siblings (#406)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-migrate-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-migrate-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
writeSkill(home, '.opencode', 'impeccable');
|
||||
writeSkill(home, '.opencode', 'unrelated-skill');
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
delete env.OPENCODE_CONFIG_DIR;
|
||||
delete env.XDG_CONFIG_HOME;
|
||||
|
||||
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
|
||||
|
||||
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
// The stranded legacy copy is gone; the sibling skill is untouched.
|
||||
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false);
|
||||
expect(existsSync(join(home, '.opencode', 'skills', 'unrelated-skill', 'SKILL.md'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('OpenCode migration never follows a symlinked legacy skills dir (#406)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-symlink-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-symlink-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
// Shared skill storage with ~/.opencode/skills symlinked at it. Deleting
|
||||
// "the legacy copy" through the link would destroy the shared original.
|
||||
writeSkill(join(home, '.config'), 'agents', 'impeccable');
|
||||
mkdirSync(join(home, '.opencode'), { recursive: true });
|
||||
symlinkSync(join(home, '.config', 'agents', 'skills'), join(home, '.opencode', 'skills'), 'dir');
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
delete env.OPENCODE_CONFIG_DIR;
|
||||
delete env.XDG_CONFIG_HOME;
|
||||
|
||||
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
|
||||
|
||||
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
// The shared store behind the symlink is intact, link included.
|
||||
expect(existsSync(join(home, '.config', 'agents', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
expect(lstatSync(join(home, '.opencode', 'skills')).isSymbolicLink()).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('OpenCode migration leaves a home-rooted repo project install alone (#406)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-homerepo-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-homerepo-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
// The home dir IS a repo (dotfiles setup): .opencode/skills there is a
|
||||
// live project-scope install, not a stranded pre-#406 global one.
|
||||
execSync('git init', { cwd: home });
|
||||
writeSkill(home, '.opencode', 'impeccable');
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
delete env.OPENCODE_CONFIG_DIR;
|
||||
delete env.XDG_CONFIG_HOME;
|
||||
|
||||
run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env });
|
||||
|
||||
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(home, '.opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('global install detects OpenCode from ~/.config/opencode alone (#406)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-detect-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-detect-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
// No ~/.opencode at all; only the config dir marks OpenCode as present.
|
||||
mkdirSync(join(home, '.config', 'opencode'), { recursive: true });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
delete env.OPENCODE_CONFIG_DIR;
|
||||
delete env.XDG_CONFIG_HOME;
|
||||
|
||||
const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env });
|
||||
|
||||
expect(output).toContain('Installed impeccable into: .opencode (global)');
|
||||
expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
// Project scope must stay at .pi/skills/ even when the git root IS the home
|
||||
// dir (dotfiles repos), where scope can't be inferred from the path alone.
|
||||
// An existing global install at ~/.pi/agent/skills must not swallow the
|
||||
@@ -1253,6 +1401,154 @@ describe('hook manifest merge helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hook command path resolution (issue #399, part 1) ───────────────────────
|
||||
// The bundled Claude manifest ships a ${CLAUDE_PROJECT_DIR}-relative command.
|
||||
// That resolves per-project, so a user-level (~/.claude/settings.local.json)
|
||||
// hook — which fires in EVERY project — must be rewritten to the resolved
|
||||
// absolute skill path, or Node crashes on every PostToolUse/Stop in projects
|
||||
// without a local skill copy. Project-level hooks keep ${CLAUDE_PROJECT_DIR}.
|
||||
// Both are wrapped with a missing-file guard so a missing script exits 0.
|
||||
|
||||
// A bundle whose Claude manifest mirrors production: ${CLAUDE_PROJECT_DIR}-relative.
|
||||
function createProjectDirBundle(root) {
|
||||
const bundleRoot = join(root, 'projdir-bundle');
|
||||
const skillDir = join(bundleRoot, '.claude', 'skills', 'impeccable', 'scripts');
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(bundleRoot, '.claude', 'skills', 'impeccable', 'SKILL.md'),
|
||||
'---\nname: impeccable\nversion: 9.9.9-local\n---\nbundle\n');
|
||||
mkdirSync(join(bundleRoot, '.claude'), { recursive: true });
|
||||
writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
||||
{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' },
|
||||
] }],
|
||||
Stop: [{ hooks: [
|
||||
{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' },
|
||||
] }],
|
||||
},
|
||||
}, null, 2));
|
||||
return bundleRoot;
|
||||
}
|
||||
|
||||
function claudeHookCommands(manifestPath) {
|
||||
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
return Object.values(parsed.hooks).flatMap(entries =>
|
||||
entries.flatMap(entry => (entry.hooks || []).map(h => h.command)));
|
||||
}
|
||||
|
||||
describe('copyProviderHooks: hook command path resolution (#399)', () => {
|
||||
test('project-scope hook keeps ${CLAUDE_PROJECT_DIR} and adds a missing-file guard', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-project-'));
|
||||
const bundleDir = createProjectDirBundle(tmp);
|
||||
|
||||
// skillRoot === root === a non-home project dir: keep the portable token.
|
||||
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: tmp });
|
||||
|
||||
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
for (const command of commands) {
|
||||
expect(command).toContain('${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs');
|
||||
expect(command).not.toContain(tmp); // no absolute rewrite for project scope
|
||||
expect(command).toContain('[ ! -f '); // guarded so a missing file exits 0
|
||||
expect(command).not.toContain('|| true'); // must preserve node's exit code
|
||||
}
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The user/global case (isHomeDir(root) true) is driven end-to-end through a
|
||||
// child process below ('user-level update writes an absolute, guarded hook'),
|
||||
// where HOME is set in the child's env so os.homedir() reflects it. It cannot
|
||||
// be faked reliably in-process, so it is not unit-tested here.
|
||||
|
||||
test('project hook pointing at a global skill uses the absolute skill path', () => {
|
||||
// --scope=global shape: manifest root is the project, skill lives in home.
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-hook-split-'));
|
||||
const skillHome = mkdtempSync(join(tmpdir(), 'imp-hook-skillroot-'));
|
||||
const bundleDir = createProjectDirBundle(tmp);
|
||||
|
||||
copyProviderHooks(bundleDir, tmp, ['.claude'], { skillRoot: skillHome });
|
||||
|
||||
const commands = claudeHookCommands(join(tmp, '.claude', 'settings.local.json'));
|
||||
const absolute = join(skillHome, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
|
||||
for (const command of commands) {
|
||||
expect(command).toContain(absolute);
|
||||
expect(command).not.toContain('${CLAUDE_PROJECT_DIR}');
|
||||
expect(command).toContain('[ ! -f ');
|
||||
}
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(skillHome, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update scope resolution (issue #399, part 2) ────────────────────────────
|
||||
|
||||
describe('skills update: names the resolved target and honors scope (#399)', () => {
|
||||
test('user-level update writes an absolute, guarded hook to ~/.claude', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-user-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-update-user-home-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
|
||||
// Seed a user-level install (skills only), then update it with hooks.
|
||||
run('skills install -y --providers=claude --scope=global --no-hooks', { cwd: tmp, env });
|
||||
expect(existsSync(join(home, '.claude', 'skills', 'impeccable', 'SKILL.md'))).toBe(true);
|
||||
|
||||
const output = run('skills update -y --user', { cwd: tmp, env });
|
||||
expect(output).toContain('user level'); // scope named explicitly
|
||||
expect(output).toContain('~'); // resolved home path named, not a bare ".claude"
|
||||
|
||||
const settingsPath = join(home, '.claude', 'settings.local.json');
|
||||
expect(existsSync(settingsPath)).toBe(true);
|
||||
const raw = readFileSync(settingsPath, 'utf8');
|
||||
expect(raw).toContain(join(home, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs'));
|
||||
expect(raw).not.toContain('${CLAUDE_PROJECT_DIR}');
|
||||
expect(raw).toContain('[ ! -f ');
|
||||
// The project dir was never touched.
|
||||
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(false);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 20000);
|
||||
|
||||
test('does not vendor impeccable into a repo that only tracks OTHER skills', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-vendor-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-update-vendor-home-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
|
||||
// The repo tracks a first-party, NON-impeccable skill under .claude/skills.
|
||||
writeSkill(tmp, '.claude', 'house-brand');
|
||||
// A real user-level impeccable install exists.
|
||||
run('skills install -y --providers=claude --scope=global --no-hooks', { cwd: tmp, env });
|
||||
|
||||
const output = run('skills update -y', { cwd: tmp, env });
|
||||
// Targets the user level, not the project's unrelated .claude/skills.
|
||||
expect(output).toContain('user level');
|
||||
expect(existsSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(false);
|
||||
expect(existsSync(join(tmp, '.claude', 'skills', 'house-brand'))).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 20000);
|
||||
|
||||
test('--user with no user-level install reports the resolved user path', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-update-nouser-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-update-nouser-home-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
// Only a project install exists; --user must not fall through to it.
|
||||
run('skills install -y --providers=claude --no-hooks', { cwd: tmp, env });
|
||||
|
||||
expect(() => run('skills update -y --user', { cwd: tmp, env, stdio: 'pipe' })).toThrow();
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 20000);
|
||||
});
|
||||
|
||||
// ─── Update fallback (remote direct download smoke) ──────────────────────────
|
||||
|
||||
describeRemote('skills update: refreshes from the production universal bundle', () => {
|
||||
|
||||
Reference in New Issue
Block a user