1/**
2 * Drives live-mode scripts against representative framework project shapes.
3 *
4 * Each fixture under tests/framework-fixtures/ is a small project tree with a
5 * fixture.json that declares the inject config + expected is-generated and
6 * wrap outcomes. The harness copies the fixture into a tmp git repo, applies
7 * the fixture's gitignore, and runs the live scripts against it.
8 *
9 * Run with: node --test tests/framework-fixtures.test.mjs
10 */
11
12import { describe, it } from 'node:test';
13import assert from 'node:assert/strict';
14import { execFileSync } from 'node:child_process';
15import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
16import { tmpdir } from 'node:os';
17import { join, dirname } from 'node:path';
18import { fileURLToPath } from 'node:url';
19
20import { isGeneratedFile } from '../skill/scripts/is-generated.mjs';
21import { detectCsp } from '../skill/scripts/detect-csp.mjs';
22
23const __dirname = dirname(fileURLToPath(import.meta.url));
24const SCRIPTS_DIR = join(__dirname, '..', 'skill', 'scripts');
25const FIXTURES_DIR = join(__dirname, 'framework-fixtures');
26
27function listFixtures() {
28 return readdirSync(FIXTURES_DIR, { withFileTypes: true })
29 .filter((e) => e.isDirectory())
30 .filter((e) => existsSync(join(FIXTURES_DIR, e.name, 'fixture.json')))
31 .map((e) => e.name);
32}
33
34/**
35 * Stage a fixture into a fresh tmp git repo. Returns the tmp path + loaded
36 * fixture.json. Caller is responsible for cleanup.
37 */
38function stageFixture(name) {
39 const fixtureRoot = join(FIXTURES_DIR, name);
40 const fixture = JSON.parse(readFileSync(join(fixtureRoot, 'fixture.json'), 'utf-8'));
41 const gitignore = readFileSync(join(fixtureRoot, 'gitignore.txt'), 'utf-8');
42
43 const tmp = mkdtempSync(join(tmpdir(), 'impeccable-fixture-'));
44 cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true });
45 writeFileSync(join(tmp, '.gitignore'), gitignore);
46 mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
47 writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
48
49 execFileSync('git', ['init', '-q'], { cwd: tmp });
50 execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp });
51 execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd: tmp });
52 execFileSync('git', ['add', '-A'], { cwd: tmp });
53 execFileSync('git', ['commit', '-qm', 'fixture'], { cwd: tmp });
54
55 return { tmp, fixture };
56}
57
58function runScript(script, args, opts = {}) {
59 try {
60 return execFileSync('node', [join(SCRIPTS_DIR, script), ...args], {
61 encoding: 'utf-8',
62 cwd: opts.cwd,
63 env: { ...process.env, ...(opts.env || {}) },
64 });
65 } catch (err) {
66 return { error: err.stdout?.toString() || '' , stderr: err.stderr?.toString() || '' };
67 }
68}
69
70// ---------------------------------------------------------------------------
71// Tests
72// ---------------------------------------------------------------------------
73
74for (const name of listFixtures()) {
75 describe(`fixture · ${name}`, () => {
76 it('loads fixture.json and has expected tree', () => {
77 const { tmp, fixture } = stageFixture(name);
78 try {
79 assert.ok(fixture.name, 'fixture has a name');
80 assert.ok(Array.isArray(fixture.config.files) && fixture.config.files.length > 0);
81 rmSync(tmp, { recursive: true, force: true });
82 } catch (err) {
83 rmSync(tmp, { recursive: true, force: true });
84 throw err;
85 }
86 });
87
88 it('is-generated classifies files correctly', () => {
89 const { tmp, fixture } = stageFixture(name);
90 try {
91 for (const rel of fixture.sourceFiles || []) {
92 assert.equal(
93 isGeneratedFile(rel, { cwd: tmp }),
94 false,
95 `${rel} should classify as source`
96 );
97 }
98 for (const rel of fixture.generatedFiles || []) {
99 assert.equal(
100 isGeneratedFile(rel, { cwd: tmp }),
101 true,
102 `${rel} should classify as generated`
103 );
104 }
105 } finally {
106 rmSync(tmp, { recursive: true, force: true });
107 }
108 });
109
110 it('live-inject --port adds the script tag to every config file', () => {
111 const { tmp } = stageFixture(name);
112 try {
113 const out = runScript('live-inject.mjs', ['--port', '9999'], { cwd: tmp });
114 const result = JSON.parse(typeof out === 'string' ? out : out.error);
115 assert.equal(result.ok, true, 'inject succeeded');
116 for (const r of result.results) {
117 assert.ok(r.inserted, `${r.file} got the tag (result: ${JSON.stringify(r)})`);
118 const body = readFileSync(join(tmp, r.file), 'utf-8');
119 assert.match(body, /impeccable-live-start/);
120 assert.match(body, /localhost:9999\/live\.js/);
121 }
122 } finally {
123 rmSync(tmp, { recursive: true, force: true });
124 }
125 });
126
127 it('live-inject --remove strips the script tag cleanly', () => {
128 const { tmp } = stageFixture(name);
129 try {
130 runScript('live-inject.mjs', ['--port', '9999'], { cwd: tmp });
131 const out = runScript('live-inject.mjs', ['--remove'], { cwd: tmp });
132 const result = JSON.parse(typeof out === 'string' ? out : out.error);
133 assert.equal(result.ok, true, 'remove succeeded');
134 for (const r of result.results) {
135 const body = readFileSync(join(tmp, r.file), 'utf-8');
136 assert.doesNotMatch(body, /impeccable-live-start/);
137 assert.doesNotMatch(body, /live\.js/);
138 }
139 } finally {
140 rmSync(tmp, { recursive: true, force: true });
141 }
142 });
143
144 it('detect-csp classifies CSP shape correctly', () => {
145 const { tmp, fixture } = stageFixture(name);
146 try {
147 const expected = fixture.csp?.shape ?? null;
148 const result = detectCsp(tmp);
149 assert.equal(
150 result.shape,
151 expected,
152 `expected CSP shape ${expected}, got ${result.shape}; signals: ${JSON.stringify(result.signals)}`
153 );
154 } finally {
155 rmSync(tmp, { recursive: true, force: true });
156 }
157 });
158
159 it('live-wrap routes to the expected source (or emits the expected fallback)', () => {
160 const { tmp, fixture } = stageFixture(name);
161 try {
162 for (const [i, wc] of (fixture.wrapCases || []).entries()) {
163 const flags = [];
164 if (wc.args.elementId) flags.push('--element-id', wc.args.elementId);
165 if (wc.args.classes) flags.push('--classes', wc.args.classes);
166 if (wc.args.tag) flags.push('--tag', wc.args.tag);
167 flags.push('--id', `wraptest${i}`, '--count', '3');
168
169 const out = runScript('live-wrap.mjs', flags, { cwd: tmp });
170 const payload = typeof out === 'string' ? out : (out.error || out.stderr);
171 const parsed = JSON.parse(payload.trim().split('\n').pop());
172
173 if (wc.expectsError) {
174 assert.equal(parsed.error, wc.expectsError, `wrap case "${wc.name}": expected error ${wc.expectsError}, got ${JSON.stringify(parsed)}`);
175 } else {
176 assert.equal(parsed.file, wc.expectedFile, `wrap case "${wc.name}": landed in ${parsed.file}, expected ${wc.expectedFile}`);
177 }
178 }
179 } finally {
180 rmSync(tmp, { recursive: true, force: true });
181 }
182 });
183 });
184}