diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.agents/skills/impeccable/scripts/live-wrap.mjs b/.agents/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.agents/skills/impeccable/scripts/live-wrap.mjs +++ b/.agents/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.claude/skills/impeccable/scripts/live-wrap.mjs b/.claude/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.claude/skills/impeccable/scripts/live-wrap.mjs +++ b/.claude/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.cursor/skills/impeccable/scripts/live-wrap.mjs b/.cursor/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.cursor/skills/impeccable/scripts/live-wrap.mjs +++ b/.cursor/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.gemini/skills/impeccable/scripts/live-wrap.mjs b/.gemini/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.gemini/skills/impeccable/scripts/live-wrap.mjs +++ b/.gemini/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.github/skills/impeccable/scripts/live-wrap.mjs b/.github/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.github/skills/impeccable/scripts/live-wrap.mjs +++ b/.github/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.kiro/skills/impeccable/scripts/live-wrap.mjs b/.kiro/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.kiro/skills/impeccable/scripts/live-wrap.mjs +++ b/.kiro/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.opencode/skills/impeccable/scripts/live-wrap.mjs b/.opencode/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.opencode/skills/impeccable/scripts/live-wrap.mjs +++ b/.opencode/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.pi/skills/impeccable/scripts/live-wrap.mjs b/.pi/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.pi/skills/impeccable/scripts/live-wrap.mjs +++ b/.pi/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.rovodev/skills/impeccable/scripts/live-wrap.mjs b/.rovodev/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.rovodev/skills/impeccable/scripts/live-wrap.mjs +++ b/.rovodev/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/.trae/skills/impeccable/scripts/live-wrap.mjs b/.trae/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/.trae/skills/impeccable/scripts/live-wrap.mjs +++ b/.trae/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/package.json b/package.json index d7ae791cd..647f69d7f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "dev": "bun run server/index.js", "preview": "bun run build && wrangler pages dev", "deploy": "bun run build && wrangler pages deploy build/", - "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-server.test.mjs", + "test": "bun test tests/build.test.js tests/detect-antipatterns.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-server.test.mjs && node --test tests/framework-fixtures.test.mjs", "prepack": "cp README.md README.repo.md && cp README.npm.md README.md", "postpack": "cp README.repo.md README.md && rm README.repo.md", "screenshot": "bun run scripts/screenshot-antipatterns.js", diff --git a/source/skills/impeccable/scripts/live-inject.mjs b/source/skills/impeccable/scripts/live-inject.mjs index 3762c9f00..03d054ae6 100644 --- a/source/skills/impeccable/scripts/live-inject.mjs +++ b/source/skills/impeccable/scripts/live-inject.mjs @@ -18,7 +18,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const CONFIG_PATH = path.join(__dirname, 'config.json'); +const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json'); const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; diff --git a/source/skills/impeccable/scripts/live-wrap.mjs b/source/skills/impeccable/scripts/live-wrap.mjs index cbd5d76b1..965f6a160 100644 --- a/source/skills/impeccable/scripts/live-wrap.mjs +++ b/source/skills/impeccable/scripts/live-wrap.mjs @@ -266,10 +266,13 @@ function searchDir(dir, query, seen, depth, genOpts) { } catch { /* skip unreadable files */ } } - // Then recurse into directories + // Then recurse into directories. Always skip node_modules and .git (never + // project content). dist/build/out are left to the isGeneratedFile guard so + // the includeGenerated second-pass can still find the element there and + // report `generatedMatch`. for (const entry of entries) { if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue; + if (entry.name === 'node_modules' || entry.name === '.git') continue; const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); if (result) return result; } diff --git a/tests/framework-fixtures.test.mjs b/tests/framework-fixtures.test.mjs new file mode 100644 index 000000000..c8dfc054b --- /dev/null +++ b/tests/framework-fixtures.test.mjs @@ -0,0 +1,177 @@ +/** + * Drives live-mode scripts against representative framework project shapes. + * + * Each fixture under tests/framework-fixtures/ is a small project tree with a + * fixture.json that declares the inject config + expected is-generated and + * wrap outcomes. The harness copies the fixture into a tmp git repo, applies + * the fixture's gitignore, and runs the live scripts against it. + * + * Run with: node --test tests/framework-fixtures.test.mjs + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isGeneratedFile } from '../source/skills/impeccable/scripts/is-generated.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPTS_DIR = join(__dirname, '..', 'source', 'skills', 'impeccable', 'scripts'); +const FIXTURES_DIR = join(__dirname, 'framework-fixtures'); + +function listFixtures() { + return readdirSync(FIXTURES_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); +} + +/** + * Stage a fixture into a fresh tmp git repo. Returns the tmp path + loaded + * fixture.json. Caller is responsible for cleanup. + */ +function stageFixture(name) { + const fixtureRoot = join(FIXTURES_DIR, name); + const fixture = JSON.parse(readFileSync(join(fixtureRoot, 'fixture.json'), 'utf-8')); + const gitignore = readFileSync(join(fixtureRoot, 'gitignore.txt'), 'utf-8'); + + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-fixture-')); + cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true }); + writeFileSync(join(tmp, '.gitignore'), gitignore); + writeFileSync(join(tmp, 'impeccable-live.config.json'), JSON.stringify(fixture.config)); + + execFileSync('git', ['init', '-q'], { cwd: tmp }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp }); + execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd: tmp }); + execFileSync('git', ['add', '-A'], { cwd: tmp }); + execFileSync('git', ['commit', '-qm', 'fixture'], { cwd: tmp }); + + return { tmp, fixture }; +} + +function runScript(script, args, opts = {}) { + try { + return execFileSync('node', [join(SCRIPTS_DIR, script), ...args], { + encoding: 'utf-8', + cwd: opts.cwd, + env: { ...process.env, ...(opts.env || {}) }, + }); + } catch (err) { + return { error: err.stdout?.toString() || '' , stderr: err.stderr?.toString() || '' }; + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +for (const name of listFixtures()) { + describe(`fixture · ${name}`, () => { + it('loads fixture.json and has expected tree', () => { + const { tmp, fixture } = stageFixture(name); + try { + assert.ok(fixture.name, 'fixture has a name'); + assert.ok(Array.isArray(fixture.config.files) && fixture.config.files.length > 0); + rmSync(tmp, { recursive: true, force: true }); + } catch (err) { + rmSync(tmp, { recursive: true, force: true }); + throw err; + } + }); + + it('is-generated classifies files correctly', () => { + const { tmp, fixture } = stageFixture(name); + try { + for (const rel of fixture.sourceFiles || []) { + assert.equal( + isGeneratedFile(rel, { cwd: tmp }), + false, + `${rel} should classify as source` + ); + } + for (const rel of fixture.generatedFiles || []) { + assert.equal( + isGeneratedFile(rel, { cwd: tmp }), + true, + `${rel} should classify as generated` + ); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('live-inject --port adds the script tag to every config file', () => { + const { tmp } = stageFixture(name); + try { + const configPath = join(tmp, 'impeccable-live.config.json'); + const out = runScript('live-inject.mjs', ['--port', '9999'], { + cwd: tmp, + env: { IMPECCABLE_LIVE_CONFIG: configPath }, + }); + const result = JSON.parse(typeof out === 'string' ? out : out.error); + assert.equal(result.ok, true, 'inject succeeded'); + 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'); + assert.match(body, /impeccable-live-start/); + assert.match(body, /localhost:9999\/live\.js/); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('live-inject --remove strips the script tag cleanly', () => { + const { tmp } = stageFixture(name); + try { + const configPath = join(tmp, 'impeccable-live.config.json'); + runScript('live-inject.mjs', ['--port', '9999'], { + cwd: tmp, + env: { IMPECCABLE_LIVE_CONFIG: configPath }, + }); + const out = runScript('live-inject.mjs', ['--remove'], { + cwd: tmp, + env: { IMPECCABLE_LIVE_CONFIG: configPath }, + }); + const result = JSON.parse(typeof out === 'string' ? out : out.error); + assert.equal(result.ok, true, 'remove succeeded'); + for (const r of result.results) { + const body = readFileSync(join(tmp, r.file), 'utf-8'); + assert.doesNotMatch(body, /impeccable-live-start/); + assert.doesNotMatch(body, /live\.js/); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('live-wrap routes to the expected source (or emits the expected fallback)', () => { + const { tmp, fixture } = stageFixture(name); + try { + for (const [i, wc] of (fixture.wrapCases || []).entries()) { + const flags = []; + if (wc.args.elementId) flags.push('--element-id', wc.args.elementId); + if (wc.args.classes) flags.push('--classes', wc.args.classes); + if (wc.args.tag) flags.push('--tag', wc.args.tag); + flags.push('--id', `wraptest${i}`, '--count', '3'); + + const out = runScript('live-wrap.mjs', flags, { cwd: tmp }); + const payload = typeof out === 'string' ? out : (out.error || out.stderr); + const parsed = JSON.parse(payload.trim().split('\n').pop()); + + if (wc.expectsError) { + assert.equal(parsed.error, wc.expectsError, `wrap case "${wc.name}": expected error ${wc.expectsError}, got ${JSON.stringify(parsed)}`); + } else { + assert.equal(parsed.file, wc.expectedFile, `wrap case "${wc.name}": landed in ${parsed.file}, expected ${wc.expectedFile}`); + } + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + }); +} diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md new file mode 100644 index 000000000..f29164a06 --- /dev/null +++ b/tests/framework-fixtures/README.md @@ -0,0 +1,43 @@ +# Framework fixtures + +Representative project shapes for exercising live mode against different framework conventions. Each fixture is a small directory tree that the test harness copies into a temp git repo, then drives `live-inject.mjs`, `live-wrap.mjs`, `live-accept.mjs`, and `is-generated.mjs` against. + +## Layout + +``` +/ + files/ project tree the test copies into tmp + gitignore.txt becomes .gitignore in tmp (so we can commit the real files here) + fixture.json config + expected results the test consumes +``` + +`fixture.json` schema: + +```json +{ + "name": "human-readable label", + "config": { ...contents for live-inject.mjs config.json ... }, + "sourceFiles": ["paths that is-generated should classify as source (false)"], + "generatedFiles": ["paths that is-generated should classify as generated (true)"], + "wrapCases": [ + { + "name": "description", + "args": { "classes": "...", "tag": "...", "elementId": "..." }, + "expectedFile": "where wrap should land (relative to fixture root)", + "expectsError": "optional error code, e.g. element_not_in_source" + } + ] +} +``` + +## Current fixtures + +| Fixture | Shape | +|---|---| +| `vite-react/` | Tracked `index.html` shell + `src/App.jsx`. Inject into the shell. | +| `nextjs-app/` | `app/layout.tsx` as JSX inject target (commentSyntax `jsx`). | +| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. | +| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. | +| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. | + +Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`. diff --git a/tests/framework-fixtures/astro/files/src/layouts/Layout.astro b/tests/framework-fixtures/astro/files/src/layouts/Layout.astro new file mode 100644 index 000000000..3253ff876 --- /dev/null +++ b/tests/framework-fixtures/astro/files/src/layouts/Layout.astro @@ -0,0 +1,14 @@ +--- +export interface Props { title: string } +const { title } = Astro.props; +--- + + + + + {title} + + + + + diff --git a/tests/framework-fixtures/astro/files/src/pages/index.astro b/tests/framework-fixtures/astro/files/src/pages/index.astro new file mode 100644 index 000000000..18c0ddd32 --- /dev/null +++ b/tests/framework-fixtures/astro/files/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + +
+

Astro Fixture

+

Minimal Astro tree for live-mode tests.

+
+
One
+
Two
+
+
+
diff --git a/tests/framework-fixtures/astro/fixture.json b/tests/framework-fixtures/astro/fixture.json new file mode 100644 index 000000000..9ee263f70 --- /dev/null +++ b/tests/framework-fixtures/astro/fixture.json @@ -0,0 +1,17 @@ +{ + "name": "Astro", + "config": { + "files": ["src/layouts/Layout.astro"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": ["src/layouts/Layout.astro", "src/pages/index.astro"], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps hero title in page source", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "src/pages/index.astro" + } + ] +} diff --git a/tests/framework-fixtures/astro/gitignore.txt b/tests/framework-fixtures/astro/gitignore.txt new file mode 100644 index 000000000..ddce69b68 --- /dev/null +++ b/tests/framework-fixtures/astro/gitignore.txt @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.astro/ diff --git a/tests/framework-fixtures/multipage-with-generator/files/src/template.js b/tests/framework-fixtures/multipage-with-generator/files/src/template.js new file mode 100644 index 000000000..2e9f66b56 --- /dev/null +++ b/tests/framework-fixtures/multipage-with-generator/files/src/template.js @@ -0,0 +1,8 @@ +// Pretend generator: reads source content, writes HTML into dist/. +// Not executed by the test — it only exists so the fixture has a "source" +// that the agent could reason about when picking where to write variants. +export function render(title, body) { + return ` +${title} +${body}`; +} diff --git a/tests/framework-fixtures/multipage-with-generator/fixture.json b/tests/framework-fixtures/multipage-with-generator/fixture.json new file mode 100644 index 000000000..a031b0496 --- /dev/null +++ b/tests/framework-fixtures/multipage-with-generator/fixture.json @@ -0,0 +1,17 @@ +{ + "name": "Multi-page static with generator", + "config": { + "files": ["dist/index.html", "dist/docs/one.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": ["src/template.js"], + "generatedFiles": ["dist/index.html", "dist/docs/one.html"], + "wrapCases": [ + { + "name": "refuses to wrap inside a generated dist page", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectsError": "element_not_in_source" + } + ] +} diff --git a/tests/framework-fixtures/multipage-with-generator/gitignore.txt b/tests/framework-fixtures/multipage-with-generator/gitignore.txt new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/tests/framework-fixtures/multipage-with-generator/gitignore.txt @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/tests/framework-fixtures/nextjs-app/files/app/layout.tsx b/tests/framework-fixtures/nextjs-app/files/app/layout.tsx new file mode 100644 index 000000000..8c02b1b4f --- /dev/null +++ b/tests/framework-fixtures/nextjs-app/files/app/layout.tsx @@ -0,0 +1,9 @@ +export const metadata = { title: 'Next Fixture' }; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/framework-fixtures/nextjs-app/files/app/page.tsx b/tests/framework-fixtures/nextjs-app/files/app/page.tsx new file mode 100644 index 000000000..6604e62df --- /dev/null +++ b/tests/framework-fixtures/nextjs-app/files/app/page.tsx @@ -0,0 +1,12 @@ +export default function Page() { + return ( +
+

Next Fixture

+

Minimal App Router tree for live-mode tests.

+
+
One
+
Two
+
+
+ ); +} diff --git a/tests/framework-fixtures/nextjs-app/fixture.json b/tests/framework-fixtures/nextjs-app/fixture.json new file mode 100644 index 000000000..c48d85fab --- /dev/null +++ b/tests/framework-fixtures/nextjs-app/fixture.json @@ -0,0 +1,17 @@ +{ + "name": "Next.js (App Router)", + "config": { + "files": ["app/layout.tsx"], + "insertBefore": "", + "commentSyntax": "jsx" + }, + "sourceFiles": ["app/layout.tsx", "app/page.tsx"], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps hero title in source TSX", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "app/page.tsx" + } + ] +} diff --git a/tests/framework-fixtures/nextjs-app/gitignore.txt b/tests/framework-fixtures/nextjs-app/gitignore.txt new file mode 100644 index 000000000..7c8ed2342 --- /dev/null +++ b/tests/framework-fixtures/nextjs-app/gitignore.txt @@ -0,0 +1,3 @@ +node_modules/ +.next/ +out/ diff --git a/tests/framework-fixtures/sveltekit/files/src/app.html b/tests/framework-fixtures/sveltekit/files/src/app.html new file mode 100644 index 000000000..86314eb6e --- /dev/null +++ b/tests/framework-fixtures/sveltekit/files/src/app.html @@ -0,0 +1,11 @@ + + + + + SvelteKit Fixture + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/tests/framework-fixtures/sveltekit/files/src/routes/+page.svelte b/tests/framework-fixtures/sveltekit/files/src/routes/+page.svelte new file mode 100644 index 000000000..71c48e933 --- /dev/null +++ b/tests/framework-fixtures/sveltekit/files/src/routes/+page.svelte @@ -0,0 +1,8 @@ +
+

SvelteKit Fixture

+

Minimal SvelteKit route for live-mode tests.

+
+
One
+
Two
+
+
diff --git a/tests/framework-fixtures/sveltekit/fixture.json b/tests/framework-fixtures/sveltekit/fixture.json new file mode 100644 index 000000000..8e25e59d4 --- /dev/null +++ b/tests/framework-fixtures/sveltekit/fixture.json @@ -0,0 +1,17 @@ +{ + "name": "SvelteKit", + "config": { + "files": ["src/app.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": ["src/app.html", "src/routes/+page.svelte"], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps hero title in route source", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "src/routes/+page.svelte" + } + ] +} diff --git a/tests/framework-fixtures/sveltekit/gitignore.txt b/tests/framework-fixtures/sveltekit/gitignore.txt new file mode 100644 index 000000000..31fda85b7 --- /dev/null +++ b/tests/framework-fixtures/sveltekit/gitignore.txt @@ -0,0 +1,3 @@ +node_modules/ +.svelte-kit/ +build/ diff --git a/tests/framework-fixtures/vite-react/files/index.html b/tests/framework-fixtures/vite-react/files/index.html new file mode 100644 index 000000000..5ede06283 --- /dev/null +++ b/tests/framework-fixtures/vite-react/files/index.html @@ -0,0 +1,11 @@ + + + + + Vite React Fixture + + +
+ + + diff --git a/tests/framework-fixtures/vite-react/files/src/App.jsx b/tests/framework-fixtures/vite-react/files/src/App.jsx new file mode 100644 index 000000000..46e2a9d79 --- /dev/null +++ b/tests/framework-fixtures/vite-react/files/src/App.jsx @@ -0,0 +1,12 @@ +export default function App() { + return ( +
+

Vite Fixture

+

Minimal React tree for live-mode tests.

+
+
One
+
Two
+
+
+ ); +} diff --git a/tests/framework-fixtures/vite-react/fixture.json b/tests/framework-fixtures/vite-react/fixture.json new file mode 100644 index 000000000..2b03c4268 --- /dev/null +++ b/tests/framework-fixtures/vite-react/fixture.json @@ -0,0 +1,17 @@ +{ + "name": "Vite + React", + "config": { + "files": ["index.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": ["index.html", "src/App.jsx"], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps hero title in source JSX", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "src/App.jsx" + } + ] +} diff --git a/tests/framework-fixtures/vite-react/gitignore.txt b/tests/framework-fixtures/vite-react/gitignore.txt new file mode 100644 index 000000000..3ff38cc06 --- /dev/null +++ b/tests/framework-fixtures/vite-react/gitignore.txt @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.vite/