Files
pbakaus_impeccable/tests/live-frameworks.test.mjs
T
Paul BakausandClaude Code a83d767cf9 fix: stop cross-project live session leakage and stale-adapter 401s
Field session on a nested SvelteKit app surfaced a self-reinforcing leak:
localStorage is per-origin, two projects reused 127.0.0.1:5174, and a
React project's leftover cycling session was resumed inside the Svelte
project. Its checkpoints then materialized a ghost session in the new
project's durable store that kept reattaching after every discard, and a
stale adapter module 401'd on live.js, hiding the picker.

Four fixes:

- Server: only session-creating events (generate, steer) may mint a
  journal. Progress events (checkpoints, mount acks, accept/discard) for
  unknown ids are refused with 404 unknown_session and never enqueued, so
  foreign browser state cannot create ghost sessions. Browser sends are
  gated so progress never overtakes its own creating POST (the Go-time
  checkpoint and generate are concurrent fetches; the first sweep caught
  the out-of-order arrival breaking every SvelteKit flow). Steer
  checkpoints now follow the steer event for the same reason.
- Browser: saved sessions carry the server's appRoot; a session stamped
  by another project is dropped at load time. Unstamped legacy state is
  caught by the unknown_session refusal, which clears local state and
  re-arms the picker with an explanatory toast.
