mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Unit tests of the deleted Node scripts and the JS detector are removed; their behavior is pinned by tests/oracle goldens (frozen JS behavior plus reviewed deltas) and the engine's own tests. tests/oracle.test.mjs replays the corpus against the binary (IMPECCABLE_BIN or skill/scripts/bin/<target>/, via tests/lib/engine-bin.mjs) and skips cleanly without one; the framework fixture sweep drives live-inject, live-wrap, and detect-csp through the binary the same way. record.mjs learns --bin. The function-level vectors under tests/oracle/vectors/calls are committed as the frozen snapshot they can no longer be regenerated from. Suites: core trimmed to build and transformer tests, oracle added to the default run, detector/live reduced to packaging and reference checks, the live-e2e helper tests move to the opt-in live-e2e lane pending its retarget, cli-remote-e2e is an empty placeholder. Prepared with AI assistance (Claude Code).
267 lines
13 KiB
JavaScript
267 lines
13 KiB
JavaScript
/**
|
|
* Drives the live-mode verbs of the engine binary 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 CSP and wrap
|
|
* outcomes. The harness copies the fixture into a tmp git repo, applies the
|
|
* fixture's gitignore, and runs `impeccable live-inject`, `live-wrap`, and
|
|
* `detect-csp` against it. Skips when no engine binary is available (see
|
|
* tests/lib/engine-bin.mjs).
|
|
*
|
|
* 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 { ENGINE_MISSING_MESSAGE, engineEnv, findEngineBinary } from './lib/engine-bin.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = join(__dirname, '..');
|
|
const FIXTURES_DIR = join(__dirname, 'framework-fixtures');
|
|
const ENGINE_BIN = findEngineBinary();
|
|
|
|
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 runVerb(verb, args, opts = {}) {
|
|
try {
|
|
return execFileSync(ENGINE_BIN, [verb, ...args], {
|
|
encoding: 'utf-8',
|
|
cwd: opts.cwd,
|
|
env: engineEnv(ENGINE_BIN, opts.env || {}),
|
|
});
|
|
} catch (err) {
|
|
return { error: err.stdout?.toString() || '' , stderr: err.stderr?.toString() || '' };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
for (const name of listFixtures()) {
|
|
describe(`fixture · ${name}`, { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => {
|
|
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('live-inject --port adds the script tag to every config file', () => {
|
|
const { tmp } = stageFixture(name);
|
|
try {
|
|
const out = runVerb('live-inject', ['--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 {
|
|
runVerb('live-inject', ['--port', '9999'], { cwd: tmp });
|
|
const out = runVerb('live-inject', ['--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 = JSON.parse(runVerb('detect-csp', [], { cwd: 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 = runVerb('live-wrap', 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 });
|
|
}
|
|
});
|
|
});
|
|
}
|