diff --git a/package.json b/package.json index 0e5cba6e8..ee253fc6e 100644 --- a/package.json +++ b/package.json @@ -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-accept.test.mjs && node --test tests/live-server.test.mjs && node --test tests/framework-fixtures.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-accept.test.mjs && node --test tests/live-inject.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", diff --git a/source/skills/impeccable/scripts/live-inject.mjs b/source/skills/impeccable/scripts/live-inject.mjs index 0a08e0a7f..9bbc345ac 100644 --- a/source/skills/impeccable/scripts/live-inject.mjs +++ b/source/skills/impeccable/scripts/live-inject.mjs @@ -167,15 +167,21 @@ function insertTag(content, config, port) { /** * Remove the live script block. Matches either HTML or JSX comment markers * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. */ function removeTag(content, _syntax) { - // Two patterns: HTML comment markers or JSX comment markers, with any content between. const patterns = [ - /\n?[\s\S]*?\n?/, - /\n?\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}\n?/, + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, ]; for (const pat of patterns) { - const next = content.replace(pat, '\n'); + const next = content.replace(pat, '$1'); if (next !== content) return next; } return content; diff --git a/tests/live-inject.test.mjs b/tests/live-inject.test.mjs new file mode 100644 index 000000000..49f8d2c62 --- /dev/null +++ b/tests/live-inject.test.mjs @@ -0,0 +1,154 @@ +/** + * Tests for live-inject.mjs — script-tag insert/remove round-trip. + * Run with: node --test tests/live-inject.test.mjs + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const INJECT = resolve(__dirname, '..', 'source/skills/impeccable/scripts/live-inject.mjs'); + +function runInject(cwd, configPath, args) { + try { + const out = execFileSync('node', [INJECT, ...args], { + cwd, + encoding: 'utf-8', + env: { ...process.env, IMPECCABLE_LIVE_CONFIG: configPath }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(out.trim()); + } catch (err) { + const body = err.stdout?.toString().trim() || err.stderr?.toString().trim() || ''; + return JSON.parse(body || '{}'); + } +} + +describe('live-inject — insert/remove round-trip preserves file bytes', () => { + let tmp; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-inject-test-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('round-trips an HTML file without mangling indentation', () => { + const original = ` + + Test + +
+

Hello

+
+ + +`; + const file = join(tmp, 'index.html'); + writeFileSync(file, original); + + const config = { + files: ['index.html'], + insertBefore: '', + commentSyntax: 'html', + }; + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify(config)); + + runInject(tmp, cfgPath, ['--port', '8400']); + runInject(tmp, cfgPath, ['--remove']); + + const after = readFileSync(file, 'utf-8'); + assert.equal(after, original, 'file should match original byte-for-byte after insert/remove'); + }); + + it('round-trips a JSX layout without mangling indentation', () => { + // Matches the EAC shape: indented inside a typed RootLayout return. + const original = `export default async function RootLayout({ children }) { + return ( + + + {children} + + + + ); +} +`; + const file = join(tmp, 'layout.tsx'); + writeFileSync(file, original); + + const config = { + files: ['layout.tsx'], + insertBefore: '', + commentSyntax: 'jsx', + }; + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify(config)); + + runInject(tmp, cfgPath, ['--port', '8400']); + runInject(tmp, cfgPath, ['--remove']); + + const after = readFileSync(file, 'utf-8'); + assert.equal(after, original, 'JSX file should match original byte-for-byte after insert/remove'); + }); + + it('round-trips multiple files at once', () => { + const originals = { + 'a.html': ` + +

A

+ + +`, + 'b.html': ` + +

B

+ + +`, + }; + for (const [name, body] of Object.entries(originals)) { + writeFileSync(join(tmp, name), body); + } + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ + files: ['a.html', 'b.html'], + insertBefore: '', + commentSyntax: 'html', + })); + + runInject(tmp, cfgPath, ['--port', '8400']); + runInject(tmp, cfgPath, ['--remove']); + + for (const [name, body] of Object.entries(originals)) { + const after = readFileSync(join(tmp, name), 'utf-8'); + assert.equal(after, body, `${name} should match original byte-for-byte after insert/remove`); + } + }); + + it('round-trips when the insert anchor has no leading indent (column-0 )', () => { + const original = ` + +

Content

+ + +`; + const file = join(tmp, 'flat.html'); + writeFileSync(file, original); + + const cfgPath = join(tmp, 'config.json'); + writeFileSync(cfgPath, JSON.stringify({ + files: ['flat.html'], + insertBefore: '', + commentSyntax: 'html', + })); + + runInject(tmp, cfgPath, ['--port', '8400']); + runInject(tmp, cfgPath, ['--remove']); + + const after = readFileSync(file, 'utf-8'); + assert.equal(after, original, 'column-0 anchor should round-trip cleanly too'); + }); +});