- SvelteKit adapter: the layout import carries a token-derived revision
  query so a helper restart changes the module specifier and no Vite
  client/SSR cache can serve an adapter with a rotated-out token;
  live-inject --port reads the running helper's token from server.json
  instead of writing an unauthenticated live.js URL; script load failures
  log an actionable console error; and adapter removal is byte-exact
  (the old regex swallowed the next line's indentation).
- live.mjs resolves surface briefs from appRoot, then contextRoot, then
  repoRoot, matching context.mjs in nested-app repos.

Tests: server unknown-session rejection units, adapter revision/
byte-exact-removal units, and a foreign-session e2e scenario that seeds
another project's localStorage state and asserts it is cleared, no ghost
journal materializes, and picking still works.

AI-assisted (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-07-28 17:38:46 -07:00

492 lines
20 KiB
JavaScript

/**
* Conformance tests for the live-mode framework registry.
*
* Two jobs:
*
* 1. Every entry in FRAMEWORKS satisfies the contract the live scripts rely
* on — a callable detect, a resolvable inject strategy, source traits
* drawn from the closed sets, and undo functions for every patch kind it
* can journal. A new framework module that forgets a field fails here
* rather than at a user's inject.
* 2. detect() lands on the expected entry for the static framework fixtures.
* This is the seed of the conformance battery; behavioral conformance
* (does the injected bundle actually boot in that framework) stays in the
* live-e2e suite. Fixtures are read-only here.
*
* Plus the crash-safe injection journal, which is registry-layer code: it
* heals through the entries' own undo functions.
*
* Run with: node --test tests/live-frameworks.test.mjs
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
COMMENT_SYNTAXES,
FRAMEWORKS,
INJECT_KINDS,
PATCH_UNDOERS,
PREVIEW_MODES,
SOURCE_TRAIT_DEFAULTS,
STYLE_MODES,
TAG_PATCH_KIND,
describeInjectArtifacts,
frameworkIgnorePatterns,
resolveFramework,
resolveSourceTraits,
} from '../skill/scripts/live/frameworks/index.mjs';
import {
clearInjectJournal,
healInjectJournal,
injectJournalPath,
readInjectJournal,
recordInjection,
} from '../skill/scripts/live/frameworks/journal.mjs';
import { insertTag } from '../skill/scripts/live/frameworks/tag-strategy.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = join(__dirname, 'framework-fixtures');
/** Read-only: the fixture's project tree plus its live config. */
function fixtureProject(name) {
const root = join(FIXTURES_DIR, name, 'files');
const manifest = join(FIXTURES_DIR, name, 'fixture.json');
if (!existsSync(root) || !existsSync(manifest)) return null;
return { root, config: JSON.parse(readFileSync(manifest, 'utf-8')).config };
}
// ---------------------------------------------------------------------------
// Contract shape
// ---------------------------------------------------------------------------
describe('framework registry — entry contract', () => {
it('declares the documented detection priority, terminating in static-html', () => {
assert.deepEqual(
FRAMEWORKS.map((f) => f.name),
['sveltekit', 'nuxt', 'tanstack-start', 'astro', 'nextjs', 'vite-generic', 'static-html'],
'detection order is injection priority; live-inject.mjs used to hard-code exactly this',
);
assert.equal(FRAMEWORKS.at(-1).name, 'static-html');
assert.ok(FRAMEWORKS.at(-1).detect(process.cwd(), null), 'the terminal entry must always match');
});
it('gives every entry a unique name', () => {
const names = FRAMEWORKS.map((f) => f.name);
assert.equal(new Set(names).size, names.length);
for (const name of names) {
assert.equal(typeof name, 'string');
assert.ok(name.length > 0);
}
});
for (const framework of FRAMEWORKS) {
describe(`entry · ${framework.name}`, () => {
it('exposes a callable detect', () => {
assert.equal(typeof framework.detect, 'function');
});
it('resolves its inject strategy', () => {
const { inject } = framework;
assert.ok(inject, 'entry declares an inject strategy');
assert.ok(INJECT_KINDS.includes(inject.kind), `unknown inject kind ${inject.kind}`);
if (inject.kind === 'adapter') {
assert.equal(typeof inject.apply, 'function');
assert.equal(typeof inject.remove, 'function');
assert.equal(typeof inject.artifacts, 'function', 'adapters must declare what they write');
assert.equal(typeof inject.ignorePatterns, 'function');
} else {
// The tag strategy is shared; an entry declaring per-framework
// apply/remove alongside kind:'tag' would silently never run.
assert.equal(inject.apply, undefined);
assert.equal(inject.remove, undefined);
}
});
it('registers an undo for every patch kind it can journal', () => {
for (const key of Object.keys(framework.inject.unpatch || {})) {
assert.equal(typeof PATCH_UNDOERS[key], 'function', `no undo registered for ${key}`);
}
});
it('declares source traits from the closed sets', () => {
const { source } = framework;
assert.ok(Array.isArray(source.extensions) && source.extensions.length > 0);
for (const ext of source.extensions) {
assert.match(ext, /^\.[a-z0-9]+$/, 'extensions are lowercase and dotted');
const traits = resolveSourceTraits(`Example${ext}`);
assert.ok(PREVIEW_MODES.includes(traits.preview), `bad preview ${traits.preview}`);
assert.ok(STYLE_MODES.includes(traits.styleMode), `bad styleMode ${traits.styleMode}`);
assert.ok(COMMENT_SYNTAXES.includes(traits.commentSyntax), `bad commentSyntax ${traits.commentSyntax}`);
assert.equal(typeof traits.styleTag, 'string');
assert.equal(typeof traits.injectScriptAttrs, 'string');
}
});
});
}
it('keeps shared extensions in agreement across entries', () => {
// `.tsx` belongs to tanstack-start, nextjs and vite-generic. resolveSourceTraits
// returns the first claimant, so a disagreement would resolve by array
// position instead of by intent.
const seen = new Map();
for (const framework of FRAMEWORKS) {
for (const ext of framework.source.extensions) {
const traits = { ...SOURCE_TRAIT_DEFAULTS, ...framework.source };
delete traits.extensions;
const prior = seen.get(ext);
if (!prior) { seen.set(ext, { name: framework.name, traits }); continue; }
assert.deepEqual(
traits,
prior.traits,
`${framework.name} and ${prior.name} both claim ${ext} but disagree on its traits`,
);
}
}
});
it('falls back to the defaults for an unclaimed extension', () => {
const traits = resolveSourceTraits('src/helpers.ts');
assert.equal(traits.framework, null);
assert.equal(traits.preview, 'source');
assert.equal(traits.styleMode, 'scoped');
assert.equal(traits.commentSyntax, 'html');
assert.equal(traits.injectScriptAttrs, '');
});
it('routes the three authoring modes live-wrap depends on', () => {
assert.equal(resolveSourceTraits('src/routes/+page.svelte').preview, 'component');
assert.equal(resolveSourceTraits('src/pages/index.astro').styleMode, 'astro-global-prefixed');
assert.equal(resolveSourceTraits('src/pages/index.astro').injectScriptAttrs, 'is:inline ');
assert.equal(resolveSourceTraits('app/page.tsx').commentSyntax, 'jsx');
assert.equal(resolveSourceTraits('index.html').commentSyntax, 'html');
assert.equal(resolveSourceTraits('app/app.vue').styleMode, 'scoped');
});
it('registers the generic tag undo', () => {
assert.equal(typeof PATCH_UNDOERS[TAG_PATCH_KIND], 'function');
});
});
// ---------------------------------------------------------------------------
// Detection against the static fixtures (read-only)
// ---------------------------------------------------------------------------
describe('framework registry — fixture detection', () => {
/**
* `sveltekit` is deliberately absent: that fixture is a bare src/app.html +
* one route with no svelte.config and no package.json, and the SvelteKit
* detector has always required one of those. It resolves to static-html and
* takes the generic tag path, which is exactly what live-inject did before
* the registry. `vite8-sveltekit` is the fully-shaped SvelteKit fixture.
*/
const EXPECTED = {
'vite8-sveltekit': 'sveltekit',
'vite8-sveltekit-stateful': 'sveltekit',
'sveltekit-csp': 'sveltekit',
'nuxt-vite7': 'nuxt',
'nuxt-csp': 'nuxt',
'tanstack-start': 'tanstack-start',
// A plain TanStack Router SPA has no @tanstack/react-start and a static
// index.html, so it must NOT take the SSR adapter.
'tanstack-router-vite': 'vite-generic',
astro: 'astro',
'astro-vite7': 'astro',
'nextjs-app': 'nextjs',
'nextjs-app-router': 'nextjs',
'vite8-react-plain': 'vite-generic',
'vite8-react-ts': 'vite-generic',
'multipage-with-generator': 'static-html',
};
for (const [name, expected] of Object.entries(EXPECTED)) {
it(`${name} resolves to ${expected}`, (t) => {
const project = fixtureProject(name);
if (!project) {
t.skip(`fixture ${name} is not present`);
return;
}
const resolved = resolveFramework(project.root, project.config);
assert.ok(resolved, 'a framework always resolves');
assert.equal(resolved.framework.name, expected);
assert.ok(resolved.project, 'detect returns a truthy project descriptor');
});
}
it('the bare sveltekit fixture keeps the pre-registry generic path', () => {
const project = fixtureProject('sveltekit');
if (!project) return;
const resolved = resolveFramework(project.root, project.config);
assert.equal(resolved.framework.name, 'static-html');
assert.equal(resolved.framework.inject.kind, 'tag');
});
it('adapters ask for the gitignore patterns their generated paths need', () => {
const nuxt = fixtureProject('nuxt-vite7');
const resolvedNuxt = resolveFramework(nuxt.root, nuxt.config);
assert.deepEqual(frameworkIgnorePatterns(resolvedNuxt), ['plugins/impeccable-live.client.ts']);
const tanstack = fixtureProject('tanstack-start');
const resolvedTanstack = resolveFramework(tanstack.root, tanstack.config);
assert.deepEqual(
frameworkIgnorePatterns(resolvedTanstack),
['src/impeccable/ImpeccableLiveRoot.tsx'],
);
const vite = fixtureProject('vite8-react-plain');
assert.deepEqual(frameworkIgnorePatterns(resolveFramework(vite.root, vite.config)), []);
});
it('describes adapter artifacts as created files plus patched anchors', () => {
const tanstack = fixtureProject('tanstack-start');
const resolved = resolveFramework(tanstack.root, tanstack.config);
const artifacts = describeInjectArtifacts(resolved, { cwd: tanstack.root, files: [] });
assert.deepEqual(artifacts.map((a) => [a.kind, a.path]), [
['created', 'src/impeccable/ImpeccableLiveRoot.tsx'],
['patched', 'src/routes/__root.tsx'],
]);
for (const artifact of artifacts) {
if (artifact.kind === 'patched') {
assert.equal(typeof PATCH_UNDOERS[artifact.patch], 'function');
assert.ok(artifact.markers.length > 0, 'patched artifacts carry ownership markers');
} else {
assert.ok(artifact.marker, 'created artifacts carry an ownership marker');
}
}
});
it('describes tag-strategy artifacts as one patch per resolved file', () => {
const vite = fixtureProject('vite8-react-plain');
const resolved = resolveFramework(vite.root, vite.config);
const artifacts = describeInjectArtifacts(resolved, { cwd: vite.root, files: ['index.html', 'other.html'] });
assert.deepEqual(artifacts.map((a) => a.path), ['index.html', 'other.html']);
assert.ok(artifacts.every((a) => a.kind === 'patched' && a.patch === TAG_PATCH_KIND));
});
});
// ---------------------------------------------------------------------------
// Crash-safe injection journal
// ---------------------------------------------------------------------------
const PRISTINE_HTML = '<!DOCTYPE html>\n<html>\n <body>\n <h1>Hello</h1>\n </body>\n</html>\n';
// Exactly what an inject leaves behind, so healing has to restore the original
// bytes the same way `--remove` does.
const INJECTED_HTML = insertTag(
PRISTINE_HTML,
{ insertBefore: '</body>', commentSyntax: 'html' },
8400,
undefined,
'',
);
const NUXT_PLUGIN_BODY = '/* impeccable-live-nuxt-plugin */\nexport default defineNuxtPlugin(() => {});\n';
describe('inject journal — crash recovery', () => {
let tmp;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-journal-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
/** Simulate a session that wrote artifacts and was SIGKILLed before stop. */
function stageKilledSession({ withPlugin = true, withTag = true } = {}) {
const artifacts = [];
if (withPlugin) {
mkdirSync(join(tmp, 'app', 'plugins'), { recursive: true });
writeFileSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts'), NUXT_PLUGIN_BODY);
artifacts.push({
kind: 'created',
path: 'app/plugins/impeccable-live.client.ts',
marker: 'impeccable-live-nuxt-plugin',
pruneTo: 'app',
});
}
if (withTag) {
writeFileSync(join(tmp, 'index.html'), INJECTED_HTML);
artifacts.push({
kind: 'patched',
path: 'index.html',
patch: TAG_PATCH_KIND,
markers: ['impeccable-live-start', 'data-impeccable-csp-original'],
});
}
recordInjection(tmp, { framework: 'nuxt', port: 8400, artifacts });
return artifacts;
}
it('writes the journal under .impeccable/live/ so the ignore block covers it', () => {
stageKilledSession();
assert.equal(injectJournalPath(tmp), join(tmp, '.impeccable', 'live', 'inject-journal.json'));
assert.ok(existsSync(injectJournalPath(tmp)));
const journal = readInjectJournal(tmp);
assert.equal(journal.version, 1);
assert.equal(journal.framework, 'nuxt');
assert.equal(journal.port, 8400);
assert.equal(journal.appRoot, resolve(tmp));
assert.equal(journal.artifacts.length, 2);
});
it('heals a SIGKILLed session back to a clean tree', () => {
stageKilledSession();
const { healed } = healInjectJournal(tmp);
assert.deepEqual(
healed.map((h) => [h.path, h.action]).sort(),
[['app/plugins/impeccable-live.client.ts', 'removed'], ['index.html', 'unpatched']].sort(),
);
assert.equal(
existsSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts')),
false,
'the generated plugin is gone',
);
assert.equal(
existsSync(join(tmp, 'app', 'plugins')),
false,
'the generated plugins/ directory is pruned',
);
assert.equal(existsSync(join(tmp, 'app')), true, 'pruning stops at the declared boundary');
assert.equal(
readFileSync(join(tmp, 'index.html'), 'utf-8'),
PRISTINE_HTML,
'the patched entry is restored byte-for-byte, same as --remove would',
);
assert.equal(readInjectJournal(tmp), null, 'a fully healed journal is cleared');
});
it('is idempotent — a second heal finds nothing to do', () => {
stageKilledSession();
healInjectJournal(tmp);
const second = healInjectJournal(tmp);
assert.deepEqual(second.healed, []);
assert.deepEqual(second.kept, []);
assert.equal(existsSync(injectJournalPath(tmp)), false);
});
it('keeps artifacts the current run is about to rewrite', () => {
stageKilledSession();
const { healed, kept } = healInjectJournal(tmp, {
keep: ['app/plugins/impeccable-live.client.ts'],
});
assert.deepEqual(healed.map((h) => h.path), ['index.html']);
assert.deepEqual(kept.map((k) => k.path), ['app/plugins/impeccable-live.client.ts']);
assert.ok(
existsSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts')),
'a kept artifact is left in place so re-injection stays byte-idempotent',
);
const journal = readInjectJournal(tmp);
assert.deepEqual(journal.artifacts.map((a) => a.path), ['app/plugins/impeccable-live.client.ts']);
});
it('disowns a generated file the user has since replaced', () => {
stageKilledSession({ withTag: false });
const pluginPath = join(tmp, 'app', 'plugins', 'impeccable-live.client.ts');
const userBody = 'export default defineNuxtPlugin(() => { /* mine now */ });\n';
writeFileSync(pluginPath, userBody);
const { healed } = healInjectJournal(tmp);
assert.deepEqual(healed, [], 'nothing is reported as healed');
assert.equal(readFileSync(pluginPath, 'utf-8'), userBody, 'the user file survives untouched');
assert.equal(readInjectJournal(tmp), null, 'but we stop claiming it');
});
it('leaves a patched file alone once its markers are gone', () => {
stageKilledSession({ withPlugin: false });
// Whitespace an undo function would normalize, with no marker left.
const handEdited = '<html>\n\n\n <body>\n </body>\n</html>\n';
writeFileSync(join(tmp, 'index.html'), handEdited);
const { healed } = healInjectJournal(tmp);
assert.deepEqual(healed, []);
assert.equal(readFileSync(join(tmp, 'index.html'), 'utf-8'), handEdited);
});
it('tolerates artifacts whose files are already gone', () => {
stageKilledSession({ withTag: false });
rmSync(join(tmp, 'app', 'plugins', 'impeccable-live.client.ts'));
const { healed } = healInjectJournal(tmp);
assert.deepEqual(healed, []);
assert.equal(existsSync(injectJournalPath(tmp)), false);
});
it('no-ops without a journal, and clears cleanly', () => {
assert.deepEqual(healInjectJournal(tmp), { healed: [], kept: [] });
clearInjectJournal(tmp);
assert.equal(readInjectJournal(tmp), null);
});
it('drops the journal file when an injection wrote nothing', () => {
stageKilledSession({ withTag: false });
recordInjection(tmp, { framework: 'static-html', port: 8400, artifacts: [] });
assert.equal(existsSync(injectJournalPath(tmp)), false);
});
});
describe('sveltekit adapter: token revision and byte-exact removal', () => {
let adapter;
beforeEach(async () => {
adapter = await import('../skill/scripts/live/sveltekit-adapter.mjs');
});
const LAYOUT = `<script>
import '../app.css';
let { children } = $props();
</script>
{@render children()}
`;
it('stamps the import with a token-derived revision and swaps it on rotation', () => {
const revA = adapter.svelteAdapterRev('token-a');
const revB = adapter.svelteAdapterRev('token-b');
assert.match(revA, /^[0-9a-f]{8}$/);
assert.notEqual(revA, revB, 'a rotated token must change the module specifier');
const patchedA = adapter.patchSvelteLayout(LAYOUT, { rev: revA });
assert.ok(patchedA.includes(`ImpeccableLiveRoot.svelte?impeccable-live=${revA}'`), 'import carries the revision');
// A re-apply after helper restart replaces the import IN PLACE: exactly
// one import, at the new revision, same indentation. A stale specifier
// is a cached module with a rotated-out token, which 401s on live.js.
const patchedB = adapter.patchSvelteLayout(patchedA, { rev: revB });
const importCount = (patchedB.match(/import ImpeccableLiveRoot/g) || []).length;
assert.equal(importCount, 1, 'rotation must not stack imports');
assert.ok(patchedB.includes(`?impeccable-live=${revB}'`));
assert.ok(!patchedB.includes(`?impeccable-live=${revA}'`));
assert.match(patchedB, /\n import ImpeccableLiveRoot/, 'replacement keeps the original indentation');
});
it('removal restores the layout byte-for-byte, including neighbor indentation', () => {
// The field failure: the old removal regex used \s* and swallowed the
// NEXT line's indentation, de-indenting the user's stylesheet import.
for (const rev of [null, adapter.svelteAdapterRev('some-token')]) {
const patched = adapter.patchSvelteLayout(LAYOUT, { rev });
assert.notEqual(patched, LAYOUT, 'patch must change the layout');
const restored = adapter.unpatchSvelteLayout(patched);
assert.equal(restored, LAYOUT, `removal must be byte-exact (rev=${rev})`);
}
});
it('removal of a created-from-scratch layout leaves no script husk', () => {
const patched = adapter.patchSvelteLayout('', { rev: adapter.svelteAdapterRev('t') });
const restored = adapter.unpatchSvelteLayout(patched);
assert.doesNotMatch(restored, /ImpeccableLiveRoot|impeccable-live-svelte/);
assert.doesNotMatch(restored, /<script>\s*<\/script>/);
});
it('the root component embeds the tokened URL and reports load failures', () => {
const body = adapter.buildSvelteLiveRootComponent(4321, 'tok123');
assert.match(body, /live\.js\?token=tok123/);
assert.match(body, /onerror/, 'a stale-token 401 must be diagnosable from the console');
});
});