mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
test(live): framework fixture matrix for inject / wrap / is-generated
Five representative project shapes under tests/framework-fixtures/ that stage into fresh tmp git repos and drive the live scripts against each: - vite-react: tracked index.html shell + src/App.jsx - nextjs-app: app/layout.tsx as JSX inject target - astro: src/layouts/Layout.astro - sveltekit: src/app.html shell + src/routes/+page.svelte - multipage-with-generator: src/ tracked, dist/ gitignored (our own repo's shape); exercises the is-generated guard and element_not_in_source fallback Each fixture declares its config, expected source/generated paths, and wrap cases in fixture.json. The harness copies into tmpdir, applies gitignore, commits, then asserts: - inject --port lands the script tag at the correct anchor across all configured files - inject --remove strips it cleanly - is-generated classifies source vs generated paths correctly - wrap routes to the expected source file or emits the expected fallback error Plumbing + bug caught while building out the matrix: - IMPECCABLE_LIVE_CONFIG env var so tests can point live-inject at a fixture-specific config.json without clobbering the harness copy. Backwards-compatible. - live-wrap.mjs no longer hardcodes dist/build in its directory skip list. Only node_modules and .git remain universal skips; the isGeneratedFile check is now the sole guard for generated paths. This lets the includeGenerated second pass find elements in dist/ and report generatedMatch, which is what the multipage-with-generator fixture needs to exercise. Wired into bun run test. 25 tests, 5 suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
37b8e8ba33
commit
c9c152f0f0
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
```
|
||||
<fixture>/
|
||||
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`.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
export interface Props { title: string }
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
<Layout title="Astro Fixture">
|
||||
<main class="page">
|
||||
<h1 class="hero-title">Astro Fixture</h1>
|
||||
<p class="hero-hook">Minimal Astro tree for live-mode tests.</p>
|
||||
<section id="features" class="feature-grid">
|
||||
<article class="feature-card">One</article>
|
||||
<article class="feature-card">Two</article>
|
||||
</section>
|
||||
</main>
|
||||
</Layout>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Astro",
|
||||
"config": {
|
||||
"files": ["src/layouts/Layout.astro"],
|
||||
"insertBefore": "</body>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
@@ -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 `<!DOCTYPE html>
|
||||
<html><head><title>${title}</title></head>
|
||||
<body>${body}</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Multi-page static with generator",
|
||||
"config": {
|
||||
"files": ["dist/index.html", "dist/docs/one.html"],
|
||||
"insertBefore": "</body>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,9 @@
|
||||
export const metadata = { title: 'Next Fixture' };
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1 className="hero-title">Next Fixture</h1>
|
||||
<p className="hero-hook">Minimal App Router tree for live-mode tests.</p>
|
||||
<section id="features" className="feature-grid">
|
||||
<article className="feature-card">One</article>
|
||||
<article className="feature-card">Two</article>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Next.js (App Router)",
|
||||
"config": {
|
||||
"files": ["app/layout.tsx"],
|
||||
"insertBefore": "</body>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>SvelteKit Fixture</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
<main class="page">
|
||||
<h1 class="hero-title">SvelteKit Fixture</h1>
|
||||
<p class="hero-hook">Minimal SvelteKit route for live-mode tests.</p>
|
||||
<section id="features" class="feature-grid">
|
||||
<article class="feature-card">One</article>
|
||||
<article class="feature-card">Two</article>
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "SvelteKit",
|
||||
"config": {
|
||||
"files": ["src/app.html"],
|
||||
"insertBefore": "</body>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite React Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function App() {
|
||||
return (
|
||||
<main className="page">
|
||||
<h1 className="hero-title">Vite Fixture</h1>
|
||||
<p className="hero-hook">Minimal React tree for live-mode tests.</p>
|
||||
<section id="features" className="feature-grid">
|
||||
<article className="feature-card">One</article>
|
||||
<article className="feature-card">Two</article>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Vite + React",
|
||||
"config": {
|
||||
"files": ["index.html"],
|
||||
"insertBefore": "</body>",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
Reference in New Issue
Block a user