live-inject.test.mjs

  1/**
  2 * Tests for live-inject.mjs — script-tag insert/remove round-trip.
  3 * Run with: node --test tests/live-inject.test.mjs
  4 */
  5
  6import { describe, it, beforeEach, afterEach } from 'node:test';
  7import assert from 'node:assert/strict';
  8import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync, rmSync } from 'node:fs';
  9import { dirname, join, resolve } from 'node:path';
 10import { tmpdir } from 'node:os';
 11import { fileURLToPath } from 'node:url';
 12import { execFileSync } from 'node:child_process';
 13
 14const __dirname = dirname(fileURLToPath(import.meta.url));
 15const INJECT = resolve(__dirname, '..', 'skill/scripts/live-inject.mjs');
 16
 17function runInject(cwd, configPath, args) {
 18  try {
 19    const out = execFileSync('node', [INJECT, ...args], {
 20      cwd,
 21      encoding: 'utf-8',
 22      env: { ...process.env, IMPECCABLE_LIVE_CONFIG: configPath },
 23      stdio: ['ignore', 'pipe', 'pipe'],
 24    });
 25    return JSON.parse(out.trim());
 26  } catch (err) {
 27    const body = err.stdout?.toString().trim() || err.stderr?.toString().trim() || '';
 28    return JSON.parse(body || '{}');
 29  }
 30}
 31
 32function runInjectDefault(cwd, args) {
 33  try {
 34    const out = execFileSync('node', [INJECT, ...args], {
 35      cwd,
 36      encoding: 'utf-8',
 37      env: { ...process.env, IMPECCABLE_LIVE_CONFIG: '' },
 38      stdio: ['ignore', 'pipe', 'pipe'],
 39    });
 40    return JSON.parse(out.trim());
 41  } catch (err) {
 42    const body = err.stdout?.toString().trim() || err.stderr?.toString().trim() || '';
 43    return JSON.parse(body || '{}');
 44  }
 45}
 46
 47describe('live-inject — insert/remove round-trip preserves file bytes', () => {
 48  let tmp;
 49  beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-inject-test-')); });
 50  afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
 51
 52  it('reports .impeccable/live/config.json as the default missing config path', () => {
 53    const result = runInjectDefault(tmp, ['--check']);
 54
 55    assert.equal(result.ok, false);
 56    assert.equal(result.error, 'config_missing');
 57    assert.equal(result.path, join(realpathSync(tmp), '.impeccable', 'live', 'config.json'));
 58  });
 59
 60  it('uses .impeccable/live/config.json without an environment override', () => {
 61    const original = `<html>
 62  <body>
 63    <p>Content</p>
 64  </body>
 65</html>
 66`;
 67    writeFileSync(join(tmp, 'index.html'), original);
 68    const configDir = join(tmp, '.impeccable', 'live');
 69    mkdirSync(configDir, { recursive: true });
 70    writeFileSync(join(configDir, 'config.json'), JSON.stringify({
 71      files: ['index.html'],
 72      insertBefore: '</body>',
 73      commentSyntax: 'html',
 74    }));
 75
 76    const inserted = runInjectDefault(tmp, ['--port', '8400']);
 77    assert.equal(inserted.ok, true);
 78    assert.match(readFileSync(join(tmp, 'index.html'), 'utf-8'), /localhost:8400\/live\.js/);
 79
 80    const removed = runInjectDefault(tmp, ['--remove']);
 81    assert.equal(removed.ok, true);
 82    assert.equal(readFileSync(join(tmp, 'index.html'), 'utf-8'), original);
 83  });
 84
 85  it('round-trips an HTML file without mangling indentation', () => {
 86    const original = `<!DOCTYPE html>
 87<html>
 88  <head><title>Test</title></head>
 89  <body>
 90    <main>
 91      <h1>Hello</h1>
 92    </main>
 93  </body>
 94</html>
 95`;
 96    const file = join(tmp, 'index.html');
 97    writeFileSync(file, original);
 98
 99    const config = {
100      files: ['index.html'],
101      insertBefore: '</body>',
102      commentSyntax: 'html',
103    };
104    const cfgPath = join(tmp, 'config.json');
105    writeFileSync(cfgPath, JSON.stringify(config));
106
107    runInject(tmp, cfgPath, ['--port', '8400']);
108    runInject(tmp, cfgPath, ['--remove']);
109
110    const after = readFileSync(file, 'utf-8');
111    assert.equal(after, original, 'file should match original byte-for-byte after insert/remove');
112  });
113
114  it('round-trips a JSX layout without mangling indentation', () => {
115    // Matches the EAC shape: indented </body> inside a typed RootLayout return.
116    const original = `export default async function RootLayout({ children }) {
117  return (
118    <html lang="en">
119      <body>
120        {children}
121        <SpeedInsights />
122      </body>
123    </html>
124  );
125}
126`;
127    const file = join(tmp, 'layout.tsx');
128    writeFileSync(file, original);
129
130    const config = {
131      files: ['layout.tsx'],
132      insertBefore: '</body>',
133      commentSyntax: 'jsx',
134    };
135    const cfgPath = join(tmp, 'config.json');
136    writeFileSync(cfgPath, JSON.stringify(config));
137
138    runInject(tmp, cfgPath, ['--port', '8400']);
139    runInject(tmp, cfgPath, ['--remove']);
140
141    const after = readFileSync(file, 'utf-8');
142    assert.equal(after, original, 'JSX file should match original byte-for-byte after insert/remove');
143  });
144
145  it('round-trips multiple files at once', () => {
146    const originals = {
147      'a.html': `<html>
148  <body>
149    <p>A</p>
150  </body>
151</html>
152`,
153      'b.html': `<html>
154  <body>
155    <p>B</p>
156  </body>
157</html>
158`,
159    };
160    for (const [name, body] of Object.entries(originals)) {
161      writeFileSync(join(tmp, name), body);
162    }
163    const cfgPath = join(tmp, 'config.json');
164    writeFileSync(cfgPath, JSON.stringify({
165      files: ['a.html', 'b.html'],
166      insertBefore: '</body>',
167      commentSyntax: 'html',
168    }));
169
170    runInject(tmp, cfgPath, ['--port', '8400']);
171    runInject(tmp, cfgPath, ['--remove']);
172
173    for (const [name, body] of Object.entries(originals)) {
174      const after = readFileSync(join(tmp, name), 'utf-8');
175      assert.equal(after, body, `${name} should match original byte-for-byte after insert/remove`);
176    }
177  });
178
179  it('round-trips with insertAfter — preserves indented opener line below it', () => {
180    const original = `<!DOCTYPE html>
181<html>
182  <head>
183    <title>Test</title>
184  </head>
185  <body>
186    <main>
187      <h1>Hello</h1>
188    </main>
189  </body>
190</html>
191`;
192    const file = join(tmp, 'index.html');
193    writeFileSync(file, original);
194
195    const cfgPath = join(tmp, 'config.json');
196    writeFileSync(cfgPath, JSON.stringify({
197      files: ['index.html'],
198      insertAfter: '<head>',
199      commentSyntax: 'html',
200    }));
201
202    runInject(tmp, cfgPath, ['--port', '8400']);
203    runInject(tmp, cfgPath, ['--remove']);
204
205    const after = readFileSync(file, 'utf-8');
206    assert.equal(after, original, 'insertAfter round-trip must restore original byte-for-byte');
207  });
208
209  it('round-trips through CSP-meta patch and revert (insert mutates the meta tag, remove restores it)', () => {
210    // Mirrors a Vite app that ships a CSP meta tag in index.html. live-inject
211    // appends `http://localhost:PORT` to script-src / connect-src on insert
212    // and stashes the original directives in `data-impeccable-csp-original`.
213    // --remove must restore the meta tag's original `content` exactly.
214    const original = `<!DOCTYPE html>
215<html>
216  <head>
217    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src 'self';" />
218    <title>CSP test</title>
219  </head>
220  <body>
221    <main>
222      <h1>Hello</h1>
223    </main>
224  </body>
225</html>
226`;
227    const file = join(tmp, 'index.html');
228    writeFileSync(file, original);
229
230    const cfgPath = join(tmp, 'config.json');
231    writeFileSync(cfgPath, JSON.stringify({
232      files: ['index.html'],
233      insertBefore: '</body>',
234      commentSyntax: 'html',
235    }));
236
237    runInject(tmp, cfgPath, ['--port', '8400']);
238    runInject(tmp, cfgPath, ['--remove']);
239
240    const after = readFileSync(file, 'utf-8');
241    assert.equal(after, original, 'CSP meta tag must round-trip exactly through insert+remove');
242  });
243
244  it('round-trips when the insert anchor has no leading indent (column-0 </body>)', () => {
245    const original = `<html>
246<body>
247<p>Content</p>
248</body>
249</html>
250`;
251    const file = join(tmp, 'flat.html');
252    writeFileSync(file, original);
253
254    const cfgPath = join(tmp, 'config.json');
255    writeFileSync(cfgPath, JSON.stringify({
256      files: ['flat.html'],
257      insertBefore: '</body>',
258      commentSyntax: 'html',
259    }));
260
261    runInject(tmp, cfgPath, ['--port', '8400']);
262    runInject(tmp, cfgPath, ['--remove']);
263
264    const after = readFileSync(file, 'utf-8');
265    assert.equal(after, original, 'column-0 anchor should round-trip cleanly too');
266  });
267});