Astro Fixture
+Minimal Astro tree for live-mode tests.
+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
+
+```
+ Minimal Astro tree for live-mode tests.Astro Fixture
+
", + "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 ` +
+