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});