Files
pbakaus_impeccable/tests/framework-fixtures.test.mjs
T
Paul BakausandGitHub 672ca29642 Fix Next.js 16 CSP and parent hook discovery (#710)
* Fix CSP and hook ancestor discovery

Recognize Next.js 16 proxy files when detecting runtime CSP and mirror harness ancestor lookup when locating active hook manifests for nested projects.

AI assistance disclosure: Implemented and verified with Codex under maintainer direction.

* Tighten hook and proxy discovery

AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction.

* Honor ancestor hook disable config

AI assistance disclosure: Codex implemented and verified this fix under maintainer direction.

* Keep hook discovery within target repository

Stop manifest discovery at the target repository boundary instead of re-adding an outer workspace root, with regression coverage for nested Git targets.

AI assistance disclosure: This commit was prepared with Codex under maintainer direction.

* Detect proxy CSP in nested Next apps

Recognize proxy files at root or src placement relative to nested Next project markers while continuing to ignore unrelated proxy helpers.

AI assistance disclosure: This commit was prepared with Codex under maintainer direction.

* Resolve external targets from their own repository

Scope explicit sibling targets to their own Git root so caller context and hook manifests cannot suppress required detector guidance.

AI assistance disclosure: This commit was prepared with Codex under maintainer direction.

* Isolate explicit targets at Git boundaries

Keep nested repositories and external targets out of caller and home-level context or hook discovery.

AI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
2026-09-02 14:10:46 -04:00

323 lines
15 KiB
JavaScript

/**
* 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, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isGeneratedFile } from '../skill/scripts/lib/is-generated.mjs';
import { detectCsp } from '../skill/scripts/detect-csp.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPTS_DIR = join(__dirname, '..', 'skill', 'scripts');
const REPO_ROOT = join(__dirname, '..');
const FIXTURES_DIR = join(__dirname, 'framework-fixtures');
function listFixtures() {
return readdirSync(FIXTURES_DIR, { withFileTypes: true })
.filter((e) => e.isDirectory())
.filter((e) => existsSync(join(FIXTURES_DIR, e.name, 'fixture.json')))
.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);
mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
// The AST scaffolder resolves the app's svelte compiler from the staged
// root; the runtime suite gets it from a real npm install, the static sweep
// links this repo's devDependency so svelte fixtures take the
// component-preview path here too.
const repoSvelte = join(REPO_ROOT, 'node_modules', 'svelte');
if (existsSync(repoSvelte) && !existsSync(join(tmp, 'node_modules', 'svelte'))) {
mkdirSync(join(tmp, 'node_modules'), { recursive: true });
try {
symlinkSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), 'dir');
} catch {
// Windows without Developer Mode cannot symlink; copying is slower but
// keeps the suite runnable there.
cpSync(repoSvelte, join(tmp, 'node_modules', 'svelte'), { recursive: true });
}
}
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 out = runScript('live-inject.mjs', ['--port', '9999'], { cwd: tmp });
const result = JSON.parse(typeof out === 'string' ? out : out.error);
assert.equal(result.ok, true, 'inject succeeded');
assert.equal(result.gitIgnore?.mode, 'git-info-exclude', 'live runtime ignores are installed locally');
const ignored = execFileSync('git', [
'check-ignore',
'.impeccable/live/server.json',
'.impeccable/live/sessions/example.jsonl',
'.impeccable/live/previews/example/v1.html',
'.impeccable/live/artifacts/example-r1.jsx',
'.impeccable/live/accept-receipts/example.json',
'.impeccable/live/locks/example.lock',
'.impeccable/live/deferred-svelte-component-accepts.json',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/a4ac4e74/v3.svelte',
], { cwd: tmp, encoding: 'utf-8' });
assert.match(ignored, /\.impeccable\/live\/server\.json/);
assert.match(ignored, /\.impeccable\/live\/sessions\/example\.jsonl/);
assert.match(ignored, /\.impeccable\/live\/previews\/example\/v1\.html/);
assert.match(ignored, /\.impeccable\/live\/artifacts\/example-r1\.jsx/);
assert.match(ignored, /\.impeccable\/live\/accept-receipts\/example\.json/);
assert.match(ignored, /\.impeccable\/live\/locks\/example\.lock/);
assert.match(ignored, /\.impeccable\/live\/deferred-svelte-component-accepts\.json/);
assert.match(ignored, /src\/lib\/impeccable\/ImpeccableLiveRoot\.svelte/);
assert.match(ignored, /src\/lib\/impeccable\/__runtime\.js/);
assert.match(ignored, /src\/lib\/impeccable\/a4ac4e74\/v3\.svelte/);
if (result.adapter === 'sveltekit') {
const layout = readFileSync(join(tmp, 'src/routes/+layout.svelte'), 'utf-8');
const appHtml = readFileSync(join(tmp, 'src/app.html'), 'utf-8');
const root = readFileSync(join(tmp, 'src/lib/impeccable/ImpeccableLiveRoot.svelte'), 'utf-8');
assert.match(layout, /impeccable-live-svelte-start/, 'SvelteKit layout got the adapter marker');
assert.match(layout, /ImpeccableLiveRoot/, 'SvelteKit layout renders the adapter host');
assert.doesNotMatch(appHtml, /impeccable-live-start/, 'SvelteKit app.html must remain untouched');
assert.doesNotMatch(appHtml, /localhost:9999\/live\.js/, 'SvelteKit app.html must not own live.js');
assert.match(root, /localhost:9999\/live\.js/, 'SvelteKit root component loads live.js');
return;
}
if (result.adapter === 'nuxt') {
const plugin = result.results[0];
const body = readFileSync(join(tmp, plugin.file), 'utf-8');
assert.equal(plugin.inserted, true, 'Nuxt client plugin was created');
assert.match(body, /impeccable-live-nuxt-plugin/);
assert.match(body, /if \(!import\.meta\.dev/);
assert.match(body, /localhost:9999\/live\.js/);
return;
}
if (result.adapter === 'tanstack-start') {
const adapterResult = result.results[0];
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
const component = readFileSync(join(tmp, adapterResult.componentFile), 'utf-8');
assert.equal(adapterResult.inserted, true, 'TanStack Start root document was patched');
assert.match(rootDoc, /impeccable-live-tanstack-start/, 'root document got the adapter marker');
assert.match(rootDoc, /<ImpeccableLiveRoot \/>/, 'root document renders the mount component');
assert.doesNotMatch(rootDoc, /impeccable-live-start/, 'root document must not get the raw script block');
assert.doesNotMatch(rootDoc, /localhost:9999\/live\.js/, 'root document must not own live.js directly');
assert.match(component, /localhost:9999\/live\.js/, 'mount component loads live.js');
assert.match(component, /useEffect/, 'mount component appends the script on mount');
return;
}
for (const r of result.results) {
assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
const body = readFileSync(join(tmp, r.file), 'utf-8');
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 {
runScript('live-inject.mjs', ['--port', '9999'], { cwd: tmp });
const out = runScript('live-inject.mjs', ['--remove'], { cwd: tmp });
const result = JSON.parse(typeof out === 'string' ? out : out.error);
assert.equal(result.ok, true, 'remove succeeded');
if (result.adapter === 'sveltekit') {
const layout = readFileSync(join(tmp, 'src/routes/+layout.svelte'), 'utf-8');
const appHtml = readFileSync(join(tmp, 'src/app.html'), 'utf-8');
assert.doesNotMatch(layout, /ImpeccableLiveRoot/);
assert.doesNotMatch(layout, /impeccable-live-svelte-start/);
assert.doesNotMatch(appHtml, /impeccable-live-start/);
assert.equal(existsSync(join(tmp, 'src/lib/impeccable/ImpeccableLiveRoot.svelte')), false);
return;
}
if (result.adapter === 'nuxt') {
assert.equal(result.results[0].removed, true);
assert.equal(existsSync(join(tmp, result.results[0].file)), false, 'Nuxt client plugin was removed');
return;
}
if (result.adapter === 'tanstack-start') {
const adapterResult = result.results[0];
const rootDoc = readFileSync(join(tmp, adapterResult.file), 'utf-8');
assert.doesNotMatch(rootDoc, /ImpeccableLiveRoot/);
assert.doesNotMatch(rootDoc, /impeccable-live-tanstack-start/);
assert.equal(existsSync(join(tmp, adapterResult.componentFile)), false, 'TanStack mount component was removed');
return;
}
for (const r of result.results) {
const body = readFileSync(join(tmp, r.file), 'utf-8');
assert.doesNotMatch(body, /impeccable-live-start/);
assert.doesNotMatch(body, /live\.js/);
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('detect-csp classifies CSP shape correctly', () => {
const { tmp, fixture } = stageFixture(name);
try {
const expected = fixture.csp?.shape ?? null;
const result = detectCsp(tmp);
assert.equal(
result.shape,
expected,
`expected CSP shape ${expected}, got ${result.shape}; signals: ${JSON.stringify(result.signals)}`
);
} 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}`);
if (wc.expectedSourceFile) {
assert.equal(parsed.sourceFile, wc.expectedSourceFile, `wrap case "${wc.name}": source file`);
}
if (wc.expectedPreviewMode) {
assert.equal(parsed.previewMode, wc.expectedPreviewMode, `wrap case "${wc.name}": preview mode`);
}
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
}
describe('detectCsp — Next.js proxy placement', () => {
it('accepts proxy files at app roots and src roots but ignores same-named helpers', () => {
const source = `export function proxy() {
const response = new Response();
response.headers.set('Content-Security-Policy', "script-src 'self'");
return response;
}\n`;
for (const [relPath, expectedShape, markers = []] of [
['proxy.ts', 'middleware'],
['src/proxy.ts', 'middleware'],
['apps/web/proxy.ts', 'middleware', ['apps/web/app']],
['apps/docs/src/proxy.ts', 'middleware', ['apps/docs/src/pages']],
['apps/store/proxy.ts', 'middleware', ['apps/store/package.json']],
['lib/network/proxy.ts', null],
['apps/web/lib/proxy.ts', null, ['apps/web/app']],
]) {
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-proxy-placement-'));
try {
mkdirSync(dirname(join(tmp, relPath)), { recursive: true });
for (const marker of markers) {
if (marker.endsWith('package.json')) {
mkdirSync(dirname(join(tmp, marker)), { recursive: true });
writeFileSync(join(tmp, marker), JSON.stringify({ dependencies: { next: '^16.0.0' } }));
} else {
mkdirSync(join(tmp, marker), { recursive: true });
}
}
writeFileSync(join(tmp, relPath), source);
assert.equal(detectCsp(tmp).shape, expectedShape, relPath);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
});
});