mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Real-world tests (EAC Next turborepo) confirmed that CSP is the common
blocker for live mode. Adds setup-time detection with a one-time user
consent flow — the patch becomes a permanent, dev-guarded entry in the
user's own config, not a transient add/remove.
## Changes
- New detect-csp.mjs helper: grep-based classifier returning
{ shape, signals }. Shape is one of:
- "shared-helper" (monorepo CSP helper with additional*Src arrays)
- "inline-headers" (literal CSP string in headers())
- "middleware" (response.headers.set in middleware.ts; detect-only v1)
- "meta-tag" (<meta http-equiv>; detect-only v1)
- null (no CSP)
Max depth 6, skips node_modules / build / cache dirs, 64KB per file.
- cspChecked boolean on config.json. First-run setup runs detection;
subsequent runs skip. Users re-trigger by deleting the flag.
Validator accepts it.
- Skill live.md gains:
- CSP detection step in first-time setup (gated by cspChecked)
- Consent-prompt template (so every agent phrases it the same way)
- Shape 1 patch template: append `...__impeccableLiveDev` to
additionalScriptSrc/additionalConnectSrc in the app's config
- Shape 2 patch template: two-point edit — declare a dev-only
variable, interpolate into script-src and connect-src in the
CSP literal string
- Troubleshooting note for "said no but now live doesn't work"
## Fixtures
- nextjs-turborepo/: Turborepo shape (shared CSP helper with
additionalScriptSrc options). Sanitized from a real monorepo so the
patch mechanics get tested against realistic layering. Includes
expected-after-patch.ts for human/agent review.
- nextjs-inline-csp/: app-level next.config.js with a literal CSP
string. Includes expected-after-patch.js showing the Shape 2 edit.
## Tests
Framework-fixture harness extended with a detect-csp shape-classification
assertion per fixture. 42 tests across 7 fixtures pass. Clean fixtures
(vite-react, nextjs-app, astro, sveltekit, multipage-with-generator)
correctly return shape: null.
## Deliberately not doing
- No patches[] array, no marker-based rollback, no add/remove lifecycle.
The patch is a permanent dev-guarded config line — the same kind of
edit a user would make themselves.
- No base URL rewriting or proxy mechanism. Script tag still points at
localhost:8400; CSP permits it once patched. No browser-side changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
194 lines
7.4 KiB
JavaScript
194 lines
7.4 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, 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';
|
|
import { detectCsp } from '../source/skills/impeccable/scripts/detect-csp.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('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}`);
|
|
}
|
|
}
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
}
|