mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 17:16:46 +03:00
Merge main: skipScan visual-contrast coverage, live overlay waivers, generated output sync
The generated browser bundle is rebuilt from the merged engine sources in the next commit's build step (both branches had regenerated it). AI-assisted (Claude Code).
This commit is contained in:
@@ -103,7 +103,9 @@ describe('ci-test-plan', () => {
|
||||
assert.equal(outputs.live_svelte_adapter_deepseek, 'false');
|
||||
assert.equal(outputs.cli_remote_e2e, 'false');
|
||||
assert.equal(outputs.core, 'true');
|
||||
assert.equal(outputs.detector, 'true');
|
||||
assert.equal(outputs.live, 'true');
|
||||
assert.equal(outputs.framework, 'true');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -981,6 +981,233 @@ describe('detectUrl — browser-only fixtures', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('extension mode suppresses disabledValues entries from scan config', async () => {
|
||||
// The live overlay resolves .impeccable ignoreValues per page and sends
|
||||
// the survivors as config.disabledValues (issue #639); the detector must
|
||||
// filter them where the findings are assembled, since the overlay draws
|
||||
// its own markers from the collected findings.
|
||||
const normalized = normalizeDesignSystem({
|
||||
frontmatter: {
|
||||
typography: {
|
||||
display: { fontFamily: 'Avenir Next, Georgia, serif' },
|
||||
body: { fontFamily: 'IBM Plex Sans, Arial, sans-serif' },
|
||||
},
|
||||
colors: {
|
||||
ink: '#241f1a',
|
||||
paper: '#f7f4ee',
|
||||
surface: '#ffffff',
|
||||
accent: '#b8422e',
|
||||
border: '#d4c7b9',
|
||||
},
|
||||
rounded: {
|
||||
sm: '4px',
|
||||
md: '8px',
|
||||
'"2xl"': '32px',
|
||||
full: '999px',
|
||||
},
|
||||
},
|
||||
});
|
||||
// The JSON-safe payload shape the extension panel and detectUrl inject as
|
||||
// __IMPECCABLE_CONFIG__.designSystem (serializeDesignSystemForBrowser in
|
||||
// cli/engine/engines/browser/detect-url.mjs).
|
||||
const designSystem = {
|
||||
present: true,
|
||||
hasFonts: normalized.hasFonts === true,
|
||||
allowedFonts: Array.from(normalized.allowedFonts || []),
|
||||
hasColors: normalized.hasColors === true,
|
||||
allowedColors: Array.from(normalized.allowedColorKeys?.values?.() || [])
|
||||
.map(entry => entry?.color)
|
||||
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
|
||||
.map(color => ({ r: color.r, g: color.g, b: color.b })),
|
||||
hasRadii: normalized.hasRadii === true,
|
||||
allowedRadii: (normalized.allowedRadii || [])
|
||||
.map(entry => Number(entry?.px))
|
||||
.filter(px => Number.isFinite(px)),
|
||||
hasPillRadius: normalized.hasPillRadius === true,
|
||||
};
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1280, height: 800 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/design-system.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.dataset.impeccableExtension = 'true';
|
||||
window.__impeccableMessages = [];
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
|
||||
window.__impeccableMessages.push(event.data);
|
||||
});
|
||||
});
|
||||
await page.evaluate(browserScript);
|
||||
const scan = (scanId, disabledValues, extraConfig = {}) => page.evaluate(async (config) => {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'scan', config }, '*');
|
||||
const deadline = Date.now() + 2000;
|
||||
while (
|
||||
Date.now() < deadline &&
|
||||
!window.__impeccableMessages.some(message =>
|
||||
message.source === 'impeccable-results' && message.scanId === config.scanId)
|
||||
) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
}
|
||||
const resultMessage = window.__impeccableMessages.find(message =>
|
||||
message.source === 'impeccable-results' && message.scanId === config.scanId);
|
||||
const flat = (resultMessage?.findings || []).flatMap(group => group.findings || []);
|
||||
return {
|
||||
total: flat.length,
|
||||
colors: flat.filter(finding => finding.type === 'design-system-color').length,
|
||||
colorValues: flat
|
||||
.filter(finding => finding.type === 'design-system-color')
|
||||
.map(finding => finding.ignoreValue || ''),
|
||||
fonts: flat
|
||||
.filter(finding => finding.type === 'design-system-font')
|
||||
.map(finding => finding.ignoreValue || ''),
|
||||
};
|
||||
}, { scanId, visualContrast: false, designSystem, ...(disabledValues ? { disabledValues } : {}), ...extraConfig });
|
||||
|
||||
const unfiltered = await scan('scan-dv-1');
|
||||
assert.ok(
|
||||
unfiltered.fonts.some(value => /poppins/i.test(value)),
|
||||
`expected an undocumented poppins font finding, got: ${JSON.stringify(unfiltered)}`,
|
||||
);
|
||||
|
||||
const filtered = await scan('scan-dv-2', [{ rule: 'design-system-font', value: 'poppins' }]);
|
||||
assert.equal(
|
||||
filtered.fonts.some(value => /poppins/i.test(value)),
|
||||
false,
|
||||
`expected the poppins waiver to suppress its finding, got: ${JSON.stringify(filtered)}`,
|
||||
);
|
||||
const waivedCount = unfiltered.fonts.filter(value => /poppins/i.test(value)).length;
|
||||
assert.equal(
|
||||
filtered.total,
|
||||
unfiltered.total - waivedCount,
|
||||
`expected exactly the waived findings to disappear, got: ${JSON.stringify({ unfiltered, filtered })}`,
|
||||
);
|
||||
assert.equal(
|
||||
filtered.colors,
|
||||
unfiltered.colors,
|
||||
`expected unrelated design-system findings to survive, got: ${JSON.stringify({ unfiltered, filtered })}`,
|
||||
);
|
||||
|
||||
// Color waivers match by value, not by spelling: the browser reports
|
||||
// computed rgb(...) strings, the waiver is written as hex (mirrors
|
||||
// ignoreValueMatches -> colorIgnoreKey in cli/lib/impeccable-config.mjs).
|
||||
const rgbToHex = (value) => {
|
||||
const m = String(value).match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i);
|
||||
if (!m) return null;
|
||||
return `#${[m[1], m[2], m[3]].map(n => Number(n).toString(16).padStart(2, '0')).join('')}`;
|
||||
};
|
||||
const rgbColor = unfiltered.colorValues.find(value => rgbToHex(value));
|
||||
assert.ok(
|
||||
rgbColor,
|
||||
`expected an rgb()-reported design-system-color finding, got: ${JSON.stringify(unfiltered.colorValues)}`,
|
||||
);
|
||||
const hexWaiver = rgbToHex(rgbColor);
|
||||
const colorFiltered = await scan('scan-dv-3', [{ rule: 'design-system-color', value: hexWaiver }]);
|
||||
const waivedColorCount = unfiltered.colorValues.filter(value => value === rgbColor).length;
|
||||
assert.equal(
|
||||
colorFiltered.colors,
|
||||
unfiltered.colors - waivedColorCount,
|
||||
`expected the hex waiver ${hexWaiver} to suppress the ${rgbColor} findings, got: ${JSON.stringify({ colorValues: unfiltered.colorValues, colorFiltered })}`,
|
||||
);
|
||||
assert.equal(
|
||||
colorFiltered.fonts.some(value => /poppins/i.test(value)),
|
||||
true,
|
||||
`expected unrelated font findings to survive the color waiver, got: ${JSON.stringify(colorFiltered)}`,
|
||||
);
|
||||
|
||||
// A page waived wholesale by detector.ignoreFiles arrives with
|
||||
// config.skipScan and must scan to nothing at all.
|
||||
const skipped = await scan('scan-dv-4', null, { skipScan: true });
|
||||
assert.equal(skipped.total, 0, `expected skipScan to empty the scan, got: ${JSON.stringify(skipped)}`);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('extension scan: skipScan suppresses the visual contrast stage too', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
headless: true,
|
||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
// Keep failing visual-contrast cards inside the no-scroll viewport.
|
||||
await page.setViewport({ width: 1280, height: 1000 });
|
||||
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
|
||||
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.dataset.impeccableExtension = 'true';
|
||||
window.__impeccableMessages = [];
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
|
||||
window.__impeccableMessages.push(event.data);
|
||||
});
|
||||
});
|
||||
await page.evaluate(browserScript);
|
||||
const resultsFor = (scanId) => page.evaluate((id) => (
|
||||
(window.__impeccableMessages || [])
|
||||
.filter(m => m.source === 'impeccable-results' && m.scanId === id)
|
||||
.map(m => ({
|
||||
count: m.count,
|
||||
types: (m.findings || []).flatMap(g => (g.findings || []).map(f => f.type || f.id)),
|
||||
}))
|
||||
), scanId);
|
||||
|
||||
// Control: the visual pass runs after the analytic scan and re-posts
|
||||
// results carrying its low-contrast findings. This is exactly what an
|
||||
// ignoreFiles-waived page must not do.
|
||||
await page.evaluate(() => {
|
||||
window.postMessage({
|
||||
source: 'impeccable-command',
|
||||
action: 'scan',
|
||||
config: { scanId: 'vc-skip-1', visualContrast: true, visualContrastMaxCandidates: 20 },
|
||||
}, '*');
|
||||
});
|
||||
const controlDeadline = Date.now() + 8000;
|
||||
let control = [];
|
||||
while (Date.now() < controlDeadline) {
|
||||
control = await resultsFor('vc-skip-1');
|
||||
if (control.some(r => r.types.includes('low-contrast'))) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.ok(
|
||||
control.some(r => r.types.includes('low-contrast')),
|
||||
`expected the control scan's visual pass to report low-contrast, got: ${JSON.stringify(control)}`,
|
||||
);
|
||||
|
||||
// skipScan: a page waived wholesale by detector.ignoreFiles must stay
|
||||
// at zero through the async visual stage as well: no results post with
|
||||
// findings, no markers.
|
||||
await page.evaluate(() => {
|
||||
window.postMessage({
|
||||
source: 'impeccable-command',
|
||||
action: 'scan',
|
||||
config: { scanId: 'vc-skip-2', visualContrast: true, visualContrastMaxCandidates: 20, skipScan: true },
|
||||
}, '*');
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
const skipped = await resultsFor('vc-skip-2');
|
||||
assert.ok(skipped.length >= 1, `expected the skipScan scan to post results, got: ${JSON.stringify(skipped)}`);
|
||||
assert.ok(
|
||||
skipped.every(r => r.count === 0 && r.types.length === 0),
|
||||
`expected every skipScan results post to stay empty, got: ${JSON.stringify(skipped)}`,
|
||||
);
|
||||
const overlays = await page.evaluate(() =>
|
||||
document.querySelectorAll('.impeccable-overlay, .impeccable-label').length);
|
||||
assert.equal(overlays, 0, `expected no markers on a skipScan page, got ${overlays}`);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => {
|
||||
const puppeteer = await import('puppeteer');
|
||||
const browser = await puppeteer.default.launch({
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
truthy,
|
||||
getConfigPath,
|
||||
getLocalConfigPath,
|
||||
getCachePath,
|
||||
getPendingPath,
|
||||
ensureHookGitExcludes,
|
||||
readConfig,
|
||||
readCache,
|
||||
@@ -67,6 +70,12 @@ import {
|
||||
import { normalizeIgnoreValueEntries as normalizeIgnoreValueEntriesCli } from '../cli/lib/impeccable-config.mjs';
|
||||
import { detectHtml, detectText } from '../cli/engine/detect-antipatterns.mjs';
|
||||
|
||||
// Hook state paths are env-sensitive: an ambient IMPECCABLE_CACHE_ROOT (a
|
||||
// developer using the redirect locally) would relocate cache/pending out of
|
||||
// the tmp projects and break stock-path assertions. Clear it up front; the
|
||||
// dedicated issue-#422 suite sets and restores it explicitly.
|
||||
delete process.env.IMPECCABLE_CACHE_ROOT;
|
||||
|
||||
function mkTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-hook-'));
|
||||
}
|
||||
@@ -415,6 +424,195 @@ describe('readCache / persistCache / bumpEditCount', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('IMPECCABLE_CACHE_ROOT relocates hook state (issue #422)', () => {
|
||||
let cwd;
|
||||
let cacheRoot;
|
||||
let savedEnv;
|
||||
beforeEach(() => {
|
||||
cwd = mkTmp();
|
||||
cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-cache-root-'));
|
||||
savedEnv = process.env.IMPECCABLE_CACHE_ROOT;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedEnv === undefined) delete process.env.IMPECCABLE_CACHE_ROOT;
|
||||
else process.env.IMPECCABLE_CACHE_ROOT = savedEnv;
|
||||
fs.rmSync(cwd, { recursive: true, force: true });
|
||||
fs.rmSync(cacheRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps hook state project-local when the env var is unset', () => {
|
||||
delete process.env.IMPECCABLE_CACHE_ROOT;
|
||||
assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json'));
|
||||
assert.equal(getPendingPath(cwd), path.join(cwd, '.impeccable', 'hook.pending.json'));
|
||||
});
|
||||
|
||||
it('treats a blank env var as unset', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = ' ';
|
||||
assert.equal(getCachePath(cwd), path.join(cwd, '.impeccable', 'hook.cache.json'));
|
||||
});
|
||||
|
||||
// Mirrors hookStateDir's slug formula: readable separator-mapped path plus
|
||||
// an 8-hex sha256 disambiguator.
|
||||
function slugFor(p) {
|
||||
const resolved = path.resolve(p);
|
||||
const readable = resolved.replace(/[:\\/.]/g, '-');
|
||||
const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8);
|
||||
return `${readable}-${digest}`;
|
||||
}
|
||||
|
||||
it('relocates cache and pending under a per-project slug dir', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json'));
|
||||
assert.equal(getPendingPath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.pending.json'));
|
||||
});
|
||||
|
||||
it('slug maps separators, colons, and dots to hyphens, with a digest suffix', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const proj = path.join(cwd, 'my.app', 'v2');
|
||||
const slugDir = path.basename(path.dirname(getCachePath(proj)));
|
||||
assert.doesNotMatch(slugDir, /[:\\/.]/, 'no path-significant chars survive');
|
||||
assert.match(slugDir, /my-app-v2-[0-9a-f]{8}$/, `readable slug + 8-hex digest (got ${slugDir})`);
|
||||
});
|
||||
|
||||
it('distinct projects whose readable slugs collide get distinct state dirs', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const dotted = path.join(cwd, 'my.app');
|
||||
const dashed = path.join(cwd, 'my-app');
|
||||
// Readable part is identical for both...
|
||||
assert.equal(
|
||||
path.resolve(dotted).replace(/[:\\/.]/g, '-'),
|
||||
path.resolve(dashed).replace(/[:\\/.]/g, '-'),
|
||||
);
|
||||
// ...but the digest keeps their hook state apart.
|
||||
assert.notEqual(path.dirname(getCachePath(dotted)), path.dirname(getCachePath(dashed)));
|
||||
});
|
||||
|
||||
it('trailing separators and relative segments slug to the same dir', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const canonical = getCachePath(cwd);
|
||||
assert.equal(getCachePath(cwd + path.sep), canonical);
|
||||
assert.equal(getCachePath(path.join(cwd, 'sub', '..')), canonical);
|
||||
});
|
||||
|
||||
it('trims stray whitespace from the env value', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = ` ${cacheRoot} `;
|
||||
assert.equal(getCachePath(cwd), path.join(cacheRoot, slugFor(cwd), 'hook.cache.json'));
|
||||
});
|
||||
|
||||
it('persistCache degrades gracefully when the cache root is unusable', () => {
|
||||
// Point the root at an existing FILE so mkdir of the slug dir must fail.
|
||||
const blocker = path.join(cacheRoot, 'not-a-dir');
|
||||
fs.writeFileSync(blocker, 'x');
|
||||
process.env.IMPECCABLE_CACHE_ROOT = blocker;
|
||||
const cache = readCache(cwd);
|
||||
bumpEditCount(cache, 'sid-1', '/x/a.tsx');
|
||||
assert.equal(persistCache(cwd, cache), false, 'returns false instead of throwing');
|
||||
assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false);
|
||||
});
|
||||
|
||||
it('config paths stay project-local even when the redirect is active', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
assert.equal(getConfigPath(cwd), path.join(cwd, '.impeccable', 'config.json'));
|
||||
assert.equal(getLocalConfigPath(cwd), path.join(cwd, '.impeccable', 'config.local.json'));
|
||||
});
|
||||
|
||||
it('expands a leading ~/ against os.homedir()', () => {
|
||||
// Property check without duplicating the expansion: the tilde form must
|
||||
// resolve identically to the explicit homedir-joined form.
|
||||
process.env.IMPECCABLE_CACHE_ROOT = path.join(os.homedir(), 'impeccable-state');
|
||||
const explicit = getCachePath(cwd);
|
||||
process.env.IMPECCABLE_CACHE_ROOT = '~/impeccable-state';
|
||||
assert.equal(getCachePath(cwd), explicit);
|
||||
assert.ok(explicit.startsWith(os.homedir()), 'anchored under the home dir');
|
||||
});
|
||||
|
||||
it('persistCache round-trips through the redirect dir and leaves the project root clean', () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const cache = readCache(cwd);
|
||||
bumpEditCount(cache, 'sid-1', '/x/a.tsx');
|
||||
assert.equal(persistCache(cwd, cache), true);
|
||||
|
||||
assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root untouched');
|
||||
assert.equal(fs.existsSync(path.join(cacheRoot, slugFor(cwd), 'hook.cache.json')), true);
|
||||
|
||||
const reloaded = readCache(cwd);
|
||||
assert.equal(reloaded.sessions['sid-1'].files['/x/a.tsx'].editCount, 1);
|
||||
});
|
||||
|
||||
function redirectEventFor(file, sessionId = 'redir-sid') {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
cwd,
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: file },
|
||||
};
|
||||
}
|
||||
|
||||
function writeProjectFile(rel, body) {
|
||||
const abs = path.join(cwd, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, body);
|
||||
return abs;
|
||||
}
|
||||
|
||||
it('runHook end-to-end: findings persist under the redirect root, project root stays clean', async () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const file = writeProjectFile('src/Card.tsx', 'noop');
|
||||
const det = fakeDetector([finding('text-overflow', 1)]);
|
||||
|
||||
const first = await runHook({
|
||||
stdinJson: JSON.stringify(redirectEventFor(file)),
|
||||
env: {}, cwd, detector: det,
|
||||
});
|
||||
assert.match(first.stdout, /Design hook findings requiring review/);
|
||||
assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'no project-local footprint');
|
||||
assert.equal(fs.existsSync(getCachePath(cwd)), true, 'cache lands under the redirect root');
|
||||
|
||||
// Session dedup still works across runs through the redirected cache.
|
||||
const second = await runHook({
|
||||
stdinJson: JSON.stringify(redirectEventFor(file)),
|
||||
env: {}, cwd, detector: det,
|
||||
});
|
||||
assert.doesNotMatch(second.stdout, /Design hook findings requiring review/);
|
||||
assert.match(second.stdout, /flagged earlier this session/);
|
||||
});
|
||||
|
||||
it('runHook end-to-end: clean edits keep persisting editCount once redirected state exists', async () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const file = writeProjectFile('src/Card.tsx', 'noop');
|
||||
|
||||
// Earn the footprint (in the redirect dir) with a real finding first.
|
||||
await runHook({
|
||||
stdinJson: JSON.stringify(redirectEventFor(file)),
|
||||
env: {}, cwd, detector: fakeDetector([finding('text-overflow', 1)]),
|
||||
});
|
||||
assert.equal(fs.existsSync(getCachePath(cwd)), true);
|
||||
|
||||
// A clean follow-up edit must still persist its editCount bump — the
|
||||
// opted-in check has to see the redirected cache, not just `<cwd>/.impeccable/`.
|
||||
await runHook({
|
||||
stdinJson: JSON.stringify(redirectEventFor(file)),
|
||||
env: {}, cwd, detector: fakeDetector([]),
|
||||
});
|
||||
const cache = readCache(cwd);
|
||||
assert.equal(cache.sessions['redir-sid'].files[file].editCount, 2);
|
||||
assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false, 'project root still clean');
|
||||
});
|
||||
|
||||
it('runHook end-to-end: a no-footprint clean edit writes nothing anywhere (gates hold under redirect)', async () => {
|
||||
process.env.IMPECCABLE_CACHE_ROOT = cacheRoot;
|
||||
const file = writeProjectFile('src/Card.tsx', 'noop');
|
||||
const r = await runHook({
|
||||
stdinJson: JSON.stringify(redirectEventFor(file)),
|
||||
env: {}, cwd, detector: fakeDetector([]),
|
||||
});
|
||||
assert.match(r.stdout, /No deterministic design-quality issues found/);
|
||||
assert.equal(fs.existsSync(path.join(cwd, '.impeccable')), false);
|
||||
assert.equal(fs.existsSync(getCachePath(cwd)), false, 'redirect root also stays empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureHookGitExcludes()', () => {
|
||||
let cwd;
|
||||
beforeEach(() => { cwd = mkTmp(); });
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SCRIPT = join(REPO_ROOT, 'skill/scripts/live-browser-ignores.js');
|
||||
|
||||
// Evaluated in the test realm (not a vm context) so the arrays the resolver
|
||||
// returns share this realm's prototypes and deepEqual compares them plainly.
|
||||
function loadIgnoresApi() {
|
||||
const source = readFileSync(SCRIPT, 'utf-8');
|
||||
const factory = vm.runInThisContext(
|
||||
`(function (window) {\n${source}\nreturn window.__IMPECCABLE_LIVE_IGNORES__;\n})`,
|
||||
{ filename: SCRIPT },
|
||||
);
|
||||
return factory({});
|
||||
}
|
||||
|
||||
const resolve = loadIgnoresApi().resolveDetectIgnores;
|
||||
|
||||
const EMPTY = { disabledRules: [], disabledValues: [], skipScan: false };
|
||||
|
||||
describe('live-browser-ignores resolver', () => {
|
||||
it('registers a versioned API on the root', () => {
|
||||
const api = loadIgnoresApi();
|
||||
assert.equal(api.version, 1);
|
||||
assert.equal(typeof api.resolveDetectIgnores, 'function');
|
||||
});
|
||||
|
||||
it('degrades to an empty filter when the config global is missing or malformed', () => {
|
||||
assert.deepEqual(resolve(), EMPTY);
|
||||
assert.deepEqual(resolve({ ignores: undefined, pathname: '/index.html' }), EMPTY);
|
||||
assert.deepEqual(resolve({ ignores: null, pathname: '/index.html' }), EMPTY);
|
||||
assert.deepEqual(resolve({ ignores: 'nonsense', pathname: '/index.html' }), EMPTY);
|
||||
assert.deepEqual(resolve({ ignores: {}, pathname: '/index.html' }), EMPTY);
|
||||
});
|
||||
|
||||
it('does not spread a string ignoreRules into characters', () => {
|
||||
// `"ignoreRules": "foo"` in a hand-edited config must disable nothing,
|
||||
// not look like it disabled three one-letter rules.
|
||||
const out = resolve({ ignores: { ignoreRules: 'foo' }, pathname: '/index.html' });
|
||||
assert.deepEqual(out, EMPTY);
|
||||
});
|
||||
|
||||
it('forwards ignoreRules normalized and deduplicated', () => {
|
||||
const out = resolve({
|
||||
ignores: { ignoreRules: ['Dark-Glow', 'dark-glow', ' gradient-text ', '', 42, null] },
|
||||
pathname: '/index.html',
|
||||
});
|
||||
assert.deepEqual(out.disabledRules, ['dark-glow', 'gradient-text']);
|
||||
});
|
||||
|
||||
it('forwards unscoped value entries and drops malformed ones', () => {
|
||||
const out = resolve({
|
||||
ignores: {
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: 'Geist+Mono' },
|
||||
null,
|
||||
'not-an-entry',
|
||||
{ rule: '', value: 'x' },
|
||||
{ rule: 'overused-font', value: '' },
|
||||
],
|
||||
},
|
||||
pathname: '/index.html',
|
||||
});
|
||||
assert.deepEqual(out.disabledValues, [{ rule: 'overused-font', value: 'geist mono' }]);
|
||||
});
|
||||
|
||||
it('applies wildcard entries only on pages their globs name', () => {
|
||||
const ignores = {
|
||||
roots: ['prototype/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['prototype/attack-the-soc.html'] },
|
||||
],
|
||||
};
|
||||
const onPage = resolve({ ignores, pathname: '/attack-the-soc.html' });
|
||||
assert.deepEqual(onPage.disabledRules, ['dark-glow']);
|
||||
const elsewhere = resolve({ ignores, pathname: '/index.html' });
|
||||
assert.deepEqual(elsewhere.disabledRules, []);
|
||||
});
|
||||
|
||||
it('never applies an unscoped wildcard entry, matching the CLI', () => {
|
||||
// isIgnoredFindingValue in cli/lib/impeccable-config.mjs returns false
|
||||
// for a wildcard entry with no files; project-wide suppression is
|
||||
// ignoreRules' job.
|
||||
const out = resolve({
|
||||
ignores: { ignoreValues: [{ rule: 'dark-glow', value: '*' }] },
|
||||
pathname: '/index.html',
|
||||
});
|
||||
assert.deepEqual(out, EMPTY);
|
||||
});
|
||||
|
||||
it('drops scoped value entries on pages outside their globs', () => {
|
||||
const ignores = {
|
||||
roots: ['prototype/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'overused-font', value: 'geist mono', files: ['prototype/mgmt-demo.html'] },
|
||||
],
|
||||
};
|
||||
const onPage = resolve({ ignores, pathname: '/mgmt-demo.html' });
|
||||
assert.deepEqual(onPage.disabledValues, [{ rule: 'overused-font', value: 'geist mono' }]);
|
||||
const elsewhere = resolve({ ignores, pathname: '/index.html' });
|
||||
assert.deepEqual(elsewhere.disabledValues, []);
|
||||
});
|
||||
|
||||
it('does not lend one entry\'s glob prefix to other pages', () => {
|
||||
// The trap from issue #639: prefixes come only from `roots`, never from
|
||||
// the ignore globs themselves. An entry scoped to prototype/library/**
|
||||
// must not suppress on prototype/index.html.
|
||||
const ignores = {
|
||||
roots: ['prototype/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'em-dash-overuse', value: '*', files: ['prototype/library/**'] },
|
||||
],
|
||||
};
|
||||
const inside = resolve({ ignores, pathname: '/library/buttons.html' });
|
||||
assert.deepEqual(inside.disabledRules, ['em-dash-overuse']);
|
||||
const outside = resolve({ ignores, pathname: '/index.html' });
|
||||
assert.deepEqual(outside.disabledRules, []);
|
||||
});
|
||||
|
||||
it('resolves root and directory URLs to their index file', () => {
|
||||
const ignores = {
|
||||
roots: ['prototype/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['prototype/index.html'] },
|
||||
{ rule: 'gradient-text', value: '*', files: ['prototype/news/index.html'] },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(resolve({ ignores, pathname: '/' }).disabledRules, ['dark-glow']);
|
||||
assert.deepEqual(resolve({ ignores, pathname: '/news/' }).disabledRules, ['gradient-text']);
|
||||
});
|
||||
|
||||
it('matches path suffixes like the CLI scoped-file matcher', () => {
|
||||
// findingMatchesScopedIgnoreFile tries every path suffix of the finding's
|
||||
// file, so `library/**` written without the prototype/ prefix still
|
||||
// scopes to the library pages.
|
||||
const ignores = {
|
||||
roots: ['prototype/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'em-dash-overuse', value: '*', files: ['library/**'] },
|
||||
{ rule: 'dark-glow', value: '*', files: ['buttons.html'] },
|
||||
],
|
||||
};
|
||||
const out = resolve({ ignores, pathname: '/library/buttons.html' });
|
||||
assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'em-dash-overuse']);
|
||||
});
|
||||
|
||||
it('supports the CLI glob dialect, including alternation', () => {
|
||||
const ignores = {
|
||||
roots: ['prototype/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['prototype/{index,about}.html'] },
|
||||
{ rule: 'gradient-text', value: '*', files: ['prototype/page-?.html'] },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(resolve({ ignores, pathname: '/about.html' }).disabledRules, ['dark-glow']);
|
||||
assert.deepEqual(resolve({ ignores, pathname: '/page-3.html' }).disabledRules, ['gradient-text']);
|
||||
assert.deepEqual(resolve({ ignores, pathname: '/page-33.html' }).disabledRules, []);
|
||||
});
|
||||
|
||||
it('treats glob metacharacters in filenames literally', () => {
|
||||
const ignores = {
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['pricing (v2).html'] },
|
||||
],
|
||||
};
|
||||
const out = resolve({ ignores, pathname: '/pricing (v2).html' });
|
||||
assert.deepEqual(out.disabledRules, ['dark-glow']);
|
||||
const near = resolve({ ignores, pathname: '/pricing xv2y.html' });
|
||||
assert.deepEqual(near.disabledRules, []);
|
||||
});
|
||||
|
||||
it('accepts a single `file` string alongside `files`', () => {
|
||||
const out = resolve({
|
||||
ignores: {
|
||||
ignoreValues: [{ rule: 'dark-glow', value: '*', file: 'index.html' }],
|
||||
},
|
||||
pathname: '/index.html',
|
||||
});
|
||||
assert.deepEqual(out.disabledRules, ['dark-glow']);
|
||||
});
|
||||
|
||||
it('asserts no prefix when the configured roots share no common ancestor', () => {
|
||||
// With src/**/*.html and public/**/*.html both configured, no single
|
||||
// document root maps /foo.html to a unique project file, so no prefix
|
||||
// is asserted. A waiver naming src/foo.html must not hide a finding on
|
||||
// a page served from public/foo.html; ambiguity resolves to showing
|
||||
// the finding. Bare-path spellings still apply whichever root serves it.
|
||||
const ignores = {
|
||||
roots: ['src/', 'public/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['src/foo.html'] },
|
||||
{ rule: 'clipped-overflow-container', value: '*', files: ['src/foo.html', 'public/foo.html'] },
|
||||
{ rule: 'em-dash-overuse', value: '*', files: ['foo.html'] },
|
||||
{ rule: 'gradient-text', value: '*', files: ['**/foo.html'] },
|
||||
],
|
||||
};
|
||||
const out = resolve({ ignores, pathname: '/foo.html' });
|
||||
assert.deepEqual(out.disabledRules.sort(), ['em-dash-overuse', 'gradient-text']);
|
||||
});
|
||||
|
||||
it('reduces nested roots to their common ancestor so normal waivers keep applying', () => {
|
||||
// Globs at two depths in one tree (prototype/*.html plus
|
||||
// prototype/library/**/*.html) derive the prefixes prototype/ and
|
||||
// prototype/library/. Those are not alternative identities: one server
|
||||
// serves both, so the document root sits at their common ancestor and
|
||||
// a project-relative waiver like prototype/index.html must apply.
|
||||
const ignores = {
|
||||
roots: ['prototype/', 'prototype/library/'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['prototype/index.html'] },
|
||||
{ rule: 'em-dash-overuse', value: '*', files: ['prototype/library/**'] },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(
|
||||
resolve({ ignores, pathname: '/index.html' }).disabledRules,
|
||||
['dark-glow'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolve({ ignores, pathname: '/library/buttons.html' }).disabledRules,
|
||||
['em-dash-overuse'],
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a URL to its one served file when pageFiles knows it', () => {
|
||||
// PR #645 review discussion r3840011436: with src/ and public/ both
|
||||
// served, /foo.html used to borrow identities from every root. The
|
||||
// served page list disambiguates: this URL serves public/foo.html, so
|
||||
// src-scoped waivers must not apply.
|
||||
const ignores = {
|
||||
roots: ['src/', 'public/'],
|
||||
pageFiles: ['src/other.html', 'public/foo.html'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['src/foo.html'] },
|
||||
{ rule: 'gradient-text', value: '*', files: ['public/foo.html'] },
|
||||
],
|
||||
};
|
||||
const out = resolve({ ignores, pathname: '/foo.html' });
|
||||
assert.deepEqual(out.disabledRules, ['gradient-text']);
|
||||
});
|
||||
|
||||
it('keeps ambiguity conservative when served files share the URL suffix', () => {
|
||||
const ignores = {
|
||||
roots: ['src/', 'public/'],
|
||||
pageFiles: ['src/foo.html', 'public/foo.html'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['src/foo.html'] },
|
||||
{ rule: 'gradient-text', value: '*', files: ['public/foo.html'] },
|
||||
{ rule: 'em-dash-overuse', value: '*', files: ['foo.html'] },
|
||||
],
|
||||
};
|
||||
const out = resolve({ ignores, pathname: '/foo.html' });
|
||||
// Neither root-scoped waiver can claim the page; the bare spelling
|
||||
// still applies whichever root serves it.
|
||||
assert.deepEqual(out.disabledRules, ['em-dash-overuse']);
|
||||
});
|
||||
|
||||
it('falls back to the common ancestor when index files collide across depths', () => {
|
||||
// /index.html suffix-matches both served index files; the ambiguity
|
||||
// resolves through the common ancestor, which still yields the correct
|
||||
// shallow identity and never the deep one.
|
||||
const ignores = {
|
||||
roots: ['prototype/', 'prototype/library/'],
|
||||
pageFiles: ['prototype/index.html', 'prototype/library/index.html'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['prototype/index.html'] },
|
||||
{ rule: 'gradient-text', value: '*', files: ['prototype/library/index.html'] },
|
||||
],
|
||||
};
|
||||
const out = resolve({ ignores, pathname: '/index.html' });
|
||||
assert.deepEqual(out.disabledRules, ['dark-glow']);
|
||||
});
|
||||
|
||||
it('skips the scan on pages named by ignoreFiles', () => {
|
||||
const ignores = {
|
||||
roots: ['prototype/'],
|
||||
ignoreFiles: ['prototype/library/**'],
|
||||
ignoreRules: ['dark-glow'],
|
||||
};
|
||||
const waived = resolve({ ignores, pathname: '/library/buttons.html' });
|
||||
assert.deepEqual(waived, { disabledRules: [], disabledValues: [], skipScan: true });
|
||||
const scanned = resolve({ ignores, pathname: '/index.html' });
|
||||
assert.equal(scanned.skipScan, false);
|
||||
assert.deepEqual(scanned.disabledRules, ['dark-glow']);
|
||||
});
|
||||
|
||||
it('matches ignoreFiles by basename like the CLI glob matcher', () => {
|
||||
const out = resolve({
|
||||
ignores: { ignoreFiles: ['buttons.html'], roots: ['prototype/'] },
|
||||
pathname: '/library/buttons.html',
|
||||
});
|
||||
assert.equal(out.skipScan, true);
|
||||
});
|
||||
|
||||
it('treats a malformed ignoreFiles value as no waiver at all', () => {
|
||||
const out = resolve({
|
||||
ignores: { ignoreFiles: 'prototype/**', roots: [] },
|
||||
pathname: '/index.html',
|
||||
});
|
||||
assert.equal(out.skipScan, false);
|
||||
});
|
||||
|
||||
it('drops entries scoped to source paths that no route URL can match', () => {
|
||||
// Framework apps inject into source files while scans see route URLs; a
|
||||
// source-scoped entry must fail conservative (finding shown), never
|
||||
// suppress by accident. Pinned so a refactor cannot flip the direction.
|
||||
const ignores = {
|
||||
roots: ['src/'],
|
||||
pageFiles: ['src/routes/about/+page.svelte'],
|
||||
ignoreValues: [
|
||||
{ rule: 'dark-glow', value: '*', files: ['src/routes/about/+page.svelte'] },
|
||||
],
|
||||
};
|
||||
const out = resolve({ ignores, pathname: '/about' });
|
||||
assert.deepEqual(out.disabledRules, []);
|
||||
assert.equal(out.skipScan, false);
|
||||
});
|
||||
|
||||
it('survives malformed roots and percent-escapes without throwing', () => {
|
||||
const out = resolve({
|
||||
ignores: {
|
||||
roots: 7,
|
||||
pageFiles: 'not-a-list',
|
||||
ignoreFiles: [null, 42],
|
||||
ignoreRules: ['dark-glow'],
|
||||
ignoreValues: [{ rule: 'gradient-text', value: '*', files: ['broken%.html'] }],
|
||||
},
|
||||
pathname: '/broken%.html',
|
||||
});
|
||||
assert.deepEqual(out.disabledRules.sort(), ['dark-glow', 'gradient-text']);
|
||||
});
|
||||
});
|
||||
@@ -714,8 +714,18 @@ describe('live-browser.js regression guards', () => {
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,160}?config: \{ scanId \}/,
|
||||
'Detect scans must send a fresh scan id to the detector',
|
||||
/function requestDetectScan\(\)[\s\S]{0,240}?const scanId = String\(\+\+detectScanSeq\);[\s\S]{0,80}?activeDetectScanId = scanId;[\s\S]{0,2200}?config: \{\s*scanId,\s*disabledRules: ignores\.disabledRules \|\| \[\],\s*disabledValues: ignores\.disabledValues \|\| \[\],\s*skipScan: ignores\.skipScan === true,\s*\},/,
|
||||
'Detect scans must send a fresh scan id plus the resolved project waivers to the detector',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/let ignores = \{ disabledRules: \[\], disabledValues: \[\], skipScan: false \};\s*if \(typeof ignoresApi\?\.resolveDetectIgnores === 'function'\) \{\s*try \{/,
|
||||
'a cached live.js without the ignores resolver part must still scan, just unfiltered',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/\} catch \(e\) \{\s*ignores = \{ disabledRules: \[\], disabledValues: \[\], skipScan: false \};\s*\}/,
|
||||
'a throwing ignores resolver must degrade to an unfiltered scan, not break the detect toggle',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
|
||||
@@ -12,13 +12,15 @@ describe('live browser script parts', () => {
|
||||
it('resolves the canonical browser script order', () => {
|
||||
const parts = resolveLiveBrowserScriptParts('/repo/skill/scripts');
|
||||
|
||||
assert.deepEqual(parts.map((part) => part.name), ['session-state', 'dom-helpers', 'browser-ui']);
|
||||
assert.deepEqual(parts.map((part) => part.name), ['session-state', 'dom-helpers', 'project-ignores', 'browser-ui']);
|
||||
assert.equal(parts[0].file, 'live-browser-session.js');
|
||||
assert.equal(parts[1].file, 'live-browser-dom.js');
|
||||
assert.equal(parts[2].file, 'live-browser.js');
|
||||
assert.equal(parts[2].file, 'live-browser-ignores.js');
|
||||
assert.equal(parts[3].file, 'live-browser.js');
|
||||
assert.equal(parts[0].path, path.join('/repo/skill/scripts', 'live-browser-session.js'));
|
||||
assert.equal(parts[1].path, path.join('/repo/skill/scripts', 'live-browser-dom.js'));
|
||||
assert.equal(parts[2].path, path.join('/repo/skill/scripts', 'live-browser.js'));
|
||||
assert.equal(parts[2].path, path.join('/repo/skill/scripts', 'live-browser-ignores.js'));
|
||||
assert.equal(parts[3].path, path.join('/repo/skill/scripts', 'live-browser.js'));
|
||||
});
|
||||
|
||||
it('asserts missing script parts by name', () => {
|
||||
@@ -37,6 +39,7 @@ describe('live browser script parts', () => {
|
||||
assert.deepEqual(loaded.map((part) => part.source), [
|
||||
'source:live-browser-session.js',
|
||||
'source:live-browser-dom.js',
|
||||
'source:live-browser-ignores.js',
|
||||
'source:live-browser.js',
|
||||
]);
|
||||
});
|
||||
@@ -50,16 +53,20 @@ describe('live browser script parts', () => {
|
||||
parts: [
|
||||
{ name: 'session-state', file: 'live-browser-session.js', source: 'window.__SESSION_PART__ = true;' },
|
||||
{ name: 'dom-helpers', file: 'live-browser-dom.js', source: 'window.__DOM_PART__ = true;' },
|
||||
{ name: 'project-ignores', file: 'live-browser-ignores.js', source: 'window.__IGNORES_PART__ = true;' },
|
||||
{ name: 'browser-ui', file: 'live-browser.js', source: 'window.__BROWSER_PART__ = true;' },
|
||||
],
|
||||
projectIgnores: { ignoreRules: ['dark-glow'], ignoreValues: [], ignoreFiles: [], roots: ['prototype/'], pageFiles: ['prototype/index.html'] },
|
||||
});
|
||||
|
||||
const tokenIndex = script.indexOf('window.__IMPECCABLE_TOKEN__');
|
||||
const portIndex = script.indexOf('window.__IMPECCABLE_PORT__');
|
||||
const commandPrefixIndex = script.indexOf('window.__IMPECCABLE_COMMAND_PREFIX__');
|
||||
const vocabIndex = script.indexOf('window.__IMPECCABLE_VOCAB__');
|
||||
const projectIgnoresIndex = script.indexOf('window.__IMPECCABLE_PROJECT_IGNORES__');
|
||||
const sessionIndex = script.indexOf('window.__SESSION_PART__');
|
||||
const domIndex = script.indexOf('window.__DOM_PART__');
|
||||
const ignoresIndex = script.indexOf('window.__IGNORES_PART__');
|
||||
const browserIndex = script.indexOf('window.__BROWSER_PART__');
|
||||
|
||||
assert.ok(tokenIndex !== -1);
|
||||
@@ -67,11 +74,26 @@ describe('live browser script parts', () => {
|
||||
assert.ok(portIndex < commandPrefixIndex);
|
||||
assert.ok(commandPrefixIndex < vocabIndex);
|
||||
assert.match(script, /window\.__IMPECCABLE_COMMAND_PREFIX__ = "\$"/);
|
||||
assert.ok(vocabIndex < sessionIndex);
|
||||
assert.ok(vocabIndex < projectIgnoresIndex);
|
||||
assert.ok(projectIgnoresIndex < sessionIndex);
|
||||
assert.ok(sessionIndex < domIndex);
|
||||
assert.ok(domIndex < browserIndex);
|
||||
assert.ok(domIndex < ignoresIndex);
|
||||
assert.ok(ignoresIndex < browserIndex);
|
||||
assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = \{"ignoreRules":\["dark-glow"\],"ignoreValues":\[\],"ignoreFiles":\[\],"roots":\["prototype\/"\],"pageFiles":\["prototype\/index\.html"\]\};/);
|
||||
assert.match(script, /impeccable live script part: session-state \(live-browser-session\.js\)/);
|
||||
assert.match(script, /impeccable live script part: dom-helpers \(live-browser-dom\.js\)/);
|
||||
assert.match(script, /impeccable live script part: project-ignores \(live-browser-ignores\.js\)/);
|
||||
assert.match(script, /impeccable live script part: browser-ui \(live-browser\.js\)/);
|
||||
});
|
||||
|
||||
it('serializes null project ignores when none are passed', () => {
|
||||
const script = assembleLiveBrowserScript({
|
||||
token: 'token-a',
|
||||
port: 8421,
|
||||
vocabulary: [],
|
||||
parts: [],
|
||||
});
|
||||
|
||||
assert.match(script, /window\.__IMPECCABLE_PROJECT_IGNORES__ = null;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,38 @@ import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { livePathGlobToRegex } from '../skill/scripts/lib/live-path-globs.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const INJECT = resolve(__dirname, '..', 'skill/scripts/live-inject.mjs');
|
||||
|
||||
describe('live path globs', () => {
|
||||
it('matches recursive segments, including zero segments', () => {
|
||||
const anywhere = livePathGlobToRegex('**/index.html');
|
||||
assert.equal(anywhere.test('index.html'), true);
|
||||
assert.equal(anywhere.test('public/index.html'), true);
|
||||
assert.equal(anywhere.test('apps/web/public/index.html'), true);
|
||||
|
||||
const underPublic = livePathGlobToRegex('public/**/*.html');
|
||||
assert.equal(underPublic.test('public/index.html'), true);
|
||||
assert.equal(underPublic.test('public/docs/index.html'), true);
|
||||
assert.equal(underPublic.test('src/index.html'), false);
|
||||
});
|
||||
|
||||
it('keeps single-star and question-mark matches inside one segment', () => {
|
||||
const pattern = livePathGlobToRegex('pages/*/item?.html');
|
||||
assert.equal(pattern.test('pages/docs/item1.html'), true);
|
||||
assert.equal(pattern.test('pages/docs/deep/item1.html'), false);
|
||||
assert.equal(pattern.test('pages/docs/item12.html'), false);
|
||||
});
|
||||
|
||||
it('treats regular-expression punctuation as literal path text', () => {
|
||||
const pattern = livePathGlobToRegex('pages/[draft]/item+.html');
|
||||
assert.equal(pattern.test('pages/[draft]/item+.html'), true);
|
||||
assert.equal(pattern.test('pages/d/itemm.html'), false);
|
||||
});
|
||||
});
|
||||
|
||||
function runInject(cwd, configPath, args) {
|
||||
try {
|
||||
const out = execFileSync('node', [INJECT, ...args], {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, 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 { fileURLToPath } from 'node:url';
|
||||
import { collectProjectDetectorIgnores } from '../skill/scripts/live/project-ignores.mjs';
|
||||
|
||||
const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const SCRIPTS_DIR = path.join(REPO_ROOT, 'skill', 'scripts');
|
||||
|
||||
const tempDirs = [];
|
||||
function makeTemp() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-project-ignores-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
after(() => {
|
||||
for (const dir of tempDirs) {
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
});
|
||||
|
||||
function write(root, rel, content) {
|
||||
const filePath = path.join(root, rel);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function writeDetectorConfig(root, detector) {
|
||||
write(root, '.impeccable/config.json', JSON.stringify({ detector }, null, 2));
|
||||
}
|
||||
|
||||
function writeLiveConfig(root, files) {
|
||||
write(root, '.impeccable/live/config.json', JSON.stringify({
|
||||
files,
|
||||
insertBefore: '</body>',
|
||||
commentSyntax: 'html',
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
describe('collectProjectDetectorIgnores', () => {
|
||||
it('collects waivers, roots, and pageFiles from a single-root project', () => {
|
||||
const app = makeTemp();
|
||||
write(app, 'package.json', '{"name":"single","private":true}\n');
|
||||
writeDetectorConfig(app, {
|
||||
ignoreRules: ['ai-color-palette'],
|
||||
ignoreFiles: ['prototype/legacy/**'],
|
||||
ignoreValues: [
|
||||
{ rule: 'gradient-text', value: '*', files: ['prototype/library/**'], reason: 'stays local' },
|
||||
],
|
||||
});
|
||||
writeLiveConfig(app, ['prototype/index.html', 'prototype/library/buttons.html']);
|
||||
write(app, 'prototype/index.html', '<html></html>');
|
||||
write(app, 'prototype/library/buttons.html', '<html></html>');
|
||||
|
||||
const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR });
|
||||
assert.deepEqual(out.ignoreRules, ['ai-color-palette']);
|
||||
assert.deepEqual(out.ignoreFiles, ['prototype/legacy/**']);
|
||||
// createdAt/reason stay local; only rule/value/files ride to the browser.
|
||||
assert.deepEqual(out.ignoreValues, [
|
||||
{ rule: 'gradient-text', value: '*', files: ['prototype/library/**'] },
|
||||
]);
|
||||
assert.deepEqual(out.roots.sort(), ['prototype/', 'prototype/library/']);
|
||||
assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']);
|
||||
});
|
||||
|
||||
it('reads waivers keyed at the repo root, where the hook and the CLI put them', () => {
|
||||
// The monorepo shape from the PR #645 review: the live server chdirs
|
||||
// onto the child appRoot, while resolveCacheCwd keys the hook's config
|
||||
// at the session cwd, which is the repo root.
|
||||
const repo = makeTemp();
|
||||
const app = path.join(repo, 'site');
|
||||
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
|
||||
write(app, 'package.json', '{"name":"site","private":true}\n');
|
||||
writeDetectorConfig(repo, {
|
||||
ignoreRules: ['ai-color-palette'],
|
||||
ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }],
|
||||
});
|
||||
writeLiveConfig(app, ['prototype/index.html']);
|
||||
write(app, 'prototype/index.html', '<html></html>');
|
||||
|
||||
const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR });
|
||||
assert.deepEqual(out.ignoreRules, ['ai-color-palette']);
|
||||
assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]);
|
||||
// Identities serialize repo-relative so waivers spelled from either root
|
||||
// match through the resolver's suffix expansion.
|
||||
assert.deepEqual(out.roots, ['site/prototype/']);
|
||||
assert.deepEqual(out.pageFiles, ['site/prototype/index.html']);
|
||||
});
|
||||
|
||||
it('unions configs across roots and dedupes identical value entries', () => {
|
||||
const repo = makeTemp();
|
||||
const app = path.join(repo, 'site');
|
||||
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
|
||||
write(app, 'package.json', '{"name":"site","private":true}\n');
|
||||
writeDetectorConfig(repo, {
|
||||
ignoreRules: ['ai-color-palette'],
|
||||
ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }],
|
||||
});
|
||||
writeDetectorConfig(app, {
|
||||
ignoreRules: ['gradient-text', 'ai-color-palette'],
|
||||
ignoreValues: [{ rule: 'overused-font', value: 'space grotesk' }],
|
||||
});
|
||||
writeLiveConfig(app, ['prototype/index.html']);
|
||||
write(app, 'prototype/index.html', '<html></html>');
|
||||
|
||||
const out = collectProjectDetectorIgnores({ appRoot: app, repoRoot: repo, scriptsDir: SCRIPTS_DIR });
|
||||
assert.deepEqual(out.ignoreRules.sort(), ['ai-color-palette', 'gradient-text']);
|
||||
assert.deepEqual(out.ignoreValues, [{ rule: 'overused-font', value: 'space grotesk' }]);
|
||||
});
|
||||
|
||||
it('expands glob file entries to existing files and drops missing literals', () => {
|
||||
const app = makeTemp();
|
||||
write(app, 'package.json', '{"name":"globs","private":true}\n');
|
||||
writeLiveConfig(app, ['prototype/**/*.html', 'prototype/not-created-yet.html']);
|
||||
write(app, 'prototype/index.html', '<html></html>');
|
||||
write(app, 'prototype/library/buttons.html', '<html></html>');
|
||||
|
||||
const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR });
|
||||
assert.deepEqual(out.pageFiles.sort(), ['prototype/index.html', 'prototype/library/buttons.html']);
|
||||
assert.deepEqual(out.roots.sort(), ['prototype/']);
|
||||
});
|
||||
|
||||
it('degrades to empty arrays when nothing is configured', () => {
|
||||
const app = makeTemp();
|
||||
write(app, 'package.json', '{"name":"bare","private":true}\n');
|
||||
const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR });
|
||||
assert.deepEqual(out, { ignoreRules: [], ignoreValues: [], ignoreFiles: [], roots: [], pageFiles: [] });
|
||||
});
|
||||
|
||||
it('survives a malformed detector config without throwing', () => {
|
||||
const app = makeTemp();
|
||||
write(app, 'package.json', '{"name":"broken","private":true}\n');
|
||||
write(app, '.impeccable/config.json', '{"detector":{"ignoreRules":"foo","ignoreValues":[null,7],"ignoreFiles":{}}}');
|
||||
writeLiveConfig(app, ['prototype/index.html']);
|
||||
write(app, 'prototype/index.html', '<html></html>');
|
||||
|
||||
const out = collectProjectDetectorIgnores({ appRoot: app, scriptsDir: SCRIPTS_DIR });
|
||||
assert.deepEqual(out.ignoreRules, []);
|
||||
assert.deepEqual(out.ignoreValues, []);
|
||||
assert.deepEqual(out.ignoreFiles, []);
|
||||
assert.deepEqual(out.pageFiles, ['prototype/index.html']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user