framework-fixtures.test.mjs

  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}